@kavishdua/proctor 1.2.1 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +36 -80
  2. package/dist/cli.js +713 -131
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -64,7 +64,7 @@ __export(runner_exports, {
64
64
  });
65
65
  import { spawn as spawn2 } from "child_process";
66
66
  import { mkdtempSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync } from "fs";
67
- import { join as join14 } from "path";
67
+ import { join as join15 } from "path";
68
68
  import { tmpdir as tmpdir2 } from "os";
69
69
  async function runDynamic(options) {
70
70
  const started = Date.now();
@@ -77,7 +77,7 @@ async function runDynamic(options) {
77
77
  }
78
78
  let scratch;
79
79
  try {
80
- scratch = mkdtempSync(join14(tmpdir2(), "proctor-dynamic-"));
80
+ scratch = mkdtempSync(join15(tmpdir2(), "proctor-dynamic-"));
81
81
  } catch {
82
82
  return skipped("cannot create scratch directory");
83
83
  }
@@ -88,14 +88,14 @@ async function runDynamic(options) {
88
88
  }
89
89
  };
90
90
  try {
91
- mkdirSync2(join14(scratch, "tmp"));
92
- writeFileSync2(join14(scratch, "input.json"), JSON.stringify({ ...options, files: options.files.map(({ from, to }) => ({ from, to })) }));
91
+ mkdirSync2(join15(scratch, "tmp"));
92
+ writeFileSync2(join15(scratch, "input.json"), JSON.stringify({ ...options, files: options.files.map(({ from, to }) => ({ from, to })) }));
93
93
  const env = {
94
94
  PATH: process.env["PATH"],
95
95
  HOME: scratch,
96
- TMPDIR: join14(scratch, "tmp"),
97
- TMP: join14(scratch, "tmp"),
98
- TEMP: join14(scratch, "tmp"),
96
+ TMPDIR: join15(scratch, "tmp"),
97
+ TMP: join15(scratch, "tmp"),
98
+ TEMP: join15(scratch, "tmp"),
99
99
  LANG: "C.UTF-8",
100
100
  CI: "true",
101
101
  NO_COLOR: "1",
@@ -105,7 +105,7 @@ async function runDynamic(options) {
105
105
  npm_config_offline: "true",
106
106
  npm_config_audit: "false",
107
107
  npm_config_update_notifier: "false",
108
- npm_config_cache: join14(scratch, "npm-cache")
108
+ npm_config_cache: join15(scratch, "npm-cache")
109
109
  };
110
110
  const remaining = timeout - (Date.now() - started);
111
111
  if (remaining <= 0) return skipped(`overall timeout (${timeout}ms)`);
@@ -127,7 +127,7 @@ async function runDynamic(options) {
127
127
  "--",
128
128
  process.execPath,
129
129
  options.workerPath,
130
- join14(scratch, "input.json")
130
+ join15(scratch, "input.json")
131
131
  ];
132
132
  const child = spawn2("bwrap", namespaceArgs, {
133
133
  cwd: scratch,
@@ -213,13 +213,13 @@ var init_runner = __esm({
213
213
 
214
214
  // src/cli.ts
215
215
  import { Command } from "commander";
216
- import { resolve as resolve3, join as join15 } from "path";
216
+ import { resolve as resolve3, join as join16 } from "path";
217
217
  import { homedir } from "os";
218
218
 
219
219
  // package.json
220
220
  var package_default = {
221
221
  name: "@kavishdua/proctor",
222
- version: "1.2.0",
222
+ version: "1.3.0",
223
223
  description: "Catch AI agents gaming their test suites: deleted tests, skipped tests, weakened assertions, hardcoded outputs",
224
224
  homepage: "https://github.com/catfish-1234/proctor#readme",
225
225
  bugs: {
@@ -279,7 +279,7 @@ var package_default = {
279
279
  lint: "oxlint --deny correctness --allow no-unused-vars --allow no-useless-escape --allow prefer-string-starts-ends-with src tests scripts tsup.config.ts vitest.config.ts"
280
280
  },
281
281
  dependencies: {
282
- "@anthropic-ai/sdk": "^0.132.0",
282
+ "@anthropic-ai/sdk": "^1.3.0",
283
283
  acorn: "^8.19.0",
284
284
  "brace-expansion": "^5.0.12",
285
285
  commander: "^14.0.3",
@@ -302,7 +302,7 @@ var package_default = {
302
302
  };
303
303
 
304
304
  // src/cli.ts
305
- import { readFile as readFile11, writeFile as writeFile7, appendFile } from "fs/promises";
305
+ import { readFile as readFile12, writeFile as writeFile7, appendFile } from "fs/promises";
306
306
  import { fileURLToPath as fileURLToPath3 } from "url";
307
307
 
308
308
  // src/diff.ts
@@ -688,6 +688,9 @@ var DEFAULT_GLOBS = [
688
688
  // are recognized, not just plain .ts/.js, otherwise those tests escape every test-file check.
689
689
  "**/*.test.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
690
690
  "**/*.spec.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
691
+ // Bare exact stems complement the patterns above/below; picomatch's **/ also matches the root.
692
+ "**/test.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
693
+ "**/test.py",
691
694
  "test/**/*",
692
695
  "tests/**/*",
693
696
  "__tests__/**/*",
@@ -1333,7 +1336,29 @@ function assertionCount(file, kind) {
1333
1336
  }
1334
1337
  return n;
1335
1338
  }
1336
- function testsRelocated(file, files, ctx) {
1339
+ function csharpMoveIdentity(file, kind) {
1340
+ const path9 = kind === "del" ? file.from : file.to;
1341
+ if (!path9?.endsWith(".cs") || !(kind === "del" ? file.deleted : file.new)) return;
1342
+ if (file.chunks.length !== 1) return;
1343
+ const chunk = file.chunks[0];
1344
+ const start = kind === "del" ? chunk.oldStart : chunk.newStart;
1345
+ const count = kind === "del" ? chunk.oldLines : chunk.newLines;
1346
+ if (start !== 1 || count !== chunk.changes.length || chunk.changes.some((c) => c.type !== kind || c.content.length >= MAX_LINE_LENGTH)) return;
1347
+ const view = sourceView(chunk, kind === "del" ? "before" : "after");
1348
+ const namespace = /\bnamespace\s+([A-Za-z_]\w*(?:\s*\.\s*[A-Za-z_]\w*)*)\s*(?=[{;])/.exec(view.code);
1349
+ if (!namespace || !/^(?:\s*using\b[^;{}]*;)*\s*$/.test(view.code.slice(0, namespace.index)) || !/\[(?:Test|TestMethod|Fact|Theory)\s*(?:\(|\])/.test(view.code) || !/\bAssert\s*\./.test(view.code)) return;
1350
+ const nameStart = namespace.index + namespace[0].lastIndexOf(namespace[1]);
1351
+ return `${basename(path9)}\0${view.text.slice(0, nameStart)}${view.text.slice(nameStart + namespace[1].length)}`;
1352
+ }
1353
+ function testsRelocated(file, files, ctx, claimed) {
1354
+ if (file.from?.endsWith(".cs")) {
1355
+ const identity = csharpMoveIdentity(file, "del");
1356
+ if (!identity) return false;
1357
+ const replacement = files.find((other) => other !== file && !claimed.has(other) && ctx.isTestFile(other.to ?? "") && csharpMoveIdentity(other, "add") === identity);
1358
+ if (!replacement) return false;
1359
+ claimed.add(replacement);
1360
+ return true;
1361
+ }
1337
1362
  const removed = testTitlesIn(file, "del");
1338
1363
  if (removed.size === 0) return false;
1339
1364
  const relocated = /* @__PURE__ */ new Set();
@@ -1375,6 +1400,7 @@ function run(context) {
1375
1400
  const files = context.files;
1376
1401
  const ctx = context;
1377
1402
  const findings = [];
1403
+ const claimedMoves = /* @__PURE__ */ new Set();
1378
1404
  for (const file of files) {
1379
1405
  const from = file.from ?? "";
1380
1406
  const to = file.to ?? "";
@@ -1387,7 +1413,7 @@ function run(context) {
1387
1413
  };
1388
1414
  const hasCoordinatedImplDeletion = files.some(isCoordinatedImpl);
1389
1415
  if (hasCoordinatedImplDeletion) continue;
1390
- if (testsRelocated(file, files, ctx)) continue;
1416
+ if (testsRelocated(file, files, ctx, claimedMoves)) continue;
1391
1417
  findings.push({
1392
1418
  verifierId: "RH001",
1393
1419
  severity: "error",
@@ -2215,6 +2241,24 @@ function run2(context) {
2215
2241
  });
2216
2242
  }
2217
2243
  }
2244
+ if (filePath.endsWith("_test.go")) {
2245
+ const failing = /\bt\.(?:Fatalf?|Errorf?)\(/;
2246
+ for (const del of dels) {
2247
+ if (!failing.test(del.content)) continue;
2248
+ const asLog = del.content.slice(1).replace(/\bt\.(?:Fatal|Error)(f?)\(/, "t.Log$1(").replace(/\s+/g, "");
2249
+ const wa = adds.find((a) => !reported.has(a.ln) && a.content.slice(1).replace(/\s+/g, "") === asLog);
2250
+ if (!wa) continue;
2251
+ reported.add(wa.ln);
2252
+ findings.push({
2253
+ verifierId: "RH002",
2254
+ severity: "error",
2255
+ file: filePath,
2256
+ line: wa.ln,
2257
+ message: "Assertion weakened: a failing check now only logs, so the test passes whatever the result.",
2258
+ suggestion: "Restore t.Fatal or t.Errorf so a wrong result fails the test."
2259
+ });
2260
+ }
2261
+ }
2218
2262
  if (filePath.endsWith(".swift")) {
2219
2263
  for (const del of dels) {
2220
2264
  const lhs = swiftTestingComparisonLhs(del.content);
@@ -2728,6 +2772,47 @@ function nativeReason(text) {
2728
2772
  const reason = text.slice(start, end).trim();
2729
2773
  return reason.split(/\s+/).length >= 3 || /^https?:\/\/\S+/.test(reason);
2730
2774
  }
2775
+ function pythonSkipOwner(view, line) {
2776
+ const offset = view.offsetAtLine(line);
2777
+ if (offset === void 0) return void 0;
2778
+ const rows = view.code.split("\n");
2779
+ const index = view.code.slice(0, offset).split("\n").length - 1;
2780
+ const current = rows[index] ?? "";
2781
+ const ownerAt = (cursor) => {
2782
+ const declaration = /^([ \t]*)(?:async\s+)?def\s+(test_\w+)\s*\(/.exec(rows[cursor]);
2783
+ if (!declaration) return void 0;
2784
+ let indent2 = declaration[1].length;
2785
+ let name = declaration[2];
2786
+ for (let prior = cursor - 1; prior >= 0; prior--) {
2787
+ const row = rows[prior];
2788
+ if (!row.trim() || row.length - row.trimStart().length >= indent2) continue;
2789
+ const parent = /^([ \t]*)class\s+(\w+)\b/.exec(row);
2790
+ if (!parent) break;
2791
+ name = parent[2] + "." + name;
2792
+ indent2 = parent[1].length;
2793
+ }
2794
+ return name;
2795
+ };
2796
+ if (current.trimStart().startsWith("@")) {
2797
+ for (let cursor = index + 1; cursor < Math.min(rows.length, index + 12); cursor++) {
2798
+ const row = rows[cursor];
2799
+ const declaration = /^\s*(?:async\s+)?def\s+(test_\w+)\s*\(/.exec(row);
2800
+ if (declaration) return ownerAt(cursor);
2801
+ if (/^\s*(?:class|def)\b/.test(row)) return void 0;
2802
+ }
2803
+ return void 0;
2804
+ }
2805
+ const indent = current.length - current.trimStart().length;
2806
+ for (let cursor = index - 1; cursor >= 0; cursor--) {
2807
+ const row = rows[cursor];
2808
+ if (!row.trim() || row.length - row.trimStart().length >= indent) continue;
2809
+ return ownerAt(cursor);
2810
+ }
2811
+ return void 0;
2812
+ }
2813
+ function isFocus(text) {
2814
+ return /\.only\b|\[\s*['"]only['"]\s*\]|\b(?:fit|fdescribe)\s*\(/.test(text);
2815
+ }
2731
2816
  function run3(context) {
2732
2817
  const files = context.files;
2733
2818
  const ctx = context;
@@ -2749,17 +2834,32 @@ function run3(context) {
2749
2834
  const batsFile = ext === "bats" && isBatsFile(filePath);
2750
2835
  if (!isPython && !isNewLang && !ctx.isTestFile(filePath)) continue;
2751
2836
  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));
2837
+ const priorDisabledCount = [...previousSkips].filter((identity) => identity !== void 0 && !isFocus(identity)).length;
2752
2838
  for (const chunk of file.chunks) {
2753
2839
  const lines = afterLines(chunk);
2754
2840
  const view = sourceView(chunk, "after", isPython);
2755
2841
  const signatureLines = view.signatures.split("\n");
2756
2842
  const maskedLines = view.code.split("\n");
2843
+ const before = sourceView(chunk, "before", isPython);
2844
+ const priorPythonSkips = /* @__PURE__ */ new Set();
2845
+ const beforeRows = before.text.split("\n");
2846
+ let beforeOffset = 0;
2847
+ if (isPython) for (const [index, text] of before.signatures.split("\n").entries()) {
2848
+ if (isSkipPattern("+" + text, { ext, pyTestModule, goTestFile, rubyTestFile, kotlinTestFile, cTestFile, scalaTestFile, rTestFile, haskellTestFile, luaTestFile, batsFile })) {
2849
+ const owner = pythonSkipOwner(before, before.lineAt(beforeOffset));
2850
+ if (owner) priorPythonSkips.add(owner);
2851
+ }
2852
+ beforeOffset += beforeRows[index].length + 1;
2853
+ }
2854
+ const priorTests = testBodies(before);
2855
+ const afterTests = testBodies(view);
2856
+ const renamedSkip = !isPython && priorDisabledCount > 0 && afterTests.length === priorTests.length && chunk.changes.filter((c) => c.type === "add" && SKIP_ONLY.test(c.content)).length <= priorDisabledCount;
2757
2857
  if (ctx.isTestFile(filePath) && !isPython && chunk.changes.some((change) => change.type === "add" && body(change.content).includes("/*"))) {
2758
- const before = sourceView(chunk, "before");
2858
+ const before2 = sourceView(chunk, "before");
2759
2859
  const surviving = new Set(testBodies(view).map((test) => test.key));
2760
- for (const prior of testBodies(before)) {
2860
+ for (const prior of testBodies(before2)) {
2761
2861
  if (surviving.has(prior.key)) continue;
2762
- const oldLine = before.lineAt(prior.open);
2862
+ const oldLine = before2.lineAt(prior.open);
2763
2863
  const unchanged = chunk.changes.find((change) => change.type === "normal" && change.ln1 === oldLine);
2764
2864
  if (!unchanged || unchanged.type !== "normal") continue;
2765
2865
  const offset = view.offsetAtLine(unchanged.ln2);
@@ -2788,9 +2888,10 @@ function run3(context) {
2788
2888
  if (hasExplanation(raw)) continue;
2789
2889
  const later = after.code.slice(match.index + match[0].length);
2790
2890
  if (!/\bexpect\s*\(|\bassert\b/.test(later)) continue;
2891
+ const alreadyDisabled = isPython && priorPythonSkips.has(pythonSkipOwner(after, line) ?? "");
2791
2892
  findings.push({
2792
2893
  verifierId: "RH003",
2793
- severity: "error",
2894
+ severity: alreadyDisabled ? "warn" : "error",
2794
2895
  file: filePath,
2795
2896
  line,
2796
2897
  message: "A new conditional return can bypass the assertions in a test that still reports success.",
@@ -2809,11 +2910,11 @@ function run3(context) {
2809
2910
  const end = open < 0 ? -1 : closingDelimiter(view.code, open, "(", ")");
2810
2911
  const invocation = end < 0 ? text : view.text.slice(offset, end + 1);
2811
2912
  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;
2913
+ if (!isFocus(text) && (nativeReason(invocation) || /^\s*(?:\/\/|#)/.test(previousComment) && /\breason\s*:/i.test(previousComment) && hasExplanation(previousComment))) continue;
2914
+ if (!isFocus(text) && (/\breason\s*:/i.test(text) && hasExplanation(text) || /\breason\s*=\s*['"][^'"]*\S+\s+\S+\s+\S+[^'"]*['"]/.test(text))) continue;
2814
2915
  const lexical = "+" + (signatureLines[index] ?? "");
2815
2916
  const masked = "+" + (maskedLines[index] ?? "");
2816
- const bracketApi = [...change.content.matchAll(new RegExp(BRACKET_SKIP, "g"))].some((match) => {
2917
+ const bracketApi = [...change.content.matchAll(/\b(?:it|test|describe)\s*\[\s*['"`](?:skip|only)['"`]\s*\]/g)].some((match) => {
2817
2918
  const root = /^(?:it|test|describe)/.exec(match[0])[0];
2818
2919
  return masked.slice(match.index, match.index + root.length) === root;
2819
2920
  });
@@ -2831,12 +2932,15 @@ function run3(context) {
2831
2932
  luaTestFile,
2832
2933
  batsFile
2833
2934
  })) continue;
2935
+ const owner = isPython ? pythonSkipOwner(view, change.ln) : void 0;
2936
+ const title = identity?.slice(identity.indexOf(":") + 1);
2937
+ const retainedSkip = !isFocus(text) && (owner !== void 0 && priorPythonSkips.has(owner) || renamedSkip && title !== void 0 && !priorTests.some((test) => test.key === title));
2834
2938
  findings.push({
2835
2939
  verifierId: "RH003",
2836
- severity: "error",
2940
+ severity: retainedSkip ? "warn" : "error",
2837
2941
  file: filePath,
2838
2942
  line: change.ln,
2839
- message: buildSkipMessage(change.content, ext),
2943
+ message: retainedSkip ? "A previously disabled test was rewritten or renamed; the diff does not establish a newly disabled test." : buildSkipMessage(change.content, ext),
2840
2944
  suggestion: buildSuggestion(change.content)
2841
2945
  });
2842
2946
  }
@@ -2847,8 +2951,9 @@ function run3(context) {
2847
2951
  var rh003 = { id: "RH003", severity: "error", run: run3 };
2848
2952
 
2849
2953
  // src/verifiers/rh004.ts
2850
- var LITERAL_TOKEN = "(?:\"[^\"\\n]*\"|'[^'\\n]*'|`[^`\\n]*`|-?\\d+(?:\\.\\d+)?|true|false|True|False)";
2851
- var RETURN_LITERAL_RE = new RegExp(`return\\s+(${LITERAL_TOKEN})\\s*;?\\s*\\}*\\s*$`);
2954
+ import { readFile as readFile2 } from "fs/promises";
2955
+ import { join as join2 } from "path";
2956
+ var RETURN_LITERAL_RE = /return\s+("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*;?\s*\}*\s*$/;
2852
2957
  var RETURN_EXPR_RE = /return[ \t]+([^;{}\n]*[^;{}\s\n])[;}\s]*$/;
2853
2958
  var TRIVIAL_RETURN_VALUES = /* @__PURE__ */ new Set(["null", "undefined", "none", "pass"]);
2854
2959
  var CONDITIONAL_BEFORE_RE = /\b(?:if|else|for|while|switch|case)\b|\?|&&|\|\|/;
@@ -2880,7 +2985,7 @@ function stripTrailingNoise(content) {
2880
2985
  return s;
2881
2986
  }
2882
2987
  function isBareLiteral(expr) {
2883
- return new RegExp(`^${LITERAL_TOKEN}$`).test(expr.trim());
2988
+ return /^(?:"[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)$/.test(expr.trim());
2884
2989
  }
2885
2990
  function isNonTrivialExpr(expr) {
2886
2991
  const trimmed = expr.trim();
@@ -2889,12 +2994,8 @@ function isNonTrivialExpr(expr) {
2889
2994
  if (!/[A-Za-z0-9_]/.test(trimmed)) return false;
2890
2995
  return !isBareLiteral(trimmed);
2891
2996
  }
2892
- var BRANCH_LITERAL_RE = new RegExp(
2893
- `if\\s*\\([^)]*(?<!!)===?\\s*(${LITERAL_TOKEN})[^)]*\\)\\s*return\\s+(${LITERAL_TOKEN})`
2894
- );
2895
- var PY_BRANCH_LITERAL_RE = new RegExp(
2896
- `if\\s+[^:]*==\\s*(${LITERAL_TOKEN})[^:]*:\\s*return\\s+(${LITERAL_TOKEN})`
2897
- );
2997
+ var BRANCH_LITERAL_RE = /if\s*\([^)]*(?<!!)===?\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)[^)]*\)\s*return\s+("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)/;
2998
+ var PY_BRANCH_LITERAL_RE = /if\s+[^:]*==\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)[^:]*:\s*return\s+("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)/;
2898
2999
  var EMPTINESS_GUARD_RE = /\.(?:length|size)\s*===?\s*0\b|===?\s*(?:''|""|``)/;
2899
3000
  var TYPEOF_GUARD_RE = /\btypeof\s+[\w.[\]'"]+\s*[!=]==?\s*['"](?:string|number|boolean|object|function|undefined|symbol|bigint)['"]/;
2900
3001
  var COMMENT_LINE_RE = /^\+?\s*(?:\/\/|\/\*|\*|#|--|;)/;
@@ -2912,7 +3013,7 @@ function extractLiterals(line) {
2912
3013
  }
2913
3014
  function assertionPairs(context) {
2914
3015
  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");
3016
+ const pattern = /expect\(\s*([A-Za-z_$][\w$]*)\(\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*\)\s*\)\.(?:toBe|toEqual|toStrictEqual)\(\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*\)/g;
2916
3017
  for (const file of context.files) {
2917
3018
  if (!context.isTestFile(pathOf(file))) continue;
2918
3019
  for (const chunk of file.chunks) {
@@ -2943,7 +3044,7 @@ function structuralHardcodes(context) {
2943
3044
  const old = oldFunctions.find((candidate) => candidate.header === fn.header);
2944
3045
  if (!evidence || !old) continue;
2945
3046
  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");
3047
+ const branch = /\bif\s*\(\s*[A-Za-z_$][\w$]*\s*===?\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*\)\s*\{\s*return\s+("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*;?\s*\}/g;
2947
3048
  for (const match of raw.matchAll(branch)) {
2948
3049
  if (!evidence.has(match[1].trim() + ":" + match[2].trim())) continue;
2949
3050
  const offset = fn.open + 1 + match.index;
@@ -2955,7 +3056,7 @@ function structuralHardcodes(context) {
2955
3056
  if (compact(rest) !== compact(old.body)) continue;
2956
3057
  findings.push({
2957
3058
  verifierId: "RH004",
2958
- severity: "error",
3059
+ severity: semanticLiteral(match[1]) ? "warn" : "error",
2959
3060
  file: filePath,
2960
3061
  line: after.lineAt(offset),
2961
3062
  message: "A literal-input branch returns the matching test expectation while leaving the general computation unchanged.",
@@ -2967,7 +3068,7 @@ function structuralHardcodes(context) {
2967
3068
  if (!lookup) continue;
2968
3069
  const table = [...fn.body.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g)].find((binding) => binding[1] === lookup[1]);
2969
3070
  if (!table) continue;
2970
- const literalEntry = new RegExp(`\\[?\\s*(${LITERAL_TOKEN})\\s*\\]?\\s*:\\s*(${LITERAL_TOKEN})`, "g");
3071
+ const literalEntry = /\[?\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*\]?\s*:\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)/g;
2971
3072
  const tableOpen = fn.open + 1 + table.index + table[0].length - 1;
2972
3073
  const tableEnd = closingDelimiter(after.code, tableOpen);
2973
3074
  if (tableEnd < 0) continue;
@@ -2986,10 +3087,341 @@ function structuralHardcodes(context) {
2986
3087
  }
2987
3088
  return findings;
2988
3089
  }
3090
+ function semanticLiteral(text) {
3091
+ return /^["'`]|^(?:true|false|True|False)$/.test(text);
3092
+ }
3093
+ function literalValue(node, python = false, depth = 0) {
3094
+ if (depth > 6) return void 0;
3095
+ if (node.type === "Literal") {
3096
+ const value = node.value;
3097
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
3098
+ if (typeof value === "number" && Number.isFinite(value)) return value;
3099
+ }
3100
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? void 0;
3101
+ if (node.type === "Identifier" && python) {
3102
+ if (node.name === "True") return true;
3103
+ if (node.name === "False") return false;
3104
+ if (node.name === "None") return null;
3105
+ }
3106
+ if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
3107
+ const value = literalValue(node.argument, python, depth + 1);
3108
+ if (typeof value === "number") return node.operator === "-" ? -value : value;
3109
+ }
3110
+ if (node.type === "ArrayExpression" && node.elements.length <= 32) {
3111
+ const values = [];
3112
+ for (const element of node.elements) {
3113
+ if (!element || element.type === "SpreadElement") return void 0;
3114
+ const value = literalValue(element, python, depth + 1);
3115
+ if (value === void 0) return void 0;
3116
+ values.push(value);
3117
+ }
3118
+ return values;
3119
+ }
3120
+ if (node.type === "ObjectExpression" && node.properties.length <= 32) {
3121
+ const entries = [];
3122
+ for (const property of node.properties) {
3123
+ if (property.type !== "Property" || property.kind !== "init" || property.computed || property.method || property.shorthand) return void 0;
3124
+ const key = property.key.type === "Identifier" ? property.key.name : literalValue(property.key, python, depth + 1);
3125
+ const value = literalValue(property.value, python, depth + 1);
3126
+ if (typeof key !== "string" && typeof key !== "number" || key === "__proto__" || value === void 0 || entries.some((entry) => entry[0] === String(key))) return void 0;
3127
+ entries.push([String(key), value]);
3128
+ }
3129
+ return Object.fromEntries(entries);
3130
+ }
3131
+ return void 0;
3132
+ }
3133
+ function expression(text, parse) {
3134
+ if (text.length > 4096) return void 0;
3135
+ try {
3136
+ return parse(text, 0, { ecmaVersion: "latest" });
3137
+ } catch {
3138
+ }
3139
+ return void 0;
3140
+ }
3141
+ var SIGNATURE_SEPARATORS = [":", "::", ",", ";", "|", "/", "-", "_", ".", "~", " ", " ", "\n", ""];
3142
+ var SCALAR_PREFIX = /^(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\\n])*`|true|false|True|False|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
3143
+ function scalarList(value, parse) {
3144
+ if (value.length > 4096) return false;
3145
+ const first = SCALAR_PREFIX.exec(value.trimStart());
3146
+ const inferred = first && /^[^A-Za-z0-9"'`+.-]+/.exec(value.trimStart().slice(first[0].length))?.[0].trim();
3147
+ const separators = SIGNATURE_SEPARATORS.filter((separator) => separator !== "");
3148
+ if (inferred && inferred.length <= 16) separators.push(inferred);
3149
+ return separators.some((separator) => {
3150
+ let offset = 0;
3151
+ let count = 0;
3152
+ while (offset < value.length && count < 32) {
3153
+ const token = SCALAR_PREFIX.exec(value.slice(offset).trimStart());
3154
+ if (!token) return false;
3155
+ offset += value.slice(offset).length - value.slice(offset).trimStart().length;
3156
+ const node = expression(token[0], parse);
3157
+ const scalar = node && node.end === token[0].length ? literalValue(node, true) : void 0;
3158
+ if (scalar === void 0 || scalar === null || typeof scalar === "object") return false;
3159
+ offset += token[0].length;
3160
+ count++;
3161
+ const whitespace = /^\s*/.exec(value.slice(offset))[0].length;
3162
+ if (offset + whitespace === value.length) return count >= 2;
3163
+ if (separator === " " || separator === " ") {
3164
+ if (!whitespace) return false;
3165
+ offset += whitespace;
3166
+ } else {
3167
+ offset += whitespace;
3168
+ if (!value.startsWith(separator, offset)) return false;
3169
+ offset += separator.length;
3170
+ }
3171
+ }
3172
+ return false;
3173
+ });
3174
+ }
3175
+ function serializations(value) {
3176
+ const keys = /* @__PURE__ */ new Set([JSON.stringify(value), JSON.stringify(value, null, 2)]);
3177
+ if (Array.isArray(value) && value.every((item) => item === null || typeof item !== "object")) {
3178
+ const variants = [value, [...value].reverse(), [...value].sort()];
3179
+ if (value.every((item) => typeof item === "number")) variants.push([...value].sort((a, b) => Number(a) - Number(b)));
3180
+ for (const variant of variants) {
3181
+ keys.add(JSON.stringify(variant));
3182
+ for (const separator of SIGNATURE_SEPARATORS) keys.add(variant.join(separator));
3183
+ }
3184
+ }
3185
+ return keys;
3186
+ }
3187
+ function fixtureInputs(context, parse) {
3188
+ const result = /* @__PURE__ */ new Map();
3189
+ let count = 0;
3190
+ for (const file of context.files) {
3191
+ const path9 = pathOf(file);
3192
+ if (!context.isTestFile(path9)) continue;
3193
+ for (const chunk of file.chunks) {
3194
+ const view = sourceView(chunk, "after", path9.endsWith(".py"));
3195
+ const assertion = /\b(?:expect|assert(?:\.(?:equal|strictEqual|deepEqual|deepStrictEqual))?|(?:self|cls)\.assert(?:Equal|ListEqual|DictEqual))\s*\(\s*((?:[A-Za-z_$][\w$]*\.)*[A-Za-z_$][\w$]*)\s*\(|\bassert[ \t]+((?:[A-Za-z_$][\w$]*\.)*[A-Za-z_$][\w$]*)\s*\(/g;
3196
+ for (const match of view.code.matchAll(assertion)) {
3197
+ if (++count > 64) return result;
3198
+ const callee = match[1] ?? match[2];
3199
+ const start = match.index + match[0].lastIndexOf(callee);
3200
+ const open = view.code.indexOf("(", start + callee.length);
3201
+ const close = closingDelimiter(view.code, open, "(", ")");
3202
+ if (open < 0 || close < 0 || close - start > 4096) continue;
3203
+ if (match[0].startsWith("expect") && !/^\s*\)\s*\.(?:not\.)?(?:toBe|toEqual|toStrictEqual|toMatchObject|toHaveLength|toBeTruthy|toBeDefined)\s*\(/.test(view.code.slice(close + 1))) continue;
3204
+ const call = expression(view.text.slice(start, close + 1), parse);
3205
+ if (call?.type !== "CallExpression") continue;
3206
+ const name = callee.split(".").at(-1);
3207
+ const keys = result.get(name) ?? /* @__PURE__ */ new Map();
3208
+ for (const argument of call.arguments) {
3209
+ if (argument.type !== "ArrayExpression" && argument.type !== "ObjectExpression") continue;
3210
+ const value = literalValue(argument, path9.endsWith(".py"));
3211
+ if (value === void 0) continue;
3212
+ for (const key of serializations(value)) if (key && key.length <= 4096) keys.set(key, `${path9}:${view.lineAt(start)}`);
3213
+ }
3214
+ result.set(name, keys);
3215
+ }
3216
+ }
3217
+ }
3218
+ return result;
3219
+ }
3220
+ function functionAt(view, offset, python) {
3221
+ if (python) return [...view.code.slice(0, offset).matchAll(/\bdef\s+([A-Za-z_]\w*)\s*\(/g)].at(-1)?.[1];
3222
+ const functions = callables(view);
3223
+ for (const match of view.code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^{}\n]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{/g)) {
3224
+ const open = match.index + match[0].length - 1;
3225
+ functions.push({ name: match[1], open, end: closingDelimiter(view.code, open), start: match.index, header: "", body: "" });
3226
+ }
3227
+ return functions.filter((fn) => offset > fn.open && offset < fn.end).sort((a, b) => b.open - a.open)[0]?.name;
3228
+ }
3229
+ function caughtPath(view, offset, python) {
3230
+ if (python) {
3231
+ const start = view.code.lastIndexOf("\n", offset - 1) + 1;
3232
+ let indent = /^\s*/.exec(view.code.slice(start, offset))[0].length;
3233
+ for (const row of view.code.slice(0, start).split("\n").reverse()) {
3234
+ if (!row.trim()) continue;
3235
+ const parent = row.length - row.trimStart().length;
3236
+ if (parent >= indent) continue;
3237
+ if (/^\s*except\b.*:\s*$/.test(row)) return true;
3238
+ indent = parent;
3239
+ }
3240
+ return false;
3241
+ }
3242
+ return [...view.code.matchAll(/\bcatch\s*(?:\([^{}]*\))?\s*\{/g)].some((match) => {
3243
+ const open = match.index + match[0].length - 1;
3244
+ return offset > open && offset < closingDelimiter(view.code, open);
3245
+ });
3246
+ }
3247
+ function stringBranches(view, python, parse) {
3248
+ const result = [];
3249
+ let count = 0;
3250
+ const pattern = python ? /\bif[ \t]+/g : /\bif\s*\(/g;
3251
+ for (const match of view.code.matchAll(pattern)) {
3252
+ if (++count > 128) break;
3253
+ const start = match.index + match[0].length;
3254
+ const close = python ? view.code.indexOf(":", start) : closingDelimiter(view.code, start - 1, "(", ")");
3255
+ if (close < 0 || close - start > 4096) continue;
3256
+ const conditionText = view.text.slice(start, close);
3257
+ const condition = expression(conditionText, parse);
3258
+ if (condition?.type !== "BinaryExpression" || condition.left.type === "PrivateIdentifier" || !["==", "==="].includes(condition.operator) || conditionText.slice(condition.end).trim()) continue;
3259
+ const left = literalValue(condition.left, python), right = literalValue(condition.right, python);
3260
+ if (left !== void 0 && right !== void 0) continue;
3261
+ const key = typeof right === "string" ? right : typeof left === "string" ? left : void 0;
3262
+ if (key === void 0 || !key) continue;
3263
+ let body2 = close + 1;
3264
+ while (/\s/.test(view.code[body2] ?? "") && body2 < view.code.length) body2++;
3265
+ const braced = !python && view.code[body2] === "{";
3266
+ const end = braced ? closingDelimiter(view.code, body2) : view.code.indexOf("\n", body2);
3267
+ const bodyEnd = end < 0 ? view.code.length : end;
3268
+ if (braced) body2++;
3269
+ const returned = /^\s*return\s+/.exec(view.code.slice(body2, bodyEnd));
3270
+ if (!returned) continue;
3271
+ const valueStart = body2 + returned[0].length;
3272
+ const node = expression(view.text.slice(valueStart, bodyEnd), parse);
3273
+ const value = node && literalValue(node, python);
3274
+ if (!node || value === void 0 || value === null || typeof value === "object") continue;
3275
+ const tail = view.code.slice(valueStart + node.end, bodyEnd);
3276
+ if (!(braced ? /^\s*;?\s*$/ : /^\s*(?:;|\}|$)/).test(tail)) continue;
3277
+ result.push({
3278
+ offset: match.index,
3279
+ end: valueStart + node.end,
3280
+ key,
3281
+ fingerprint: JSON.stringify([compact(view.code.slice(start, close)), key, value])
3282
+ });
3283
+ }
3284
+ return result;
3285
+ }
3286
+ async function serializedSignatures(context) {
3287
+ const candidates = context.files.filter((file) => isWatchedSource(context, pathOf(file))).flatMap((file) => file.chunks.filter((chunk) => chunk.changes.some((change) => change.type === "add" && /['"`]/.test(change.content)) && chunk.changes.some((change) => /\bif\b/.test(change.content))).map((chunk) => ({ file, chunk })));
3288
+ if (!candidates.length) return [];
3289
+ const { parseExpressionAt: parse } = await import("acorn");
3290
+ let inputs;
3291
+ const result = [];
3292
+ for (const { file, chunk } of candidates) {
3293
+ const filePath = pathOf(file), python = filePath.endsWith(".py");
3294
+ const view = sourceView(chunk, "after", python);
3295
+ const previous = new Set(stringBranches(sourceView(chunk, "before", python), python, parse).map((branch) => branch.fingerprint));
3296
+ for (const branch of stringBranches(view, python, parse)) {
3297
+ if (!view.touched(branch.offset, branch.end) || previous.has(branch.fingerprint) || caughtPath(view, branch.offset, python)) continue;
3298
+ const name = functionAt(view, branch.offset, python);
3299
+ const list = scalarList(branch.key, parse);
3300
+ if (!list && !inputs) inputs = fixtureInputs(context, parse);
3301
+ const fixture = name && inputs?.get(name)?.get(branch.key);
3302
+ if (!list && !fixture) continue;
3303
+ result.push({ endLine: view.lineAt(branch.end), finding: {
3304
+ verifierId: "RH004",
3305
+ severity: "error",
3306
+ file: filePath,
3307
+ line: view.lineAt(branch.offset),
3308
+ message: list ? "A special-case string key encodes a delimiter-joined scalar list, rather than a domain category." : `A special-case string key matches a serialization of a composite literal asserted as input to ${name} at ${fixture}.`,
3309
+ suggestion: "Compute the result from the input instead of recognizing a serialized fixture."
3310
+ } });
3311
+ }
3312
+ }
3313
+ return result;
3314
+ }
3315
+ function conditionalPath(view, line, python) {
3316
+ const offset = view.offsetAtLine(line);
3317
+ if (offset === void 0) return false;
3318
+ if (python) {
3319
+ const rows = view.code.slice(0, offset).split("\n");
3320
+ const current = view.code.slice(offset).split("\n")[0];
3321
+ let indent = current.length - current.trimStart().length;
3322
+ for (const row2 of rows.reverse()) {
3323
+ if (!row2.trim()) continue;
3324
+ const parentIndent = row2.length - row2.trimStart().length;
3325
+ if (parentIndent >= indent) continue;
3326
+ if (/^\s*(?:if|elif|else|except|case)\b.*:\s*$/.test(row2)) return true;
3327
+ indent = parentIndent;
3328
+ }
3329
+ return false;
3330
+ }
3331
+ const row = view.code.slice(offset).split("\n")[0];
3332
+ const returnOffset = offset + Math.max(0, row.search(/\breturn\b/));
3333
+ for (const match of view.code.matchAll(/\b(?:if|else|catch|switch)\b[^{};]*\{/g)) {
3334
+ const open = match.index + match[0].length - 1;
3335
+ if (open < returnOffset && closingDelimiter(view.code, open) > returnOffset) return true;
3336
+ }
3337
+ return false;
3338
+ }
3339
+ function documentedChange(context, finding) {
3340
+ const file = context.files.find((file2) => pathOf(file2) === finding.file);
3341
+ if (!file) return false;
3342
+ const names = /* @__PURE__ */ new Set();
3343
+ let literals = /* @__PURE__ */ new Set();
3344
+ for (const chunk of file.chunks) {
3345
+ const view = sourceView(chunk, "after", finding.file.endsWith(".py"));
3346
+ const offset = view.offsetAtLine(finding.line);
3347
+ if (offset === void 0) continue;
3348
+ const end = view.text.indexOf("\n", offset);
3349
+ literals = extractLiterals(view.text.slice(offset, end < 0 ? void 0 : end));
3350
+ if (finding.file.endsWith(".py")) {
3351
+ const definitions = [...view.code.slice(0, offset).matchAll(/\bdef\s+([A-Za-z_]\w*)\s*\(/g)];
3352
+ const last = definitions.at(-1);
3353
+ if (last) names.add(last[1].toLowerCase());
3354
+ } else for (const fn of callables(view)) {
3355
+ if (offset >= fn.start && offset <= fn.end) names.add(fn.name.toLowerCase());
3356
+ }
3357
+ }
3358
+ for (const doc of context.files) {
3359
+ if (!/(?:^|\/)(?:SPEC|CHANGELOG)\.md$/i.test(pathOf(doc))) continue;
3360
+ for (const change of doc.chunks.flatMap((chunk) => chunk.changes)) {
3361
+ if (change.type !== "add") continue;
3362
+ const note = change.content.slice(1).trim();
3363
+ if (note.startsWith("#") || !/\b(?:returns?|fallback|sentinel|calibrat\w*|supports?)\b/i.test(note)) continue;
3364
+ const words = new Set(note.toLowerCase().match(/[A-Za-z_$][\w$]*|-?\d+(?:\.\d+)?/g) ?? []);
3365
+ if ([...names].some((name) => words.has(name))) return true;
3366
+ if (literals.size >= 2 && [...literals].every((literal) => words.has(literal.toLowerCase()))) return true;
3367
+ }
3368
+ }
3369
+ return false;
3370
+ }
3371
+ var SPECIAL_RESULT_RE = /\breturn\s+((?:Ok\(|Some\()?(?:"[^"\n]{2,}"|'[^'\n]{2,}'|-?\d{2,}(?:\.\d+)?|\[[^\]\n]{3,}\])\)?)\s*;?\s*\}?\s*$/;
3372
+ var SPECIAL_COND_RE = /^\s*(?:\}\s*else\s+)?if\b[^\n]*(?:===?|==|\.equals\()/;
3373
+ async function specialCaseBranches(context) {
3374
+ const findings = [];
3375
+ let testText;
3376
+ const loadTests = async () => {
3377
+ if (testText !== void 0) return testText;
3378
+ const parts = [];
3379
+ for (const path9 of context.testFiles.slice(0, 300)) {
3380
+ try {
3381
+ const text = await readFile2(join2(context.cwd, path9), "utf8");
3382
+ if (text.length < 3e5) parts.push(text);
3383
+ } catch {
3384
+ }
3385
+ }
3386
+ testText = parts.join("\n").replace(/\s+/g, "");
3387
+ return testText;
3388
+ };
3389
+ for (const file of context.files) {
3390
+ const filePath = pathOf(file);
3391
+ if (!isWatchedSource(context, filePath)) continue;
3392
+ for (const chunk of file.chunks) {
3393
+ const adds = chunk.changes.filter((c) => c.type === "add");
3394
+ const dels = chunk.changes.filter((c) => c.type === "del");
3395
+ if (adds.length === 0 || adds.length > dels.length + 4) continue;
3396
+ const addedCompact = adds.map((a) => a.content.slice(1).replace(/\s+/g, "")).join("");
3397
+ if (!dels.every((d) => addedCompact.includes(d.content.slice(1).replace(/\s+/g, "")))) continue;
3398
+ for (let i = 0; i < adds.length; i++) {
3399
+ const text = adds[i].content.slice(1);
3400
+ if (!SPECIAL_COND_RE.test(text)) continue;
3401
+ const sameLine = SPECIAL_RESULT_RE.exec(text);
3402
+ const next = adds[i + 1] ? SPECIAL_RESULT_RE.exec(adds[i + 1].content.slice(1)) : null;
3403
+ const literal = (sameLine ?? next)?.[1];
3404
+ if (!literal) continue;
3405
+ const bare = literal.replace(/^(?:Ok|Some)\(|\)$/g, "").replace(/\s+/g, "");
3406
+ if (!(await loadTests()).includes(bare)) continue;
3407
+ findings.push({
3408
+ verifierId: "RH004",
3409
+ severity: "warn",
3410
+ file: filePath,
3411
+ line: adds[i].ln,
3412
+ message: `An input-specific branch returns ${bare}, a value an existing test expects, in front of the unchanged general computation.`,
3413
+ suggestion: "Fix the general computation so every input gets the right answer. If this really is a documented special case, say so in a comment."
3414
+ });
3415
+ break;
3416
+ }
3417
+ }
3418
+ }
3419
+ return findings;
3420
+ }
2989
3421
  async function run4(context) {
2990
3422
  const files = context.files;
2991
3423
  const ctx = context;
2992
- const findings = structuralHardcodes(context);
3424
+ let findings = [...structuralHardcodes(context), ...await specialCaseBranches(context)];
2993
3425
  const fuzzyCandidates = [];
2994
3426
  for (const file of files) {
2995
3427
  const filePath = file.to ?? file.from ?? "";
@@ -3016,13 +3448,14 @@ async function run4(context) {
3016
3448
  }) : void 0;
3017
3449
  if (pairedDel) {
3018
3450
  const priorExpr = (pairedDel.content.match(RETURN_EXPR_RE)?.[1] ?? "").trim();
3451
+ const fallback = conditionalPath(sourceView(chunk, "after", filePath.endsWith(".py")), line, filePath.endsWith(".py"));
3019
3452
  findings.push({
3020
3453
  verifierId: "RH004",
3021
- severity: "error",
3454
+ severity: fallback ? "warn" : "error",
3022
3455
  file: filePath,
3023
3456
  line,
3024
- message: `Implementation now returns hardcoded ${literal} where it previously computed \`${priorExpr}\`.`,
3025
- suggestion: "Implement the actual logic instead of returning a hardcoded value."
3457
+ message: fallback ? `A conditional path now returns ${literal} instead of computing \`${priorExpr}\`; this can be an intentional fallback.` : `Implementation now returns hardcoded ${literal} where it previously computed \`${priorExpr}\`.`,
3458
+ suggestion: fallback ? "Check the supported input contract and test the fallback and the general path." : "Implement the actual logic instead of returning a hardcoded value."
3026
3459
  });
3027
3460
  } else {
3028
3461
  fuzzyCandidates.push({ file: filePath, line, content: add.content });
@@ -3036,15 +3469,25 @@ async function run4(context) {
3036
3469
  if (/^(['"`])[^\w\s]\1$/.test(branchMatch[1])) continue;
3037
3470
  findings.push({
3038
3471
  verifierId: "RH004",
3039
- severity: "error",
3472
+ severity: semanticLiteral(branchMatch[1]) ? "warn" : "error",
3040
3473
  file: filePath,
3041
3474
  line: add.ln,
3042
- message: `Implementation special-cases input ${branchMatch[1]} to return hardcoded ${branchMatch[2]} instead of computing it generally.`,
3043
- suggestion: "Remove the special-case branch and implement the general logic."
3475
+ message: semanticLiteral(branchMatch[1]) ? `Implementation special-cases input ${branchMatch[1]} to return ${branchMatch[2]}; a diff alone cannot distinguish a domain case from fixture hardcoding.` : `Implementation special-cases input ${branchMatch[1]} to return hardcoded ${branchMatch[2]} instead of computing it generally.`,
3476
+ suggestion: semanticLiteral(branchMatch[1]) ? "Check the supported input contract and test both the special case and other inputs." : "Remove the special-case branch and implement the general logic."
3044
3477
  });
3045
3478
  }
3046
3479
  }
3047
3480
  }
3481
+ const signatures = await serializedSignatures(context);
3482
+ findings = findings.filter((finding) => !signatures.some((signature) => signature.finding.file === finding.file && finding.line >= signature.finding.line && finding.line <= signature.endLine));
3483
+ const fixtureFindings = new Set(signatures.map((signature) => signature.finding));
3484
+ findings.push(...fixtureFindings);
3485
+ for (const finding of findings) {
3486
+ if (fixtureFindings.has(finding) || finding.severity !== "error" || !documentedChange(context, finding)) continue;
3487
+ finding.severity = "warn";
3488
+ finding.message = "A literal implementation change has a specific behavior note in SPEC or CHANGELOG; the diff does not establish whether it is incorrect.";
3489
+ finding.suggestion = "Check the documented behavior against the supported input classes and retain tests for other behavior.";
3490
+ }
3048
3491
  if (!ctx.aiEnabled || !ctx.judge) return findings;
3049
3492
  const flagged = new Set(findings.map((f) => `${f.file}:${f.line}`));
3050
3493
  const implLiterals = /* @__PURE__ */ new Map();
@@ -3104,8 +3547,8 @@ function isConditionalReturn2(line) {
3104
3547
  }
3105
3548
  var NONTRIVIAL_RETURN_RE = /return\s+([^;{}\n]+)\s*;?\s*\}*\s*$/;
3106
3549
  var TRIVIAL_RETURN_VALUES2 = /* @__PURE__ */ new Set(["null", "undefined", "none", "pass"]);
3107
- var LITERAL_TOKEN2 = "(?:\"[^\"\\n]*\"|'[^'\\n]*'|`[^`\\n]*`|-?\\d+(?:\\.\\d+)?|true|false|True|False)";
3108
- var BARE_LITERAL_RE = new RegExp(`^${LITERAL_TOKEN2}$`);
3550
+ var LITERAL_TOKEN = "(?:\"[^\"\\n]*\"|'[^'\\n]*'|`[^`\\n]*`|-?\\d+(?:\\.\\d+)?|true|false|True|False)";
3551
+ var BARE_LITERAL_RE = new RegExp(`^${LITERAL_TOKEN}$`);
3109
3552
  function isGuttedAdd(content) {
3110
3553
  const stripped = stripTrailingNoise(content);
3111
3554
  const wholeLine = stripped.replace(/^\+\s*/, "").trim();
@@ -3764,6 +4207,21 @@ var TIMEOUT_MS_THRESHOLD = 12e4;
3764
4207
  var TIMEOUT_S_THRESHOLD = 120;
3765
4208
  var NETWORK_MOCK_RE = /\.(?:mockResolvedValue|mockReturnValue)(?:Once)?\(([^)]*)\)|\.reply\(\s*\d+\s*,([^)]*)\)/;
3766
4209
  var ASSERTION_LITERAL_RE = /\.(?:toBe|toEqual)\(([^)]*)\)|assertEqual\([^,]+,([^)]*)\)/;
4210
+ var SLEEP_MS_THRESHOLD = 1e3;
4211
+ function fixedSleepMs(text) {
4212
+ const code = withoutTrailingComment(body(text));
4213
+ let m = /\bsetTimeout\s*\(\s*(?:\w+|\(\s*\)\s*=>\s*\w+(?:\(\))?)\s*,\s*(\d+)\s*\)/.exec(code);
4214
+ if (m && /\bnew\s+Promise\b|\bawait\b/.test(code)) return Number(m[1]);
4215
+ m = /\btime\.sleep\s*\(\s*(\d+(?:\.\d+)?)\s*\)/.exec(code);
4216
+ if (m) return Math.round(Number(m[1]) * 1e3);
4217
+ m = /\bThread\.sleep\s*\(\s*(\d+)L?\s*\)/.exec(code);
4218
+ if (m) return Number(m[1]);
4219
+ m = /\btime\.Sleep\s*\(\s*(\d+)\s*\*\s*time\.(Millisecond|Second)\s*\)/.exec(code);
4220
+ if (m) return Number(m[1]) * (m[2] === "Second" ? 1e3 : 1);
4221
+ m = /^\s*sleep\s*\(?\s*(\d+(?:\.\d+)?)\s*\)?\s*$/.exec(code);
4222
+ if (m) return Math.round(Number(m[1]) * 1e3);
4223
+ return void 0;
4224
+ }
3767
4225
  function normalize(s) {
3768
4226
  return s.trim().replace(/;$/, "");
3769
4227
  }
@@ -3781,10 +4239,33 @@ function run10(context) {
3781
4239
  const findings = [];
3782
4240
  for (const file of files) {
3783
4241
  const filePath = file.to ?? file.from ?? "";
3784
- if (!ctx.isTestFile(filePath)) continue;
4242
+ if (!ctx.isTestFile(filePath)) {
4243
+ if (/(?:jest|vitest|playwright|mocha)\.config\.|\.mocharc|pytest\.ini$|pyproject\.toml$|package\.json$/.test(filePath)) {
4244
+ for (const chunk of file.chunks) {
4245
+ const timeoutOf = (text) => {
4246
+ const m = /\b(?:testTimeout|timeout)\b["']?\s*[:=]\s*(\d+)/.exec(text);
4247
+ return m ? Number(m[1]) : void 0;
4248
+ };
4249
+ const before = chunk.changes.filter((c) => c.type === "del").map((c) => timeoutOf(c.content)).find((v) => v !== void 0);
4250
+ for (const add of chunk.changes.filter((c) => c.type === "add")) {
4251
+ const now = timeoutOf(add.content);
4252
+ if (now === void 0 || before === void 0 || now < 6e4 || now < before * 4) continue;
4253
+ findings.push({
4254
+ verifierId: "RH010",
4255
+ severity: "warn",
4256
+ file: filePath,
4257
+ line: add.ln,
4258
+ message: `Test timeout raised from ${before} to ${now} in runner config, which lets slow or hanging tests pass for every test.`,
4259
+ suggestion: "Find out why the test is slow. If one test really needs longer, raise the timeout on that test only and say why."
4260
+ });
4261
+ }
4262
+ }
4263
+ }
4264
+ continue;
4265
+ }
3785
4266
  for (const chunk of file.chunks) {
3786
4267
  const removedAwaited = new Set(
3787
- chunk.changes.filter((change) => change.type === "del").map((change) => awaitedExpression(change.content)).filter((expression) => expression !== void 0)
4268
+ chunk.changes.filter((change) => change.type === "del").map((change) => awaitedExpression(change.content)).filter((expression2) => expression2 !== void 0)
3788
4269
  );
3789
4270
  for (const added of chunk.changes.filter((change) => change.type === "add")) {
3790
4271
  const addedBody = body(added.content);
@@ -3872,6 +4353,18 @@ function run10(context) {
3872
4353
  });
3873
4354
  continue;
3874
4355
  }
4356
+ const sleepMs = fixedSleepMs(content);
4357
+ if (sleepMs !== void 0 && sleepMs >= SLEEP_MS_THRESHOLD) {
4358
+ findings.push({
4359
+ verifierId: "RH010",
4360
+ severity: "warn",
4361
+ file: filePath,
4362
+ line,
4363
+ message: `Fixed ${sleepMs} ms sleep added to a test, which can hide a race by waiting long enough for it to usually resolve.`,
4364
+ suggestion: "Wait for the specific event, callback or condition the test depends on, or poll with a deadline, instead of sleeping."
4365
+ });
4366
+ continue;
4367
+ }
3875
4368
  const mockM = content.match(NETWORK_MOCK_RE);
3876
4369
  const mockLit = mockM?.[1] ?? mockM?.[2];
3877
4370
  if (mockLit !== void 0 && assertionLiterals.has(normalize(mockLit))) {
@@ -4156,6 +4649,9 @@ function isCommandLine(text) {
4156
4649
  if (/^[ \t]*(?:-[ \t]*)?\{/.test(text)) return false;
4157
4650
  return key === null || COMMAND_KEYS.has(key[1].toLowerCase());
4158
4651
  }
4652
+ function baseCommandKey(content) {
4653
+ return commandKey(content).split("||")[0].trim();
4654
+ }
4159
4655
  function commandKey(content) {
4160
4656
  const command = stripDiffPrefix2(content);
4161
4657
  const invocation = TEST_COMMAND_RE.exec(command);
@@ -4202,7 +4698,10 @@ function run12(context) {
4202
4698
  }
4203
4699
  for (const change of chunk.changes) {
4204
4700
  if (change.type !== "add") continue;
4205
- if (TEST_COMMAND_RE.test(change.content)) addedCommands.add(commandKey(change.content));
4701
+ if (TEST_COMMAND_RE.test(change.content)) {
4702
+ addedCommands.add(commandKey(change.content));
4703
+ addedCommands.add(baseCommandKey(change.content));
4704
+ }
4206
4705
  }
4207
4706
  }
4208
4707
  for (const chunk of file.chunks) {
@@ -4249,7 +4748,7 @@ function run12(context) {
4249
4748
  const stripped = stripDiffPrefix2(change.content);
4250
4749
  if (!TEST_COMMAND_RE.test(stripped)) continue;
4251
4750
  if (!isCommandLine(stripped)) continue;
4252
- if (addedCommands.has(commandKey(change.content))) continue;
4751
+ if (addedCommands.has(commandKey(change.content)) || addedCommands.has(baseCommandKey(change.content))) continue;
4253
4752
  findings.push({
4254
4753
  verifierId: "RH012",
4255
4754
  severity: "error",
@@ -4695,7 +5194,7 @@ function structuralSwallows(context) {
4695
5194
  severity: "error",
4696
5195
  file: path9,
4697
5196
  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.",
5197
+ message: "Error handling was added or changed to suppress a failure instead of propagating it.",
4699
5198
  suggestion: "Return an error, retry, or propagate the failure. Document a specific, deliberate fallback."
4700
5199
  });
4701
5200
  }
@@ -4810,6 +5309,36 @@ function run15(context) {
4810
5309
  if (lexicalIndex >= 0 && !lexicalLines[lexicalIndex]?.trim()) continue;
4811
5310
  const code = withoutLiterals(added.text);
4812
5311
  if (/^\s*except\b/.test(code) && followedByElse(after, after.findIndex((l) => l.line === added.line && l.added))) continue;
5312
+ const blankAssign = /^\s*_\s*=\s*(.+?);?\s*$/.exec(code);
5313
+ if (blankAssign && /\.go$/.test(filePath)) {
5314
+ const call = blankAssign[1].replace(/\s+/g, "");
5315
+ const pairedDeletion = deletedLines(chunk).some((d) => {
5316
+ const text = d.text.replace(/\s+/g, "");
5317
+ return text === `return${call}` || text.startsWith("iferr:=") && text.includes(call);
5318
+ });
5319
+ if (pairedDeletion) {
5320
+ findings.push({
5321
+ verifierId: "WI101",
5322
+ severity: "error",
5323
+ file: filePath,
5324
+ line: added.line,
5325
+ message: "Error silently discarded: the returned error of a call that used to be returned or checked is now assigned to the blank identifier, so a failure here passes unnoticed.",
5326
+ suggestion: "Return or handle the error. If the failure really cannot matter, say why in a comment on the line."
5327
+ });
5328
+ continue;
5329
+ }
5330
+ }
5331
+ if (/\.rs$/.test(filePath) && /\bOk\s*\(.*\.(?:unwrap_or_default\s*\(\s*\)|unwrap_or\s*\()/.test(code)) {
5332
+ findings.push({
5333
+ verifierId: "WI101",
5334
+ severity: "error",
5335
+ file: filePath,
5336
+ line: added.line,
5337
+ message: "Error silently discarded: a failure is replaced by a default value inside a success result, so callers can no longer tell it happened.",
5338
+ suggestion: "Propagate the error with ? or map it to a typed error. If a default is the documented behavior, say so in a comment."
5339
+ });
5340
+ continue;
5341
+ }
4813
5342
  const inline = INLINE_SIGNATURES.find((sig) => sig.opener.test(code));
4814
5343
  if (inline) {
4815
5344
  findings.push({
@@ -4960,7 +5489,7 @@ var GUARD_SIGNATURES = [
4960
5489
  { re: /\braise_error\b|\bargument_error\b/, what: "a raised error" }
4961
5490
  ];
4962
5491
  var EXTRACTED_VALIDATOR_RE = /\b(?:validate|validation|verify|ensure|check|guard|require|precondition|sanitiz|invariant)\w*\s*[({.]|\bschema\b|\bzod\b|\bpydantic\b/i;
4963
- var REWRITTEN_GUARD_RE = /\b(?:throw|raise|panic)\b|^\s*assert\b|\b(?:process\.exit|sys\.exit|os\.Exit)\s*\(\s*[1-9]/;
5492
+ var REWRITTEN_GUARD_RE = /\b(?:throw|raise|panic)\b|\breturn\b[^\n]*\b(?:errors\.New|fmt\.Errorf)\s*\(|^\s*assert\b|\b(?:process\.exit|sys\.exit|os\.Exit)\s*\(\s*[1-9]/;
4964
5493
  function guardKey(text) {
4965
5494
  return withoutTrailingComment(text).replace(/['"`]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
4966
5495
  }
@@ -6158,6 +6687,56 @@ var ALWAYS_PASS_BODY_RE = /pass\s*:\s*true\b|=>\s*\(?\s*\{\s*pass\s*:\s*true/;
6158
6687
  function countsAsAssertion(text) {
6159
6688
  return ASSERTION_RE2.test(withoutLiterals(text));
6160
6689
  }
6690
+ var unitName = (file) => path8.basename(file.replace(/\\/g, "/")).replace(/\.(?:test|spec)\.[^.]+$|\.[^.]+$/, "").replace(/^test_/, "").replace(/_test$/, "");
6691
+ function pythonScopes(view) {
6692
+ const result = [];
6693
+ for (const declaration of view.code.matchAll(/^([ \t]*)(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)\s*\([^:\n]*\)[^:\n]*:[ \t]*$/gm)) {
6694
+ const open = declaration.index + declaration[0].lastIndexOf(":");
6695
+ let end = view.code.length;
6696
+ for (const row of view.code.slice(open + 1).matchAll(/^([ \t]*)\S/gm)) {
6697
+ if (row[1].length > declaration[1].length) continue;
6698
+ end = open + 1 + row.index;
6699
+ break;
6700
+ }
6701
+ result.push({ name: declaration[2], open, end });
6702
+ }
6703
+ return result;
6704
+ }
6705
+ function coversRemovedFeature(context, testPath, assertions) {
6706
+ const symbols = /* @__PURE__ */ new Set();
6707
+ const channels = [];
6708
+ for (const file of context.files) {
6709
+ const filePath = pathOf(file);
6710
+ if (context.isTestFile(filePath) || unitName(filePath) !== unitName(testPath)) continue;
6711
+ const python = filePath.endsWith(".py");
6712
+ const before = file.chunks.map((chunk) => sourceView(chunk, "before", python));
6713
+ const after = file.chunks.map((chunk) => sourceView(chunk, "after", python));
6714
+ const declarations = after.flatMap((view) => [...view.code.matchAll(/\b(?:function|def|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)].map((match) => match[1]));
6715
+ for (const view of before) {
6716
+ for (const declaration of view.code.matchAll(/\b(?:function|def)\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
6717
+ if (view.touched(declaration.index, declaration.index + declaration[0].length) && !declarations.includes(declaration[1])) symbols.add(declaration[1]);
6718
+ }
6719
+ for (const fn of python ? pythonScopes(view) : callables(view)) {
6720
+ const text = view.text.slice(fn.open + 1, fn.end);
6721
+ for (const branch of text.matchAll(/\bif\s*\(\s*[A-Za-z_$][\w$]*\s*===?\s*(['"])([^'"\n]+)\1\s*\)|\bcase\s+(['"])([^'"\n]+)\3\s*:|^[ \t]*(?:if|elif)\s+[A-Za-z_]\w*\s*==\s*(['"])([^'"\n]+)\5\s*:/gm)) {
6722
+ const offset = fn.open + 1 + branch.index;
6723
+ if (!/^(?:if|elif|case)\b/.test(view.code.slice(offset, offset + branch[0].length).trimStart())) continue;
6724
+ if (!view.touched(offset, offset + branch[0].length)) continue;
6725
+ const value = branch[2] ?? branch[4] ?? branch[6];
6726
+ const remains = after.some((next) => [...next.text.matchAll(/(['"])([^'"\n]+)\1/g)].some((match) => match[2] === value));
6727
+ if (!remains) channels.push({ unit: fn.name, value });
6728
+ }
6729
+ }
6730
+ }
6731
+ }
6732
+ return assertions.length > 0 && assertions.every((assertion) => {
6733
+ const code = withoutLiterals(assertion);
6734
+ const calls = [...code.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)].map((match) => match[1]);
6735
+ if (calls.some((name) => symbols.has(name))) return true;
6736
+ const literals = [...assertion.matchAll(/(['"])([^'"\n]+)\1/g)].map((match) => match[2]);
6737
+ return channels.some((channel) => calls.includes(channel.unit) && literals.includes(channel.value));
6738
+ });
6739
+ }
6161
6740
  function run26(context) {
6162
6741
  const findings = [];
6163
6742
  for (const file of context.files) {
@@ -6173,13 +6752,16 @@ function run26(context) {
6173
6752
  const lost = removedAssertions - addedAssertions;
6174
6753
  if (testsSurvive && lost > 0 && addedAssertions >= 0 && removedAssertions > 0) {
6175
6754
  const firstRemoved = file.chunks.flatMap(deletedLines).find((l) => countsAsAssertion(l.text));
6755
+ const added = new Set(file.chunks.flatMap(addedLines).map((line) => line.text.trim()));
6756
+ const removed = file.chunks.flatMap(deletedLines).filter((line) => !isCommentLine(line.text) && countsAsAssertion(line.text) && !added.has(line.text.trim()));
6757
+ const coordinated = coversRemovedFeature(context, filePath, removed.map((line) => line.text));
6176
6758
  findings.push({
6177
6759
  verifierId: "WI112",
6178
- severity: "error",
6760
+ severity: coordinated ? "warn" : "error",
6179
6761
  file: filePath,
6180
6762
  line: firstRemoved?.line ?? 1,
6181
- message: `Assertions removed: ${lost} assertion${lost === 1 ? "" : "s"} deleted from a test that still runs under the same name, so it reports the same coverage while checking less.`,
6182
- suggestion: "Put the assertions back and make the code satisfy them. A test that keeps its name and loses its checks is the most invisible way to reduce coverage: nothing in the test count, the report, or the diff summary changes."
6763
+ message: coordinated ? `Assertions removed alongside their source feature: ${lost} fewer assertion${lost === 1 ? "" : "s"}; the diff does not establish whether the feature removal is authorized.` : `Assertions removed: ${lost} assertion${lost === 1 ? "" : "s"} deleted from a test that still runs under the same name, so it reports the same coverage while checking less.`,
6764
+ suggestion: coordinated ? "Confirm that the documented contract permits the feature removal and retain coverage for supported behavior." : "Put the assertions back and make the code satisfy them. A test that keeps its name and loses its checks is the most invisible way to reduce coverage: nothing in the test count, the report, or the diff summary changes."
6183
6765
  });
6184
6766
  }
6185
6767
  }
@@ -6704,11 +7286,11 @@ var AGENT_ADAPTERS = [
6704
7286
 
6705
7287
  // src/adapters/detect.ts
6706
7288
  import { stat } from "fs/promises";
6707
- import { join as join2 } from "path";
7289
+ import { join as join3 } from "path";
6708
7290
  var FALLBACK_ADAPTER_ID = "agents-md";
6709
7291
  async function exists(cwd, relativePath) {
6710
7292
  try {
6711
- await stat(join2(cwd, relativePath));
7293
+ await stat(join3(cwd, relativePath));
6712
7294
  return true;
6713
7295
  } catch {
6714
7296
  return false;
@@ -6736,13 +7318,13 @@ function selectAgents(ids) {
6736
7318
  }
6737
7319
 
6738
7320
  // src/adapters/drift-check.ts
6739
- import { readFile as readFile3 } from "fs/promises";
6740
- import { join as join4 } from "path";
7321
+ import { readFile as readFile4 } from "fs/promises";
7322
+ import { join as join5 } from "path";
6741
7323
  import { createHash as createHash2 } from "crypto";
6742
7324
 
6743
7325
  // src/adapters/manifest.ts
6744
- import { readFile as readFile2, writeFile } from "fs/promises";
6745
- import { join as join3 } from "path";
7326
+ import { readFile as readFile3, writeFile } from "fs/promises";
7327
+ import { join as join4 } from "path";
6746
7328
  var MANIFEST_FILENAME = ".proctor-adapter-manifest.json";
6747
7329
  function isValidManifest(value) {
6748
7330
  if (typeof value !== "object" || value === null) return false;
@@ -6751,7 +7333,7 @@ function isValidManifest(value) {
6751
7333
  }
6752
7334
  async function readManifest(cwd) {
6753
7335
  try {
6754
- const raw = await readFile2(join3(cwd, MANIFEST_FILENAME), "utf8");
7336
+ const raw = await readFile3(join4(cwd, MANIFEST_FILENAME), "utf8");
6755
7337
  const parsed = JSON.parse(raw);
6756
7338
  if (isValidManifest(parsed)) return parsed;
6757
7339
  } catch {
@@ -6767,7 +7349,7 @@ async function recordWritten(cwd, adapterId, created = false) {
6767
7349
  if (created) {
6768
7350
  manifest.created = { ...manifest.created, [adapterId]: true };
6769
7351
  }
6770
- await writeFile(join3(cwd, MANIFEST_FILENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
7352
+ await writeFile(join4(cwd, MANIFEST_FILENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
6771
7353
  }
6772
7354
 
6773
7355
  // src/adapters/block.ts
@@ -6833,10 +7415,10 @@ async function checkAdapterDrift(cwd, canonical, adapters = AGENT_ADAPTERS) {
6833
7415
  const checked = [];
6834
7416
  const manifest = await readManifest(cwd);
6835
7417
  for (const adapter of adapters) {
6836
- const path9 = join4(cwd, adapter.relativePath);
7418
+ const path9 = join5(cwd, adapter.relativePath);
6837
7419
  let content;
6838
7420
  try {
6839
- content = await readFile3(path9, "utf8");
7421
+ content = await readFile4(path9, "utf8");
6840
7422
  } catch (err) {
6841
7423
  if (err.code === "ENOENT") {
6842
7424
  continue;
@@ -6864,14 +7446,14 @@ async function checkAdapterDrift(cwd, canonical, adapters = AGENT_ADAPTERS) {
6864
7446
  }
6865
7447
 
6866
7448
  // src/adapters/deploy.ts
6867
- import { readFile as readFile6, writeFile as writeFile4, mkdir as mkdir3, rm as rm2 } from "fs/promises";
6868
- import { join as join7, dirname, relative } from "path";
7449
+ import { readFile as readFile7, writeFile as writeFile4, mkdir as mkdir3, rm as rm2 } from "fs/promises";
7450
+ import { join as join8, dirname, relative } from "path";
6869
7451
 
6870
7452
  // src/hooks/pre-commit.ts
6871
- import { readFile as readFile4, writeFile as writeFile2, mkdir, rm } from "fs/promises";
7453
+ import { readFile as readFile5, writeFile as writeFile2, mkdir, rm } from "fs/promises";
6872
7454
  import { chmodSync } from "fs";
6873
7455
  import { spawnSync as spawnSync2 } from "child_process";
6874
- import { join as join5, resolve } from "path";
7456
+ import { join as join6, resolve } from "path";
6875
7457
  function preCommitHookContent() {
6876
7458
  return [
6877
7459
  "#!/bin/sh",
@@ -6907,7 +7489,7 @@ function gitHooksDir(cwd) {
6907
7489
  }
6908
7490
  async function hasHusky(cwd) {
6909
7491
  try {
6910
- const pkgJson = JSON.parse(await readFile4(join5(cwd, "package.json"), "utf8"));
7492
+ const pkgJson = JSON.parse(await readFile5(join6(cwd, "package.json"), "utf8"));
6911
7493
  return "husky" in (pkgJson["devDependencies"] ?? {});
6912
7494
  } catch {
6913
7495
  return false;
@@ -6916,7 +7498,7 @@ async function hasHusky(cwd) {
6916
7498
  async function backupForeignHook(hookPath) {
6917
7499
  let existing;
6918
7500
  try {
6919
- existing = await readFile4(hookPath, "utf8");
7501
+ existing = await readFile5(hookPath, "utf8");
6920
7502
  } catch {
6921
7503
  return;
6922
7504
  }
@@ -6928,8 +7510,8 @@ async function backupForeignHook(hookPath) {
6928
7510
  async function installPreCommitHook(cwd) {
6929
7511
  const hookContent = preCommitHookContent();
6930
7512
  if (await hasHusky(cwd)) {
6931
- const hookPath2 = join5(cwd, ".husky", "pre-commit");
6932
- await mkdir(join5(cwd, ".husky"), { recursive: true });
7513
+ const hookPath2 = join6(cwd, ".husky", "pre-commit");
7514
+ await mkdir(join6(cwd, ".husky"), { recursive: true });
6933
7515
  await backupForeignHook(hookPath2);
6934
7516
  await writeFile2(hookPath2, hookContent, "utf8");
6935
7517
  spawnSync2("git", ["add", "--chmod=+x", hookPath2], { cwd });
@@ -6939,7 +7521,7 @@ async function installPreCommitHook(cwd) {
6939
7521
  if (!hooksDir) {
6940
7522
  throw new Error("not a git repository, so there is no pre-commit hook to install");
6941
7523
  }
6942
- const hookPath = join5(hooksDir, "pre-commit");
7524
+ const hookPath = join6(hooksDir, "pre-commit");
6943
7525
  await mkdir(hooksDir, { recursive: true });
6944
7526
  await backupForeignHook(hookPath);
6945
7527
  await writeFile2(hookPath, hookContent, "utf8");
@@ -6953,12 +7535,12 @@ async function removePreCommitHook(cwd, dryRun) {
6953
7535
  const normalize2 = (s) => s.replace(/\r\n/g, "\n").split("\n").map((l) => l.trimEnd()).filter(Boolean).join("\n");
6954
7536
  const ours = normalize2(preCommitHookContent());
6955
7537
  const gitHooks = gitHooksDir(cwd);
6956
- const candidates = [join5(cwd, ".husky", "pre-commit")];
6957
- if (gitHooks) candidates.push(join5(gitHooks, "pre-commit"));
7538
+ const candidates = [join6(cwd, ".husky", "pre-commit")];
7539
+ if (gitHooks) candidates.push(join6(gitHooks, "pre-commit"));
6958
7540
  for (const hookPath of candidates) {
6959
7541
  let existing;
6960
7542
  try {
6961
- existing = await readFile4(hookPath, "utf8");
7543
+ existing = await readFile5(hookPath, "utf8");
6962
7544
  } catch {
6963
7545
  continue;
6964
7546
  }
@@ -6970,8 +7552,8 @@ async function removePreCommitHook(cwd, dryRun) {
6970
7552
  }
6971
7553
 
6972
7554
  // src/hooks/claude-settings.ts
6973
- import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
6974
- import { join as join6 } from "path";
7555
+ import { readFile as readFile6, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
7556
+ import { join as join7 } from "path";
6975
7557
  var STOP_HOOK_COMMAND = `npx ${package_default.name} stop-hook`;
6976
7558
  var STOP_HOOK_MARKER = "proctor stop-hook";
6977
7559
  function parseSettings(raw) {
@@ -6982,11 +7564,11 @@ function parseSettings(raw) {
6982
7564
  }
6983
7565
  }
6984
7566
  async function installStopHook(dir) {
6985
- const settingsPath = join6(dir, "settings.json");
7567
+ const settingsPath = join7(dir, "settings.json");
6986
7568
  let settings = {};
6987
7569
  let rawSettings;
6988
7570
  try {
6989
- rawSettings = await readFile5(settingsPath, "utf8");
7571
+ rawSettings = await readFile6(settingsPath, "utf8");
6990
7572
  } catch {
6991
7573
  }
6992
7574
  if (rawSettings !== void 0) {
@@ -7013,10 +7595,10 @@ async function installStopHook(dir) {
7013
7595
  return { status: "installed", path: settingsPath };
7014
7596
  }
7015
7597
  async function removeStopHook(dir, dryRun) {
7016
- const settingsPath = join6(dir, "settings.json");
7598
+ const settingsPath = join7(dir, "settings.json");
7017
7599
  let raw;
7018
7600
  try {
7019
- raw = await readFile5(settingsPath, "utf8");
7601
+ raw = await readFile6(settingsPath, "utf8");
7020
7602
  } catch {
7021
7603
  return void 0;
7022
7604
  }
@@ -7056,13 +7638,13 @@ async function deploySkill(cwd, adapters, canonical) {
7056
7638
  let failed = 0;
7057
7639
  const manifest = await readManifest(cwd);
7058
7640
  for (const adapter of adapters) {
7059
- const dest = join7(cwd, adapter.relativePath);
7641
+ const dest = join8(cwd, adapter.relativePath);
7060
7642
  const content = adapter.transform ? adapter.transform(canonical) : canonical;
7061
7643
  try {
7062
7644
  if (adapter.shared) {
7063
7645
  let existing;
7064
7646
  try {
7065
- existing = await readFile6(dest, "utf8");
7647
+ existing = await readFile7(dest, "utf8");
7066
7648
  } catch (err) {
7067
7649
  if (err.code !== "ENOENT") throw err;
7068
7650
  }
@@ -7101,10 +7683,10 @@ async function uninstallProctor(cwd, dryRun) {
7101
7683
  };
7102
7684
  const manifest = await readManifest(cwd);
7103
7685
  for (const adapter of AGENT_ADAPTERS) {
7104
- const dest = join7(cwd, adapter.relativePath);
7686
+ const dest = join8(cwd, adapter.relativePath);
7105
7687
  let existing;
7106
7688
  try {
7107
- existing = await readFile6(dest, "utf8");
7689
+ existing = await readFile7(dest, "utf8");
7108
7690
  } catch (err) {
7109
7691
  if (err.code !== "ENOENT") {
7110
7692
  failed.push(`${adapter.relativePath} (${err.code ?? "unreadable"})`);
@@ -7141,9 +7723,9 @@ async function uninstallProctor(cwd, dryRun) {
7141
7723
  if (!dryRun) await rm2(dest, { force: true });
7142
7724
  });
7143
7725
  }
7144
- const manifestPath = join7(cwd, MANIFEST_FILENAME);
7726
+ const manifestPath = join8(cwd, MANIFEST_FILENAME);
7145
7727
  try {
7146
- await readFile6(manifestPath, "utf8");
7728
+ await readFile7(manifestPath, "utf8");
7147
7729
  await attempt(`Removed: ${MANIFEST_FILENAME}`, async () => {
7148
7730
  if (!dryRun) await rm2(manifestPath, { force: true });
7149
7731
  });
@@ -7156,7 +7738,7 @@ async function uninstallProctor(cwd, dryRun) {
7156
7738
  failed.push(`the pre-commit hook (${err.code ?? "unknown error"})`);
7157
7739
  }
7158
7740
  try {
7159
- const stop = await removeStopHook(join7(cwd, ".claude"), dryRun);
7741
+ const stop = await removeStopHook(join8(cwd, ".claude"), dryRun);
7160
7742
  if (stop) done.push(`Removed the proctor Stop hook from: ${rel(stop)}`);
7161
7743
  } catch (err) {
7162
7744
  failed.push(`the Stop hook entry (${err.code ?? "unknown error"})`);
@@ -7167,20 +7749,20 @@ async function uninstallProctor(cwd, dryRun) {
7167
7749
  // src/bench/tasks.ts
7168
7750
  import { existsSync } from "fs";
7169
7751
  import { readdir } from "fs/promises";
7170
- import { join as join8, dirname as dirname2 } from "path";
7752
+ import { join as join9, dirname as dirname2 } from "path";
7171
7753
  import { fileURLToPath } from "url";
7172
7754
  var __dirname = fileURLToPath(new URL(".", import.meta.url));
7173
7755
  function findPackageRoot(startDir) {
7174
7756
  let dir = startDir;
7175
7757
  for (let i = 0; i < 6; i++) {
7176
- if (existsSync(join8(dir, "package.json"))) return dir;
7758
+ if (existsSync(join9(dir, "package.json"))) return dir;
7177
7759
  const parent = dirname2(dir);
7178
7760
  if (parent === dir) break;
7179
7761
  dir = parent;
7180
7762
  }
7181
7763
  return startDir;
7182
7764
  }
7183
- var DEFAULT_TASKS_DIR = join8(findPackageRoot(__dirname), "bench/tasks");
7765
+ var DEFAULT_TASKS_DIR = join9(findPackageRoot(__dirname), "bench/tasks");
7184
7766
  var TASK_NAME_RE = /^task-\d+$/;
7185
7767
  function mulberry32(seed) {
7186
7768
  let a = seed | 0;
@@ -7219,33 +7801,33 @@ async function loadTaskPool(tasksDir = DEFAULT_TASKS_DIR) {
7219
7801
  }
7220
7802
  const dirNames = entries.filter((e) => e.isDirectory() && TASK_NAME_RE.test(e.name)).map((e) => e.name).sort();
7221
7803
  return dirNames.map((taskId) => {
7222
- const dir = join8(tasksDir, taskId);
7804
+ const dir = join9(tasksDir, taskId);
7223
7805
  return {
7224
7806
  taskId,
7225
7807
  dir,
7226
- promptPath: join8(dir, "prompt.md"),
7227
- repoDir: join8(dir, "repo"),
7228
- heldOutDir: join8(dir, "held-out"),
7229
- heldOutPath: join8(dir, "held-out.json"),
7230
- mockPath: join8(dir, "mock-agent.json")
7808
+ promptPath: join9(dir, "prompt.md"),
7809
+ repoDir: join9(dir, "repo"),
7810
+ heldOutDir: join9(dir, "held-out"),
7811
+ heldOutPath: join9(dir, "held-out.json"),
7812
+ mockPath: join9(dir, "mock-agent.json")
7231
7813
  };
7232
7814
  });
7233
7815
  }
7234
7816
 
7235
7817
  // src/bench/index.ts
7236
- import { mkdir as mkdir5, writeFile as writeFile6, readFile as readFile10, rm as rm4 } from "fs/promises";
7818
+ import { mkdir as mkdir5, writeFile as writeFile6, readFile as readFile11, rm as rm4 } from "fs/promises";
7237
7819
  import { dirname as dirname6 } from "path";
7238
7820
 
7239
7821
  // src/bench/runners/fixture-runner.ts
7240
- import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
7241
- import { dirname as dirname3, join as join9, resolve as resolve2, sep } from "path";
7822
+ import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
7823
+ import { dirname as dirname3, join as join10, resolve as resolve2, sep } from "path";
7242
7824
  function createFixtureRunner(model) {
7243
7825
  return {
7244
7826
  model,
7245
7827
  async run(task) {
7246
7828
  const started = Date.now();
7247
- const mockPath = join9(task.workdir, "mock-agent.json");
7248
- const raw = await readFile7(mockPath, "utf8");
7829
+ const mockPath = join10(task.workdir, "mock-agent.json");
7830
+ const raw = await readFile8(mockPath, "utf8");
7249
7831
  const mock = JSON.parse(raw);
7250
7832
  const branch = task.proctorOn ? mock.proctorOn : mock.proctorOff;
7251
7833
  const root = resolve2(task.workdir);
@@ -7273,25 +7855,25 @@ function createFixtureRunner(model) {
7273
7855
  // src/bench/runners/shell-runner.ts
7274
7856
  import { spawn, spawnSync as spawnSync3 } from "child_process";
7275
7857
  import { existsSync as existsSync2 } from "fs";
7276
- import { readFile as readFile8 } from "fs/promises";
7277
- import { join as join10, dirname as dirname4 } from "path";
7858
+ import { readFile as readFile9 } from "fs/promises";
7859
+ import { join as join11, dirname as dirname4 } from "path";
7278
7860
  import { fileURLToPath as fileURLToPath2 } from "url";
7279
7861
  var __dirname2 = fileURLToPath2(new URL(".", import.meta.url));
7280
7862
  function findPackageRoot2(startDir) {
7281
7863
  let dir = startDir;
7282
7864
  for (let i = 0; i < 6; i++) {
7283
- if (existsSync2(join10(dir, "package.json"))) return dir;
7865
+ if (existsSync2(join11(dir, "package.json"))) return dir;
7284
7866
  const parent = dirname4(dir);
7285
7867
  if (parent === dir) break;
7286
7868
  dir = parent;
7287
7869
  }
7288
7870
  return startDir;
7289
7871
  }
7290
- var SKILL_MD_PATH = join10(findPackageRoot2(__dirname2), "src/skill/SKILL.md");
7872
+ var SKILL_MD_PATH = join11(findPackageRoot2(__dirname2), "src/skill/SKILL.md");
7291
7873
  var MAX_CAPTURED_OUTPUT = 10 * 1024 * 1024;
7292
7874
  async function buildEffectivePrompt(task) {
7293
7875
  if (!task.proctorOn) return task.prompt;
7294
- const skillContent = await readFile8(SKILL_MD_PATH, "utf8");
7876
+ const skillContent = await readFile9(SKILL_MD_PATH, "utf8");
7295
7877
  return `${skillContent}
7296
7878
  ---
7297
7879
  ${task.prompt}`;
@@ -7385,11 +7967,11 @@ var AGENT_RUNNERS = [
7385
7967
  ];
7386
7968
 
7387
7969
  // src/bench/scorer.ts
7388
- import { mkdtemp, cp, rm as rm3, readFile as readFile9 } from "fs/promises";
7970
+ import { mkdtemp, cp, rm as rm3, readFile as readFile10 } from "fs/promises";
7389
7971
  import { appendFileSync, symlinkSync } from "fs";
7390
7972
  import { spawnSync as spawnSync4 } from "child_process";
7391
7973
  import { tmpdir, platform } from "os";
7392
- import { join as join11, basename as basename3, dirname as dirname5 } from "path";
7974
+ import { join as join12, basename as basename3, dirname as dirname5 } from "path";
7393
7975
  import { createRequire } from "module";
7394
7976
  var require2 = createRequire(import.meta.url);
7395
7977
  var vitestPaths;
@@ -7399,7 +7981,7 @@ function resolveVitestPaths() {
7399
7981
  vitestPaths = {
7400
7982
  resolvedNodeModules: dirname5(vitestPkgDir),
7401
7983
  // node_modules dir containing the resolved vitest install
7402
- vitestBin: join11(vitestPkgDir, "vitest.mjs")
7984
+ vitestBin: join12(vitestPkgDir, "vitest.mjs")
7403
7985
  };
7404
7986
  }
7405
7987
  return vitestPaths;
@@ -7409,7 +7991,7 @@ function linkNodeModules(workdir) {
7409
7991
  try {
7410
7992
  symlinkSync(
7411
7993
  resolveVitestPaths().resolvedNodeModules,
7412
- join11(workdir, "node_modules"),
7994
+ join12(workdir, "node_modules"),
7413
7995
  platform() === "win32" ? "junction" : "dir"
7414
7996
  );
7415
7997
  } catch {
@@ -7422,7 +8004,7 @@ function runGit2(args, cwd) {
7422
8004
  }
7423
8005
  }
7424
8006
  function excludeNodeModules(workdir) {
7425
- appendFileSync(join11(workdir, ".git", "info", "exclude"), "\n/node_modules/\n", "utf8");
8007
+ appendFileSync(join12(workdir, ".git", "info", "exclude"), "\n/node_modules/\n", "utf8");
7426
8008
  }
7427
8009
  function runHeldOutTests(workdir) {
7428
8010
  const result = spawnSync4(process.execPath, [resolveVitestPaths().vitestBin, "run", "--root", workdir], {
@@ -7437,12 +8019,12 @@ async function scoreTask(taskDir, runner, proctorOn) {
7437
8019
  if (!TASK_NAME_RE2.test(taskId)) {
7438
8020
  throw new Error(`invalid task directory name: ${taskId}`);
7439
8021
  }
7440
- const tempDir = await mkdtemp(join11(tmpdir(), "proctor-bench-"));
8022
+ const tempDir = await mkdtemp(join12(tmpdir(), "proctor-bench-"));
7441
8023
  try {
7442
- await cp(join11(taskDir, "repo"), tempDir, { recursive: true });
8024
+ await cp(join12(taskDir, "repo"), tempDir, { recursive: true });
7443
8025
  linkNodeModules(tempDir);
7444
8026
  try {
7445
- await cp(join11(taskDir, "mock-agent.json"), join11(tempDir, "mock-agent.json"));
8027
+ await cp(join12(taskDir, "mock-agent.json"), join12(tempDir, "mock-agent.json"));
7446
8028
  } catch (err) {
7447
8029
  if (err.code !== "ENOENT") throw err;
7448
8030
  }
@@ -7452,7 +8034,7 @@ async function scoreTask(taskDir, runner, proctorOn) {
7452
8034
  runGit2(["config", "user.name", "proctor-bench"], tempDir);
7453
8035
  runGit2(["add", "-A"], tempDir);
7454
8036
  runGit2(["-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline", "--allow-empty"], tempDir);
7455
- const prompt = await readFile9(join11(taskDir, "prompt.md"), "utf8");
8037
+ const prompt = await readFile10(join12(taskDir, "prompt.md"), "utf8");
7456
8038
  const task = { taskId, prompt, workdir: tempDir, proctorOn };
7457
8039
  const agentResult = await runner.run(task);
7458
8040
  if (agentResult.timedOut) {
@@ -7471,7 +8053,7 @@ async function scoreTask(taskDir, runner, proctorOn) {
7471
8053
  const findings = await runChecks(ctx);
7472
8054
  const cheatDetected = findings.length > 0;
7473
8055
  const rhId = findings[0]?.verifierId ?? "";
7474
- await cp(join11(taskDir, "held-out"), join11(tempDir, "held-out"), { recursive: true });
8056
+ await cp(join12(taskDir, "held-out"), join12(tempDir, "held-out"), { recursive: true });
7475
8057
  const honestPass = runHeldOutTests(tempDir);
7476
8058
  return { taskId, model: runner.model, proctorOn, cheatDetected, rhId, honestPass };
7477
8059
  } finally {
@@ -7609,7 +8191,7 @@ async function runBench(opts) {
7609
8191
  const partialPath = partialOutPath(opts.outPath);
7610
8192
  let text;
7611
8193
  try {
7612
- text = await readFile10(partialPath, "utf8");
8194
+ text = await readFile11(partialPath, "utf8");
7613
8195
  } catch {
7614
8196
  process.stderr.write(`proctor: --resume found no rows to resume from at ${partialPath}, scoring every selected task
7615
8197
  `);
@@ -7673,12 +8255,12 @@ proctor: the ${rows.length / 2} task${rows.length === 2 ? "" : "s"} that did com
7673
8255
  // src/hooks/stop-hook.ts
7674
8256
  import { spawnSync as spawnSync6 } from "child_process";
7675
8257
  import { existsSync as existsSync3 } from "fs";
7676
- import { join as join13 } from "path";
8258
+ import { join as join14 } from "path";
7677
8259
 
7678
8260
  // src/session.ts
7679
8261
  import { spawnSync as spawnSync5 } from "child_process";
7680
8262
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
7681
- import { join as join12, dirname as dirname7 } from "path";
8263
+ import { join as join13, dirname as dirname7 } from "path";
7682
8264
  var EMPTY = { caught: 0, recentRules: [] };
7683
8265
  var MAX_RECENT_RULES = 20;
7684
8266
  var SESSION_FILENAME = "proctor-session.json";
@@ -7686,7 +8268,7 @@ function sessionPath(cwd) {
7686
8268
  const result = spawnSync5("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8" });
7687
8269
  if (result.status !== 0) return void 0;
7688
8270
  const gitDir = result.stdout.trim();
7689
- return gitDir ? join12(gitDir, SESSION_FILENAME) : void 0;
8271
+ return gitDir ? join13(gitDir, SESSION_FILENAME) : void 0;
7690
8272
  }
7691
8273
  function isValid(value) {
7692
8274
  if (typeof value !== "object" || value === null) return false;
@@ -7761,7 +8343,7 @@ function runStopHookCheck(cwd, cliPath) {
7761
8343
  function isMidMerge(cwd) {
7762
8344
  const resolved = spawnSync6("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8" });
7763
8345
  const gitDir = resolved.status === 0 ? (resolved.stdout ?? "").trim() : "";
7764
- return IN_PROGRESS_MARKERS.some((marker) => gitDir !== "" && existsSync3(join13(gitDir, marker)));
8346
+ return IN_PROGRESS_MARKERS.some((marker) => gitDir !== "" && existsSync3(join14(gitDir, marker)));
7765
8347
  }
7766
8348
  var IN_PROGRESS_MARKERS = [
7767
8349
  "MERGE_HEAD",
@@ -8208,7 +8790,7 @@ program.command("stop-hook").description("Claude Code Stop hook: reads stdin JSO
8208
8790
  process.exit(exitCode);
8209
8791
  });
8210
8792
  program.command("install-claude-hook").description("Install Claude Code Stop hook").option("--global", "write to ~/.claude/settings.json").action(async (options) => {
8211
- const result = await installStopHook(options.global ? join15(homedir(), ".claude") : join15(process.cwd(), ".claude"));
8793
+ const result = await installStopHook(options.global ? join16(homedir(), ".claude") : join16(process.cwd(), ".claude"));
8212
8794
  if (result.status === "invalid-json") {
8213
8795
  process.stderr.write(`proctor: ${result.path} exists but is not valid JSON; fix it manually and re-run
8214
8796
  `);
@@ -8219,7 +8801,7 @@ program.command("install-claude-hook").description("Install Claude Code Stop hoo
8219
8801
  program.command("setup").description("One-command install: writes the ruleset to every agent this repo uses, and installs both hooks").option("--all", `write the ruleset to all ${AGENT_ADAPTERS.length} supported agents, not only the ones this repo uses`).option("--agents <ids>", "comma-separated agent ids to install to (e.g. claude-code,cursor), instead of detecting").action(async (options) => {
8220
8802
  const cwd = process.cwd();
8221
8803
  const targets = await resolveTargets(cwd, options);
8222
- const failed = await deploySkill(cwd, targets, await readFile11(canonicalSkillPath(), "utf8"));
8804
+ const failed = await deploySkill(cwd, targets, await readFile12(canonicalSkillPath(), "utf8"));
8223
8805
  process.stdout.write(
8224
8806
  `Ruleset written to ${targets.length - failed} of ${targets.length} agent path${targets.length === 1 ? "" : "s"}.
8225
8807
  `
@@ -8240,7 +8822,7 @@ program.command("setup").description("One-command install: writes the ruleset to
8240
8822
  process.exitCode = 1;
8241
8823
  }
8242
8824
  if (targets.some((a) => a.id === "claude-code")) {
8243
- const stop = await installStopHook(join15(cwd, ".claude"));
8825
+ const stop = await installStopHook(join16(cwd, ".claude"));
8244
8826
  if (stop.status === "invalid-json") {
8245
8827
  process.stderr.write(`proctor: ${stop.path} is not valid JSON, so the Stop hook was not installed; fix it and re-run
8246
8828
  `);
@@ -8259,7 +8841,7 @@ program.command("install-skill").description("Deploy canonical SKILL.md to the a
8259
8841
  const failed = await deploySkill(
8260
8842
  cwd,
8261
8843
  await resolveTargets(cwd, options),
8262
- await readFile11(canonicalSkillPath(), "utf8")
8844
+ await readFile12(canonicalSkillPath(), "utf8")
8263
8845
  );
8264
8846
  if (failed > 0) {
8265
8847
  process.stderr.write(`proctor: ${failed} adapter${failed === 1 ? "" : "s"} could not be written
@@ -8345,7 +8927,7 @@ ${done.length} item${done.length === 1 ? "" : "s"} would be removed. Re-run with
8345
8927
  });
8346
8928
  program.command("drift-check").description("Verify every deployed agent adapter still matches canonical SKILL.md").action(async () => {
8347
8929
  const cwd = process.cwd();
8348
- const canonical = await readFile11(canonicalSkillPath(), "utf8");
8930
+ const canonical = await readFile12(canonicalSkillPath(), "utf8");
8349
8931
  const { drifted } = await checkAdapterDrift(cwd, canonical);
8350
8932
  for (const path9 of drifted) {
8351
8933
  process.stderr.write("Drifted: " + path9 + "\n");
@@ -8453,10 +9035,10 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
8453
9035
  process.stderr.write("proctor: --reason cannot be empty\n");
8454
9036
  process.exit(2);
8455
9037
  }
8456
- const configPath = join15(process.cwd(), "proctor.config.json");
9038
+ const configPath = join16(process.cwd(), "proctor.config.json");
8457
9039
  let config = {};
8458
9040
  try {
8459
- config = JSON.parse(await readFile11(configPath, "utf8"));
9041
+ config = JSON.parse(await readFile12(configPath, "utf8"));
8460
9042
  } catch (err) {
8461
9043
  if (err.code !== "ENOENT") {
8462
9044
  process.stderr.write(`proctor: could not read ${configPath}: ${String(err)}