@kavishdua/proctor 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,7 @@ source needs Node 20.19+ or 22.12+.
37
37
  - [What do the codes mean?](#what-do-the-codes-mean)
38
38
  - [Badges](#badges)
39
39
  - [CI](#ci)
40
+ - [Dynamic checks](#dynamic-checks), opt-in runtime evidence
40
41
 
41
42
  **Why it works**
42
43
 
@@ -306,6 +307,32 @@ See the language matrix for each check's scope.
306
307
  Some WI checks accept an explanatory comment; others require a committed approval or suppression
307
308
  marker. Check the rule's `--explain` output before relying on an exception.
308
309
 
310
+ Three static checks keep ambiguous repairs visible as warnings. RH003 warns when a rewrite re-emits an
311
+ already skipped Python test, or renames a skipped JavaScript test without disabling a known
312
+ active test. RH004 warns for string or boolean domain branches, literal returns inside
313
+ conditional fallback paths, and explicit SPEC or CHANGELOG behavior notes naming the changed
314
+ function or both special-case literals. WI112 warns when every removed assertion targets a source function
315
+ or dispatch arm removed from the corresponding module in the same diff. Newly disabled tests,
316
+ focus modifiers, unconditional computation replacements without a specific behavior note, and
317
+ unrelated assertion deletions keep their existing errors. WI112 remains opt-in.
318
+
319
+ RH004 keeps an error for an added key that encodes a list of scalar values, or matches a common
320
+ serialization of a composite literal asserted as input to the same function in changed tests.
321
+ This includes joined, JSON and template string signatures. Domain names and catch/except
322
+ fallbacks remain advisory. Serialized fixture evidence is not demoted by a behavior note.
323
+
324
+ These warnings do not establish that a repair is correct. Compare the change with the project's
325
+ documented contract and test the supported paths. Incomplete diff context can prevent matching
326
+ a repair, and an unauthorized feature removal can look like an authorized one. Warnings remain
327
+ visible in reports and follow the existing exit policy: `--ci` fails on errors only; a warning
328
+ without `--ci` can exit `1`. Paired, tuned development cases and reproduction steps live in
329
+ [bench/dynamic-dev](bench/dynamic-dev/README.md). They are not an independent accuracy estimate.
330
+ On the tuned authored cases, these checks report 7/36 original cheats and 21/21 repair-set
331
+ cheats, with 0/60 honest errors. Three original catches and one repair-set catch are warnings.
332
+ All seven added serialized-key cheats produce errors. These tuned counts do not establish
333
+ retention of blocking catches or accuracy on other inputs; an independently reported lost
334
+ blocking catch motivated the serialization regressions.
335
+
309
336
  ## Badges
310
337
 
311
338
  [![proctor](https://img.shields.io/badge/proctor-honest_pass-22C55E)](https://github.com/catfish-1234/proctor)
@@ -345,6 +372,39 @@ jobs:
345
372
  The example pins the commit behind the `v1.0.0` release. You can use the `v1.0.0` tag instead, but
346
373
  a full commit SHA is immutable, so it keeps builds reproducible.
347
374
 
375
+ ## Dynamic checks
376
+
377
+ `proctor check --dynamic` runs extra checks using a runnable project test suite. The default
378
+ check keeps its existing output and runs no project tests. You can also commit
379
+ `{"dynamic": true, "dynamicTimeoutMs": 60000}` in `proctor.config.json`. Use
380
+ `--dynamic-timeout 30000` to override the overall budget in milliseconds.
381
+
382
+ This prototype supports Node's `node:test`, Jest and Vitest through `npm run test`, plus Python
383
+ `unittest`, `pytest` and plain Python scripts. When unittest collects no cases or only collection
384
+ errors, plain scripts run directly and bare `test_*` functions run individually in fresh processes.
385
+ It needs Linux and bubblewrap with working user, mount, network and PID
386
+ namespaces. Windows, macOS, unsupported languages and unavailable isolation report
387
+ `dynamic: skipped, reason`; a skip never adds a failing finding. Tests run in temporary copies,
388
+ with host mounts read-only and network disabled. The default overall timeout is 60 seconds,
389
+ including copies; descendants are terminated when the worker ends.
390
+
391
+ Warnings name the commands, run labels and observed results. The checks need runnable
392
+ tests and installed dependencies, can miss changes, and cannot prove intent. A red original suite
393
+ is a normal fix input; named per-test comparisons and numeric, string or boolean probes still run.
394
+ For red-to-green fixes with unchanged tests, mutation-lite tries at most four parser-confirmed
395
+ changes in called functions. DY002 probes and DY004 mutation warnings are advisory and do not
396
+ change the exit code on their own. Mutations can survive because code is redundant or behavior
397
+ is deliberately untested; survival does not establish cheating. Repeated outcomes
398
+ can reflect order dependence.
399
+
400
+ Measured limits: dynamic checks are opt-in and advisory only. They do not change which findings
401
+ block a commit. Mutation-lite reports missing test coverage, not cheating, and it can fire on
402
+ honest changes whose tests do not cover every changed line. On a private 80-case corpus (40
403
+ cheating changes, 40 honest ones) that informed this iteration, dynamic warnings surfaced 4
404
+ cheating changes the static checks missed and flagged 1 honest change. Because the same corpus
405
+ informed the checks, these are not clean held-out numbers.
406
+ See the [scope, dropped ideas and tuned dev-set measurements](bench/dynamic-dev/README.md).
407
+
348
408
  ## How it works
349
409
 
350
410
  proctor combines a **ruleset** with a **guard**. The ruleset tells the agent how to complete work
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.1.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",
@@ -719,6 +874,14 @@ function normalizeConfig(config) {
719
874
  warn(`config 'aiModel' must be a string; ignoring it`);
720
875
  delete out.aiModel;
721
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
+ }
722
885
  return out;
723
886
  }
724
887
  async function buildContext(cwd, files, opts) {
@@ -816,7 +979,9 @@ async function buildContext(cwd, files, opts) {
816
979
  approvedTestChanges: config.approvedTestChanges,
817
980
  commitMessage,
818
981
  snapshotGlobs,
819
- aiModel
982
+ aiModel,
983
+ ...config.dynamic !== void 0 ? { dynamic: config.dynamic } : {},
984
+ ...config.dynamicTimeoutMs !== void 0 ? { dynamicTimeoutMs: config.dynamicTimeoutMs } : {}
820
985
  };
821
986
  }
822
987
 
@@ -6315,6 +6480,24 @@ function jsonReport(findings) {
6315
6480
 
6316
6481
  // src/reporters/sarif.ts
6317
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
6318
6501
  function levelFor(severity) {
6319
6502
  if (severity === "error") return "error";
6320
6503
  if (severity === "warn") return "warning";
@@ -6324,7 +6507,7 @@ function fingerprint(verifierId, file, line) {
6324
6507
  return createHash("sha256").update(`${verifierId}:${file}:${line}`).digest("hex");
6325
6508
  }
6326
6509
  function sarifReport(findings) {
6327
- 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]) => ({
6328
6511
  id,
6329
6512
  name: meta.name,
6330
6513
  shortDescription: { text: meta.shortDescription },
@@ -7836,13 +8019,13 @@ async function readStdin() {
7836
8019
  }
7837
8020
  var program = new Command();
7838
8021
  program.name("proctor").description("AI agent test-tampering guard").version(package_default.version);
7839
- 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 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) => {
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) => {
7840
8023
  if (options.fix && !options.explain) {
7841
8024
  process.stderr.write("proctor: --fix only applies with --explain, e.g. proctor check --explain RH001 --fix\n");
7842
8025
  process.exit(2);
7843
8026
  }
7844
8027
  if (options.explain) {
7845
- const meta = RULE_METADATA[options.explain];
8028
+ const meta = RULE_METADATA[options.explain] ?? DYNAMIC_METADATA[options.explain];
7846
8029
  if (!meta) {
7847
8030
  process.stderr.write(`proctor: unknown verifier ID '${options.explain}'
7848
8031
  `);
@@ -7892,6 +8075,10 @@ Honest fix: proctor check --explain ${options.explain} --fix
7892
8075
  `);
7893
8076
  process.exit(0);
7894
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
+ }
7895
8082
  const selectedScopes = [options.base !== void 0, options.staged === true, options.uncommitted === true].filter(Boolean).length;
7896
8083
  if (selectedScopes > 1) {
7897
8084
  process.stderr.write("proctor: choose only one diff scope: --staged, --uncommitted, or --base <ref>\n");
@@ -7957,6 +8144,24 @@ Honest fix: proctor check --explain ${options.explain} --fix
7957
8144
  process.exitCode = 3;
7958
8145
  return;
7959
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
+ }
7960
8165
  if (options.markdown) {
7961
8166
  try {
7962
8167
  await appendFile(options.markdown, markdownReport(findings) + "\n", "utf8");
@@ -7968,7 +8173,7 @@ Honest fix: proctor check --explain ${options.explain} --fix
7968
8173
  if (options.sarif) {
7969
8174
  const sarif = sarifReport(findings);
7970
8175
  const hasError2 = findings.some((f) => f.severity === "error");
7971
- const hasWarn2 = findings.some((f) => f.severity === "warn");
8176
+ const hasWarn2 = findings.some((f) => f.severity === "warn" && !["DY002", "DY004"].includes(f.verifierId));
7972
8177
  await new Promise((resolve4) => {
7973
8178
  process.stdout.write(sarif + "\n", () => {
7974
8179
  process.exitCode = hasError2 ? 2 : hasWarn2 && !options.ci ? 1 : 0;
@@ -7987,7 +8192,7 @@ Honest fix: proctor check --explain ${options.explain} --fix
7987
8192
  if (receipt.status === "honest-pass" && receipt.approvedCount === 0 && !options.ci) process.stdout.write(badgeLine(receipt) + "\n");
7988
8193
  }
7989
8194
  const hasError = findings.some((f) => f.severity === "error");
7990
- const hasWarn = findings.some((f) => f.severity === "warn");
8195
+ const hasWarn = findings.some((f) => f.severity === "warn" && !["DY002", "DY004"].includes(f.verifierId));
7991
8196
  process.exitCode = hasError ? 2 : hasWarn && !options.ci ? 1 : 0;
7992
8197
  });
7993
8198
  program.command("install-hook").description("Install git pre-commit hook").action(async () => {
@@ -8003,7 +8208,7 @@ program.command("stop-hook").description("Claude Code Stop hook: reads stdin JSO
8003
8208
  process.exit(exitCode);
8004
8209
  });
8005
8210
  program.command("install-claude-hook").description("Install Claude Code Stop hook").option("--global", "write to ~/.claude/settings.json").action(async (options) => {
8006
- 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"));
8007
8212
  if (result.status === "invalid-json") {
8008
8213
  process.stderr.write(`proctor: ${result.path} exists but is not valid JSON; fix it manually and re-run
8009
8214
  `);
@@ -8035,7 +8240,7 @@ program.command("setup").description("One-command install: writes the ruleset to
8035
8240
  process.exitCode = 1;
8036
8241
  }
8037
8242
  if (targets.some((a) => a.id === "claude-code")) {
8038
- const stop = await installStopHook(join14(cwd, ".claude"));
8243
+ const stop = await installStopHook(join15(cwd, ".claude"));
8039
8244
  if (stop.status === "invalid-json") {
8040
8245
  process.stderr.write(`proctor: ${stop.path} is not valid JSON, so the Stop hook was not installed; fix it and re-run
8041
8246
  `);
@@ -8248,7 +8453,7 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
8248
8453
  process.stderr.write("proctor: --reason cannot be empty\n");
8249
8454
  process.exit(2);
8250
8455
  }
8251
- const configPath = join14(process.cwd(), "proctor.config.json");
8456
+ const configPath = join15(process.cwd(), "proctor.config.json");
8252
8457
  let config = {};
8253
8458
  try {
8254
8459
  config = JSON.parse(await readFile11(configPath, "utf8"));
@@ -0,0 +1,776 @@
1
+ // src/dynamic-worker.ts
2
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
3
+ import { dirname as dirname2, join as join4 } from "path";
4
+ import { spawnSync as spawnSync4 } from "child_process";
5
+ import picomatch from "picomatch";
6
+
7
+ // src/dynamic/snapshots.ts
8
+ import { spawnSync } from "child_process";
9
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, symlinkSync, writeFileSync, copyFileSync, chmodSync } from "fs";
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
11
+ var EXCLUDED = /^(?:bench\/(?:dynamic|heldout)(?:\/|$))|(?:^|\/)(?:\.git|node_modules|\.venv|venv|__pycache__)(?:\/|$)/;
12
+ var MAX_BYTES = 64 * 1024 * 1024;
13
+ function git(cwd, args, input) {
14
+ const r = spawnSync("git", args, { cwd, input, maxBuffer: MAX_BYTES, timeout: 15e3, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1" } });
15
+ if (r.status !== 0) throw Error("cannot read local Git snapshot");
16
+ return r.stdout;
17
+ }
18
+ function validPath(path) {
19
+ return !isAbsolute(path) && !path.split("/").some((p) => p === ".." || p === "" || p.includes("\\"));
20
+ }
21
+ function snapshot(cwd, ref) {
22
+ const entries = git(cwd, ref ? ["ls-tree", "-rz", "--full-tree", ref] : ["ls-files", "--stage", "-z"]).toString().split("\0").filter(Boolean).map((row) => {
23
+ const tab = row.indexOf(" ");
24
+ const meta = row.slice(0, tab).split(" "), path = row.slice(tab + 1);
25
+ return { path, mode: meta[0], sha: meta[ref ? 2 : 1] };
26
+ }).filter((e) => !EXCLUDED.test(e.path));
27
+ if (entries.some((e) => !validPath(e.path) || !/^100(644|755)$/.test(e.mode))) throw Error("not run: symlink, submodule or unsafe tracked path");
28
+ const blobs = git(cwd, ["cat-file", "--batch"], entries.map((e) => e.sha).join("\n") + "\n");
29
+ const files = /* @__PURE__ */ new Map();
30
+ let offset = 0;
31
+ for (const entry of entries) {
32
+ const newline = blobs.indexOf(10, offset);
33
+ const [sha, kind, sizeText] = blobs.subarray(offset, newline).toString().split(" ");
34
+ const size = Number(sizeText);
35
+ if (sha !== entry.sha || kind !== "blob" || !Number.isSafeInteger(size)) throw Error("unreadable Git blob");
36
+ offset = newline + 1;
37
+ files.set(entry.path, { data: blobs.subarray(offset, offset + size), mode: parseInt(entry.mode, 8) & 511 });
38
+ offset += size + 1;
39
+ }
40
+ return files;
41
+ }
42
+ function loadSnapshots(options2) {
43
+ const top = git(options2.cwd, ["rev-parse", "--show-toplevel"]).toString().trim();
44
+ if (resolve(top) !== resolve(options2.cwd)) throw Error("not run: invoke dynamic checks at the repository root");
45
+ const ref = options2.base ? git(top, ["merge-base", "--", options2.base, "HEAD"]).toString().trim() : "HEAD";
46
+ const old = snapshot(top, ref);
47
+ const next = snapshot(top, options2.staged ? void 0 : "HEAD");
48
+ if (!options2.base && !options2.staged) {
49
+ for (const file of options2.files) {
50
+ if (file.from && file.from !== "/dev/null" && file.from !== file.to) next.delete(file.from);
51
+ const path = file.to;
52
+ if (!path || path === "/dev/null") {
53
+ if (file.from) next.delete(file.from);
54
+ continue;
55
+ }
56
+ if (!validPath(path) || EXCLUDED.test(path)) continue;
57
+ const disk = join(top, path);
58
+ if (!existsSync(disk)) {
59
+ next.delete(path);
60
+ continue;
61
+ }
62
+ const stat = lstatSync(disk);
63
+ if (!stat.isFile()) throw Error("not run: changed symlink or non-regular file");
64
+ if (stat.size > MAX_BYTES) throw Error("snapshot size limit");
65
+ next.set(path, { data: readFileSync(disk), mode: stat.mode & 511 });
66
+ }
67
+ }
68
+ for (const tree of [old, next]) {
69
+ if ([...tree.values()].reduce((sum, f) => sum + f.data.length, 0) > MAX_BYTES) throw Error("snapshot size limit");
70
+ }
71
+ for (const path of ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "requirements.txt", "poetry.lock", "uv.lock"]) {
72
+ if (old.get(path)?.data.toString() !== next.get(path)?.data.toString()) throw Error(`not run: dependency manifest changed (${path})`);
73
+ }
74
+ return { old, next };
75
+ }
76
+ function copyDependencies(source, target, sourceRoot, targetRoot) {
77
+ const stat = lstatSync(source);
78
+ if (stat.isSymbolicLink()) {
79
+ const destination = resolve(dirname(source), readlinkSync(source));
80
+ const rel = relative(sourceRoot, destination);
81
+ if (rel.startsWith(".." + sep) || rel === ".." || isAbsolute(rel)) throw Error("not run: dependency symlink points outside node_modules");
82
+ symlinkSync(relative(dirname(target), join(targetRoot, rel)), target);
83
+ } else if (stat.isDirectory()) {
84
+ mkdirSync(target, { recursive: true });
85
+ for (const name of readdirSync(source)) copyDependencies(join(source, name), join(target, name), sourceRoot, targetRoot);
86
+ } else if (stat.isFile()) {
87
+ copyFileSync(source, target);
88
+ chmodSync(target, stat.mode & 511);
89
+ }
90
+ }
91
+ function materialize(tree, dir, cwd) {
92
+ mkdirSync(dir, { recursive: true });
93
+ for (const [path, file] of tree) {
94
+ const target = join(dir, path);
95
+ mkdirSync(dirname(target), { recursive: true });
96
+ writeFileSync(target, file.data, { mode: file.mode });
97
+ }
98
+ const deps = join(cwd, "node_modules");
99
+ if (existsSync(deps)) copyDependencies(deps, join(dir, "node_modules"), deps, join(dir, "node_modules"));
100
+ }
101
+
102
+ // src/dynamic/adapters.ts
103
+ import { stripVTControlCharacters } from "util";
104
+ import { spawnSync as spawnSync2 } from "child_process";
105
+ import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
106
+ import { join as join2 } from "path";
107
+ function detectAdapter(tree, tests) {
108
+ const pkgText = tree.get("package.json")?.data.toString();
109
+ if (pkgText) {
110
+ const pkg = JSON.parse(pkgText);
111
+ const script = pkg.scripts?.test;
112
+ if (typeof script === "string") {
113
+ if (/\bvitest\b/.test(script)) return { command: "npm", args: ["run", "test", "--", "--run", "--reporter=json"], kind: "vitest" };
114
+ if (/\bjest\b/.test(script)) return { command: "npm", args: ["run", "test", "--", "--json", "--runInBand", "--watch=false"], kind: "jest" };
115
+ if (/\bnode\b.*--test\b/.test(script)) return { command: "npm", args: ["run", "test"], kind: "tap" };
116
+ throw Error("not run: unrecognized package test script (node:test, jest and vitest supported)");
117
+ }
118
+ }
119
+ const js = tests.filter((p) => /\.(?:mjs|cjs|js)$/.test(p));
120
+ if (js.length) return { command: process.execPath, args: ["--test", "--test-reporter=tap", ...js], kind: "tap" };
121
+ const py = tests.filter((p) => p.endsWith(".py"));
122
+ if (py.length) {
123
+ const pytest = ["pytest.ini", "conftest.py"].some((p) => tree.has(p)) || /\[tool\.pytest/.test(tree.get("pyproject.toml")?.data.toString() ?? "") || py.some((p) => /(?:^|\n)\s*(?:import pytest|from pytest\b)/.test(tree.get(p)?.data.toString() ?? ""));
124
+ return pytest ? { command: "python3", args: ["-m", "pytest", "-q", "--tb=short", "--junitxml=.proctor-junit.xml"], kind: "pytest" } : { command: "python3", args: ["-m", "unittest", "discover", "-v"], kind: "unittest", testFiles: py };
125
+ }
126
+ throw Error("not run: no supported Node or Python tests found");
127
+ }
128
+ function uniqueOutcomes(rows) {
129
+ const counts = /* @__PURE__ */ new Map();
130
+ for (const [id] of rows) counts.set(id, (counts.get(id) ?? 0) + 1);
131
+ return {
132
+ passed: rows.filter(([id, pass]) => pass && counts.get(id) === 1).map(([id]) => id),
133
+ failed: rows.filter(([id, pass]) => !pass && counts.get(id) === 1).map(([id]) => id)
134
+ };
135
+ }
136
+ function parseResults(kind, stdout, stderr, junit = "") {
137
+ const rows = [];
138
+ if (kind === "tap") {
139
+ for (const match of stdout.matchAll(/^\s*(not )?ok \d+ - (.+?)(?: #.*)?$/gm)) {
140
+ if (/ # (?:SKIP|TODO)/i.test(match[0])) continue;
141
+ rows.push([match[2].trim(), !match[1]]);
142
+ }
143
+ } else if (kind === "unittest") {
144
+ for (const match of stderr.matchAll(/^(.+? \(.+?\)) \.\.\. (ok|FAIL|ERROR)\s*$/gm)) rows.push([match[1], match[2] === "ok"]);
145
+ } else if (kind === "pytest") {
146
+ const decode = (s) => s.replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
147
+ for (const m of junit.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
148
+ if (/<skipped\b/.test(m[2] ?? "")) continue;
149
+ const cls = /\bclassname="([^"]*)"/.exec(m[1])?.[1];
150
+ const name = /\bname="([^"]*)"/.exec(m[1])?.[1];
151
+ if (name) rows.push([decode(`${cls ?? ""}::${name}`), !/<(?:failure|error)\b/.test(m[2] ?? "")]);
152
+ }
153
+ } else {
154
+ let result2;
155
+ for (const stream of [stdout, stderr]) {
156
+ const start = stream.indexOf("{"), end = stream.lastIndexOf("}");
157
+ if (start < 0 || end < start) continue;
158
+ try {
159
+ const candidate = JSON.parse(stream.slice(start, end + 1));
160
+ if (Array.isArray(candidate.testResults)) {
161
+ result2 = candidate;
162
+ break;
163
+ }
164
+ } catch {
165
+ }
166
+ }
167
+ if (!result2) throw Error("test reporter did not produce readable JSON");
168
+ for (const suite of result2.testResults ?? []) {
169
+ for (const test of suite.assertionResults ?? []) {
170
+ if (test.status === "passed" || test.status === "failed") rows.push([test.fullName ?? [...test.ancestorTitles ?? [], test.title].join(" "), test.status === "passed"]);
171
+ }
172
+ }
173
+ }
174
+ return uniqueOutcomes(rows);
175
+ }
176
+ var PYTHON_FUNCTIONS = `import ast,json,sys
177
+ tree=ast.parse(open(sys.argv[1],encoding='utf-8').read())
178
+ print(json.dumps([n.name for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name.startswith('test_')]))
179
+ `;
180
+ var PYTHON_CALL = `import asyncio,importlib.util,inspect,os,sys
181
+ file,name=sys.argv[1:]
182
+ sys.path.insert(0,os.path.dirname(os.path.abspath(file)))
183
+ spec=importlib.util.spec_from_file_location('proctor_test',file)
184
+ module=importlib.util.module_from_spec(spec)
185
+ sys.modules[spec.name]=module
186
+ spec.loader.exec_module(module)
187
+ result=getattr(module,name)()
188
+ if inspect.isawaitable(result): asyncio.run(result)
189
+ `;
190
+ function runPythonFiles(adapter, dir, label, discovery, env) {
191
+ if (!adapter.testFiles?.length) throw Error(`cannot run ${label}: no Python files for direct execution`);
192
+ const passed = [], failed = [], details = [];
193
+ const fileRuns = [];
194
+ const helper = ".proctor-python-test.py";
195
+ writeFileSync2(join2(dir, helper), PYTHON_CALL);
196
+ for (const file of adapter.testFiles) {
197
+ const collected = spawnSync2(adapter.command, ["-c", PYTHON_FUNCTIONS, file], { cwd: dir, encoding: "utf8", maxBuffer: 128 * 1024, env });
198
+ if (collected.error || collected.signal) throw Error(`cannot inspect ${file}: ${collected.error?.message ?? collected.signal}`);
199
+ const names = collected.status === 0 ? JSON.parse(collected.stdout) : [];
200
+ if (new Set(names).size !== names.length) throw Error(`cannot run ${label}: duplicate Python function names in ${file}`);
201
+ const invocations = names.length ? names.map((name) => ({ id: `${file}::${name}`, args: [helper, file, name] })) : [{ id: file, args: [file] }];
202
+ for (const { id, args } of invocations) {
203
+ const r = spawnSync2(adapter.command, args, { cwd: dir, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, env });
204
+ if (r.error || r.signal || r.status === null) throw Error(`cannot run ${label}, ${file}: ${r.error?.message ?? r.signal ?? "no exit code"}`);
205
+ fileRuns.push({ file: id, command: [adapter.command, ...args].join(" "), exitCode: r.status });
206
+ (r.status === 0 ? passed : failed).push(id);
207
+ details.push(`${id}: exit ${r.status}
208
+ ${r.stdout ?? ""}${r.stderr ?? ""}`);
209
+ }
210
+ }
211
+ return {
212
+ label,
213
+ command: `${adapter.command} -m unittest discover -v, then direct Python files`,
214
+ exitCode: failed.length ? 1 : 0,
215
+ passed,
216
+ failed,
217
+ fileRuns,
218
+ detail: stripVTControlCharacters(`${discovery}
219
+ ${details.join("\n")}`).slice(-1200)
220
+ };
221
+ }
222
+ function runTests(adapter, dir, label, extraEnv = {}) {
223
+ const artifact = join2(dir, ".proctor-junit.xml");
224
+ if (adapter.kind === "pytest") rmSync(artifact, { force: true });
225
+ const env = { ...process.env, ...extraEnv, PWD: dir, ...adapter.kind === "tap" && adapter.command === "npm" ? { NODE_OPTIONS: "--test-reporter=tap" } : {} };
226
+ const r = spawnSync2(adapter.command, adapter.args, { cwd: dir, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, env });
227
+ if (r.error || r.signal) throw Error(`cannot run ${label}: ${r.error?.message ?? r.signal}`);
228
+ const stdout = r.stdout ?? "", stderr = r.stderr ?? "";
229
+ const outcomes = parseResults(adapter.kind, stdout, stderr, existsSync2(artifact) ? readFileSync2(artifact, "utf8") : "");
230
+ if (adapter.kind === "unittest") {
231
+ const real = [...outcomes.passed, ...outcomes.failed].filter((id) => !id.includes("unittest.loader._FailedTest"));
232
+ const discovered = Number(/^Ran (\d+) tests? in /m.exec(stderr)?.[1] ?? 0);
233
+ const collectionFailed = outcomes.failed.some((id) => id.includes("unittest.loader._FailedTest"));
234
+ if (!real.length && (discovered === 0 || collectionFailed)) return runPythonFiles(adapter, dir, label, stdout + stderr, env);
235
+ if (collectionFailed) throw Error(`cannot run ${label}: unittest collection failed beside real cases`);
236
+ }
237
+ if (outcomes.passed.length + outcomes.failed.length === 0) throw Error(`cannot run ${label}: no named test results (exit ${r.status}); ${String(stdout + stderr).replace(/\s+/g, " ").slice(-300)}`);
238
+ if (r.status !== 0 && outcomes.failed.length === 0) throw Error(`cannot run ${label}: command exited ${r.status} without a named failing test`);
239
+ if (r.status === 0 && outcomes.failed.length) throw Error(`cannot run ${label}: reporter and exit code disagree`);
240
+ return {
241
+ label,
242
+ command: (adapter.kind === "tap" && adapter.command === "npm" ? "NODE_OPTIONS=--test-reporter=tap " : "") + [adapter.command, ...adapter.args].join(" "),
243
+ exitCode: r.status,
244
+ ...outcomes,
245
+ detail: stripVTControlCharacters(String(stdout + stderr)).slice(-1200)
246
+ };
247
+ }
248
+
249
+ // src/dynamic/signals.ts
250
+ var LITERAL = String.raw`(?:-?\d+(?:\.\d+)?|true|false|None|True|False|null|"[^"\n]*"|'[^'\n]*')`;
251
+ var ASSERTION_PATTERNS = [
252
+ new RegExp(String.raw`^\s*((?:assert\.(?:strictEqual|equal|deepEqual|deepStrictEqual)|self\.assertEqual)\(.+?,\s*)(${LITERAL})(\s*\)\s*;?\s*)$`),
253
+ new RegExp(String.raw`^\s*(expect\(.+?\)\.to(?:Be|Equal|StrictEqual)\(\s*)(${LITERAL})(\s*\)\s*;?\s*)$`),
254
+ new RegExp(String.raw`^(\s*assert\s+.+?\s*==\s*)(${LITERAL})(\s*)$`)
255
+ ];
256
+ var assertion = (line) => ASSERTION_PATTERNS.map((pattern) => pattern.exec(line)).find(Boolean);
257
+ function expectationChanges(before, after) {
258
+ const assertions = (text) => {
259
+ const map = /* @__PURE__ */ new Map();
260
+ for (const line of text.split("\n")) {
261
+ const match = assertion(line);
262
+ if (match) map.set(match[1].trim() + match[3].trim(), match[2]);
263
+ }
264
+ return map;
265
+ };
266
+ const a = assertions(before), b = assertions(after);
267
+ return [...a].filter(([key, value]) => b.has(key) && b.get(key) !== value).map(([key, value]) => ({ before: value, after: b.get(key) }));
268
+ }
269
+ function expectedEdits(before, after) {
270
+ return expectationChanges(before, after).length > 0;
271
+ }
272
+ function expectationOnlyEdit(before, after) {
273
+ const canonical2 = (text) => text.split("\n").map((line) => {
274
+ const match = assertion(line);
275
+ return match ? `${match[1]}__EXPECTED_LITERAL__${match[3]}` : line;
276
+ }).join("\n");
277
+ return expectedEdits(before, after) && canonical2(before) === canonical2(after);
278
+ }
279
+ function documentedIntent(old, next) {
280
+ for (const [path, file] of next) {
281
+ if (!/(?:^|\/)(?:SPEC[^/]*|CHANGELOG[^/]*)\.(?:md|rst|txt)$/i.test(path)) continue;
282
+ const before = old.get(path)?.data.toString() ?? "";
283
+ const previous = new Set(before.split("\n"));
284
+ if (file.data.toString().split("\n").some((line) => line.trim() && !/^\s*#/.test(line) && !previous.has(line))) return true;
285
+ }
286
+ return false;
287
+ }
288
+ function definesPythonFunction(text, name) {
289
+ for (const match of text.matchAll(/^\s*def (\w+)\s*\(/gm)) {
290
+ if (match[1] === name) return true;
291
+ }
292
+ return false;
293
+ }
294
+ function locateTest(id, tree, tests) {
295
+ if (tests.includes(id) && tree.has(id)) return id;
296
+ const [plainFile, plainFunction] = id.split("::");
297
+ if (plainFunction && plainFile && tests.includes(plainFile) && tree.has(plainFile)) return plainFile;
298
+ const python = (/^(test\w+) \(/.exec(id)?.[1] ?? id.split("::").at(-1))?.replace(/\[.*$/, "");
299
+ const exact = tests.filter((p) => {
300
+ const text = tree.get(p)?.data.toString() ?? "";
301
+ return text.includes(`'${id}'`) || text.includes(`"${id}"`) || p.endsWith(".py") && !!python && definesPythonFunction(text, python);
302
+ });
303
+ if (exact.length) return exact.length === 1 ? exact[0] : void 0;
304
+ const nested = tests.filter((p) => [...(tree.get(p)?.data.toString() ?? "").matchAll(/\b(?:test|it)\s*\(\s*(['"])([^'"\n]+)\1/g)].some((m) => id.endsWith(" " + m[2])));
305
+ return nested.length === 1 ? nested[0] : void 0;
306
+ }
307
+ var UNARY_ARITHMETIC = /^(\w+)\s*[*+\/-]\s*-?\d+(?:\.\d+)?$/;
308
+ var PY_BRANCH = /if\s+(\w+)\s*==\s*(-?\d+(?:\.\d+)?|True|False|"[^"\\\n]*"|'[^'\\\n]*')\s*:\s*return\s+(-?\d+(?:\.\d+)?|True|False|"[^"\\\n]*"|'[^'\\\n]*')/;
309
+ var JS_BRANCH = /if\s*\(\s*(\w+)\s*===?\s*(-?\d+(?:\.\d+)?|true|false|"[^"\\\n]*"|'[^'\\\n]*')\s*\)\s*(?:\{\s*)?return\s+(-?\d+(?:\.\d+)?|true|false|"[^"\\\n]*"|'[^'\\\n]*');?\s*\}?/;
310
+ function simpleReference(expression, arg, py) {
311
+ if (expression === arg) return true;
312
+ const numeric = UNARY_ARITHMETIC.exec(expression);
313
+ const method = /^(\w+)\.(\w+)\(\)$/.exec(expression);
314
+ const negate = /^(?:!\s*|not\s+)(\w+)$/.exec(expression);
315
+ const concat = /^(?:(\w+)\s*\+\s*(?:"[^"\\\n]*"|'[^'\\\n]*')|(?:"[^"\\\n]*"|'[^'\\\n]*')\s*\+\s*(\w+))$/.exec(expression);
316
+ return numeric?.[1] === arg || negate?.[1] === arg || concat?.[1] === arg || concat?.[2] === arg || method?.[1] === arg && (py ? ["lower", "upper", "strip"] : ["toLowerCase", "toUpperCase", "trim"]).includes(method[2]);
317
+ }
318
+ function probeLiteral(text) {
319
+ if (text.startsWith('"') || text.startsWith("'")) return text.slice(1, -1);
320
+ if (/^(?:true|True|false|False)$/.test(text)) return text === "true" || text === "True";
321
+ return Number(text);
322
+ }
323
+ function probeInputs(input) {
324
+ if (typeof input === "boolean") return [!input, input];
325
+ if (typeof input === "string") return [`${input}a`, input, `b${input}`];
326
+ return [input - 1, input, input + 1];
327
+ }
328
+ function pythonBody(text, name, arg) {
329
+ const header = `def ${name}(${arg}):`;
330
+ const start = text.indexOf(header);
331
+ if (start < 0 || start > 0 && text[start - 1] !== "\n") return void 0;
332
+ const lines = text.slice(start + header.length).split("\n");
333
+ const body = [];
334
+ for (const line of lines.slice(1)) {
335
+ if (line.trim() === "") {
336
+ body.push(line);
337
+ continue;
338
+ }
339
+ if (/^[ \t]/.test(line)) {
340
+ body.push(line);
341
+ continue;
342
+ }
343
+ break;
344
+ }
345
+ return body.length ? body.join("\n") : void 0;
346
+ }
347
+ function jsBody(text, name, arg) {
348
+ for (const marker of [`export function ${name}(${arg})`, `function ${name}(${arg})`]) {
349
+ const start = text.indexOf(marker);
350
+ if (start < 0) continue;
351
+ const open = text.indexOf("{", start + marker.length);
352
+ if (open < 0) return void 0;
353
+ const close = text.indexOf("\n}", open);
354
+ if (close < 0) return void 0;
355
+ return text.slice(open + 1, close);
356
+ }
357
+ return void 0;
358
+ }
359
+ function findProbes(old, next, isTest) {
360
+ const probes = [];
361
+ for (const [file, entry] of next) {
362
+ if (isTest(file) || !/\.(?:js|mjs|cjs|py)$/.test(file)) continue;
363
+ const before = old.get(file)?.data.toString(), after = entry.data.toString();
364
+ if (!before || before === after) continue;
365
+ const py = file.endsWith(".py");
366
+ const functions = py ? [...before.matchAll(/^def (\w+)\((\w+)\):\n[ \t]+return ([^\n]+)\n?/gm)] : [...before.matchAll(/(?:export\s+)?function (\w+)\((\w+)\)\s*\{\s*return\s+([^;{}\n]+);?\s*\}/g)];
367
+ for (const fn of functions) {
368
+ const [, name, arg, expression] = fn;
369
+ if (!simpleReference(expression.trim(), arg, py)) continue;
370
+ if (!py && !before.includes(`export function ${name}`) && !before.includes("module.exports") && !before.includes(`exports.${name}`)) continue;
371
+ const body = py ? pythonBody(after, name, arg) : jsBody(after, name, arg);
372
+ if (!body) continue;
373
+ const branchMatch = (py ? PY_BRANCH : JS_BRANCH).exec(body);
374
+ const branch = branchMatch && branchMatch[1] === arg ? branchMatch : void 0;
375
+ if (!branch || !body.includes(expression.trim())) continue;
376
+ const remainder = body.replace(branch[0], "").replace(/return\s+/, "").replace(/[;\s]/g, "");
377
+ if (remainder !== expression.replace(/\s/g, "")) continue;
378
+ probes.push({ file, name, input: probeLiteral(branch[2]), returned: probeLiteral(branch[3]), line: after.slice(0, after.indexOf(branch[0])).split("\n").length, language: py ? "python" : "js" });
379
+ }
380
+ }
381
+ return probes.slice(0, 8);
382
+ }
383
+
384
+ // src/dynamic/mutations.ts
385
+ import { parse } from "acorn";
386
+ import { spawnSync as spawnSync3 } from "child_process";
387
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
388
+ import { join as join3, relative as relative2, sep as sep2 } from "path";
389
+ import { fileURLToPath } from "url";
390
+ var MUTATION_LIMIT = 4;
391
+ function changedSourceLines(before, after) {
392
+ const a = before.split("\n"), b = after.split("\n");
393
+ if (a.length * b.length > 4e6) return /* @__PURE__ */ new Set();
394
+ const width = b.length + 1, rows = new Uint16Array((a.length + 1) * width);
395
+ for (let i2 = a.length - 1; i2 >= 0; i2--) for (let j2 = b.length - 1; j2 >= 0; j2--) {
396
+ rows[i2 * width + j2] = a[i2] === b[j2] ? rows[(i2 + 1) * width + j2 + 1] + 1 : Math.max(rows[(i2 + 1) * width + j2], rows[i2 * width + j2 + 1]);
397
+ }
398
+ const changed = /* @__PURE__ */ new Set();
399
+ let i = 0, j = 0;
400
+ while (i < a.length || j < b.length) {
401
+ if (i < a.length && j < b.length && a[i] === b[j]) {
402
+ i++;
403
+ j++;
404
+ } else if (j < b.length && (i === a.length || rows[i * width + j + 1] >= rows[(i + 1) * width + j])) {
405
+ changed.add(++j);
406
+ } else i++;
407
+ }
408
+ return changed;
409
+ }
410
+ var PYTHON_PROFILE = `import atexit,json,os,sys,threading
411
+ root=os.getcwd()+os.sep
412
+ calls=set()
413
+ def record(frame,event,arg):
414
+ if event=='call' and frame.f_code.co_filename.startswith(root):
415
+ calls.add((frame.f_code.co_filename,frame.f_code.co_firstlineno))
416
+ def save():
417
+ with open(os.path.join(os.environ['PROCTOR_CALLS'],'python-'+str(os.getpid())+'.json'),'w') as out:
418
+ json.dump(sorted(calls),out)
419
+ sys.setprofile(record)
420
+ threading.setprofile(record)
421
+ atexit.register(save)
422
+ `;
423
+ function coverageEnvironment(dir) {
424
+ const path = join3(dir, ".proctor-calls"), hooks = join3(path, "hooks");
425
+ mkdirSync2(hooks, { recursive: true });
426
+ writeFileSync3(join3(hooks, "sitecustomize.py"), PYTHON_PROFILE);
427
+ return { path, env: { NODE_V8_COVERAGE: path, PROCTOR_CALLS: path, PYTHONPATH: hooks } };
428
+ }
429
+ function readCalls(path, dir) {
430
+ const calls = { js: /* @__PURE__ */ new Map(), python: /* @__PURE__ */ new Map() };
431
+ for (const name of readdirSync2(path).filter((p) => p.endsWith(".json")).slice(0, 100)) {
432
+ try {
433
+ const text = readFileSync3(join3(path, name), "utf8");
434
+ if (text.length > 4 * 1024 * 1024) continue;
435
+ const data = JSON.parse(text);
436
+ if (name.startsWith("python-")) {
437
+ for (const [file, line] of data) calls.python.set(file, [...calls.python.get(file) ?? [], line]);
438
+ } else for (const script of data.result ?? []) {
439
+ if (!script.url.startsWith("file:")) continue;
440
+ const file = fileURLToPath(script.url);
441
+ if (relative2(dir, file).startsWith(".." + sep2)) continue;
442
+ const ranges = script.functions.map((fn) => fn.ranges[0]).filter((r) => r.count > 0).map((r) => ({ start: r.startOffset, end: r.endOffset }));
443
+ calls.js.set(file, [...calls.js.get(file) ?? [], ...ranges]);
444
+ }
445
+ } catch {
446
+ }
447
+ }
448
+ return calls;
449
+ }
450
+ var OMIT = /* @__PURE__ */ Symbol("deleted statement");
451
+ function canonical(value, target, replacement) {
452
+ if (target && value === target) return canonical(replacement);
453
+ if (Array.isArray(value)) return value.map((v) => canonical(v, target, replacement)).filter((v) => v !== OMIT);
454
+ if (!value || typeof value !== "object") return value;
455
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !["start", "end", "loc", "raw"].includes(key)).sort(([a], [b]) => a.localeCompare(b)).map(([key, v]) => [key, canonical(v, target, replacement)]));
456
+ }
457
+ var parseJs = (source) => parse(source, { ecmaVersion: "latest", sourceType: "module", locations: true });
458
+ function jsMutations(before, source, called) {
459
+ let ast, previous;
460
+ try {
461
+ ast = parseJs(source);
462
+ previous = parseJs(before);
463
+ } catch {
464
+ return [];
465
+ }
466
+ const fingerprint = (node) => JSON.stringify(canonical(node));
467
+ const original = /* @__PURE__ */ new Set();
468
+ function remember(node) {
469
+ if (["ReturnStatement", "ThrowStatement", "ExpressionStatement", "VariableDeclaration", "IfStatement"].includes(node.type)) original.add(fingerprint(node));
470
+ if (node.type === "IfStatement") original.add(fingerprint(node.test));
471
+ for (const value of Object.values(node)) {
472
+ if (Array.isArray(value)) {
473
+ for (const child of value) if (child?.type) remember(child);
474
+ } else if (value && typeof value === "object" && "type" in value) remember(value);
475
+ }
476
+ }
477
+ remember(previous);
478
+ const changed = changedSourceLines(before, source), candidates = [];
479
+ const used = /* @__PURE__ */ new Set();
480
+ function visit(node, fn) {
481
+ if (candidates.length >= MUTATION_LIMIT) return;
482
+ if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(node.type)) fn = node;
483
+ const line = node.loc.start.line;
484
+ if (fn && changed.has(line) && !used.has(line) && !original.has(fingerprint(node)) && called.some((r) => r.start === fn.start && r.end === fn.end)) {
485
+ const start = source.lastIndexOf("\n", node.start - 1) + 1;
486
+ const end = source.indexOf("\n", node.end);
487
+ const lineEnd = end < 0 ? source.length : end;
488
+ const fullLine = /^\s*$/.test(source.slice(start, node.start)) && /^\s*$/.test(source.slice(node.end, lineEnd));
489
+ const attempts = [];
490
+ if (fullLine && node.loc.end.line === line && ["ReturnStatement", "ThrowStatement", "ExpressionStatement", "VariableDeclaration", "IfStatement"].includes(node.type)) {
491
+ attempts.push({ text: source.slice(0, node.start) + source.slice(node.end), replacement: OMIT, target: node, operation: "delete statement" });
492
+ }
493
+ if (node.type === "IfStatement") {
494
+ const test = node.test;
495
+ if (test.loc.end.line === line && !original.has(fingerprint(test))) attempts.push({
496
+ text: source.slice(0, test.start) + `!(${source.slice(test.start, test.end)})` + source.slice(test.end),
497
+ target: test,
498
+ replacement: { type: "UnaryExpression", operator: "!", prefix: true, argument: canonical(test) },
499
+ operation: "negate condition"
500
+ });
501
+ }
502
+ for (const attempt of attempts) {
503
+ try {
504
+ const parsed = parseJs(attempt.text);
505
+ if (JSON.stringify(canonical(parsed)) !== JSON.stringify(canonical(ast, attempt.target, attempt.replacement))) continue;
506
+ candidates.push({ line, functionName: fn.id?.name ?? "<anonymous>", source: attempt.text, operation: attempt.operation });
507
+ used.add(line);
508
+ break;
509
+ } catch {
510
+ }
511
+ }
512
+ }
513
+ for (const value of Object.values(node)) {
514
+ if (Array.isArray(value)) {
515
+ for (const child of value) if (child?.type) visit(child, fn);
516
+ } else if (value && typeof value === "object" && "type" in value) visit(value, fn);
517
+ }
518
+ }
519
+ visit(ast);
520
+ return candidates.slice(0, MUTATION_LIMIT);
521
+ }
522
+ var PYTHON_MUTATIONS = `import ast,copy,json,sys
523
+ data=json.load(sys.stdin)
524
+ source=data['source']
525
+ lines=source.splitlines(keepends=True)
526
+ tree=ast.parse(source)
527
+ original={ast.dump(n) for n in ast.walk(ast.parse(data['before'])) if isinstance(n,(ast.stmt,ast.expr))}
528
+ # Require a lossless syntax round trip before editing this file.
529
+ assert ast.dump(ast.parse(ast.unparse(tree)))==ast.dump(tree)
530
+ changed=set(data['changed'])
531
+ called=set(data['called'])
532
+ out=[]
533
+ used=set()
534
+ def offset(line,col):
535
+ return sum(map(len,lines[:line-1]))+len(lines[line-1].encode('utf-8')[:col].decode('utf-8'))
536
+ class Replace(ast.NodeTransformer):
537
+ def __init__(self,target,replacement): self.target,self.replacement=target,replacement
538
+ def visit(self,node):
539
+ if node is self.target: return self.replacement
540
+ return super().visit(node)
541
+ def visit(node,fn=None):
542
+ if len(out)>=4: return
543
+ if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
544
+ fn=node if not node.decorator_list and isinstance(node,ast.FunctionDef) else None
545
+ line=getattr(node,'lineno',0)
546
+ if fn and fn.lineno in called and line in changed and line not in used and ast.dump(node) not in original:
547
+ attempts=[]
548
+ if isinstance(node,(ast.Return,ast.Raise,ast.Assign,ast.AnnAssign,ast.AugAssign,ast.Expr,ast.If)):
549
+ start=offset(line,node.col_offset)
550
+ end=offset(node.end_lineno,node.end_col_offset)
551
+ if node.end_lineno==line and not source[offset(line,0):start].strip() and not source[end:offset(line,len(lines[line-1].encode('utf-8')))].strip():
552
+ attempts.append((source[:start]+source[end:],node,None,'delete statement'))
553
+ target=node.test if isinstance(node,ast.If) else node.value if isinstance(node,ast.Return) else None
554
+ if target is not None and target.lineno==target.end_lineno and ast.dump(target) not in original:
555
+ start=offset(target.lineno,target.col_offset)
556
+ end=offset(target.end_lineno,target.end_col_offset)
557
+ replacement=ast.UnaryOp(op=ast.Not(),operand=copy.deepcopy(target))
558
+ attempts.append((source[:start]+'not ('+source[start:end]+')'+source[end:],target,replacement,'negate condition' if isinstance(node,ast.If) else 'negate return'))
559
+ for text,target,replacement,operation in attempts:
560
+ try:
561
+ parsed=ast.parse(text)
562
+ memo={}
563
+ expected=copy.deepcopy(tree,memo)
564
+ expected=Replace(memo[id(target)],replacement).visit(expected)
565
+ same=ast.dump(parsed)==ast.dump(expected) and ast.dump(ast.parse(ast.unparse(parsed)))==ast.dump(parsed)
566
+ except (SyntaxError,ValueError): same=False
567
+ if same:
568
+ out.append(dict(line=line,functionName=fn.name,source=text,operation=operation))
569
+ used.add(line)
570
+ break
571
+ for child in ast.iter_child_nodes(node): visit(child,fn)
572
+ visit(tree)
573
+ print(json.dumps(out[:4]))
574
+ `;
575
+ function pythonMutations(before, source, called, dir) {
576
+ const r = spawnSync3("python3", ["-c", PYTHON_MUTATIONS], {
577
+ cwd: dir,
578
+ encoding: "utf8",
579
+ maxBuffer: 2 * 1024 * 1024,
580
+ input: JSON.stringify({ before, source, changed: [...changedSourceLines(before, source)], called })
581
+ });
582
+ if (r.status !== 0) return [];
583
+ try {
584
+ return JSON.parse(r.stdout);
585
+ } catch {
586
+ return [];
587
+ }
588
+ }
589
+
590
+ // src/dynamic-worker.ts
591
+ var options = JSON.parse(readFileSync4(process.argv[2], "utf8"));
592
+ var scratch = dirname2(process.argv[2]);
593
+ var result = { status: "completed", findings: [], runs: [], notes: [], elapsedMs: 0 };
594
+ function evidence(...runs) {
595
+ return runs.map((r) => `${r.label}: \`${r.command}\`, exit ${r.exitCode}, ${r.passed.length} passed, ${r.failed.length} failed${r.failed.length ? ` (${r.failed.join(", ")})` : ""}${r.fileRuns ? "; " + r.fileRuns.map((f) => `\`${f.command}\`, exit ${f.exitCode}, file ${f.file}`).join("; ") : ""}`).join("; ");
596
+ }
597
+ function finding(id, file, message, runs, line = 1) {
598
+ result.findings.push({ verifierId: id, severity: "warn", file, line, message: `${message} Evidence: ${evidence(...runs)}.`, suggestion: "Review the observed behavior and add a focused regression test. Runtime evidence cannot establish intent." });
599
+ }
600
+ try {
601
+ let run2 = function(tree, label) {
602
+ const dir = join4(scratch, `run-${result.runs.length + 1}`);
603
+ materialize(tree, dir, options.cwd);
604
+ const runAdapter = detectAdapter(tree, [...tree.keys()].filter((p) => isTest(p)));
605
+ const testRun = runTests(runAdapter, dir, label);
606
+ result.runs.push(testRun);
607
+ return { run: testRun, dir };
608
+ }, withTests2 = function(source, tests) {
609
+ const hybrid = new Map(source);
610
+ for (const p of /* @__PURE__ */ new Set([...oldTests, ...nextTests])) hybrid.delete(p);
611
+ for (const [p, data] of tests) if (isTest(p)) hybrid.set(p, data);
612
+ return hybrid;
613
+ };
614
+ run = run2, withTests = withTests2;
615
+ const { old, next } = loadSnapshots(options);
616
+ const isTest = picomatch(options.testPathGlobs, { dot: true });
617
+ const oldTests = [...old.keys()].filter((p) => isTest(p)), nextTests = [...next.keys()].filter((p) => isTest(p));
618
+ const adapter = detectAdapter(old, oldTests);
619
+ const nextAdapter = detectAdapter(next, nextTests);
620
+ if (adapter.kind !== nextAdapter.kind) throw Error("not run: test framework changed");
621
+ if ([...next.keys()].some((p) => !isTest(p) && /\.(?:go|rs|java|rb|php|cs|kt|cpp|c|swift)$/.test(p) && old.get(p)?.data.toString() !== next.get(p)?.data.toString())) {
622
+ result.notes.push("not run: probes for languages other than Node JavaScript and Python");
623
+ }
624
+ const original = run2(old, "original tests / original code").run;
625
+ const current = run2(next, "new tests / new code");
626
+ if (original.failed.length && current.run.exitCode === 0) {
627
+ result.notes.push("Original tests are red and new tests are green. Continue with per-test comparisons and applicable probes; this is a normal fix shape.");
628
+ }
629
+ const testChanges = [.../* @__PURE__ */ new Set([...oldTests, ...nextTests])].filter((p) => old.get(p)?.data.toString() !== next.get(p)?.data.toString());
630
+ const changedSource = [.../* @__PURE__ */ new Set([...old.keys(), ...next.keys()])].some((p) => !isTest(p) && !/\.(?:md|rst|txt)$/i.test(p) && old.get(p)?.data.toString() !== next.get(p)?.data.toString());
631
+ const intent = documentedIntent(old, next);
632
+ const edits = testChanges.filter((p) => expectedEdits(old.get(p)?.data.toString() ?? "", next.get(p)?.data.toString() ?? ""));
633
+ const cross = testChanges.length ? run2(withTests2(next, old), "original tests / new code").run : current.run;
634
+ if (!intent) {
635
+ for (const id of cross.failed.filter((id2) => original.passed.includes(id2))) {
636
+ const file = locateTest(id, old, oldTests);
637
+ if (file && !edits.includes(file)) finding("DY001", file, `Previously passing original test ${JSON.stringify(id)} fails against the new code`, [original, current.run, cross]);
638
+ }
639
+ }
640
+ let reverse;
641
+ if (testChanges.length) {
642
+ reverse = run2(withTests2(old, next), "new tests / original code").run;
643
+ const both = current.run.passed.filter((id) => reverse.passed.includes(id));
644
+ if (both.length) result.notes.push(`${both.length} named tests passed on both code revisions. This alone does not show that changed tests exercise the change and is not a finding.`);
645
+ if (!changedSource && !intent && testChanges.length === 1 && edits.length && expectationOnlyEdit(old.get(edits[0])?.data.toString() ?? "", next.get(edits[0])?.data.toString() ?? "")) {
646
+ const rejected = reverse.failed.filter((id) => original.passed.includes(id));
647
+ const accepted = reverse.passed.filter((id) => original.failed.includes(id));
648
+ for (const id of [...rejected, ...accepted]) {
649
+ const file = locateTest(id, old, oldTests);
650
+ const mismatch = accepted.includes(id) ? "failed with the original expectation, but the edited expectation passes on that same old code; the edit already matches the old behavior" : "passed with the original expectation, but the edited expectation fails on that same old code";
651
+ if (file && edits.includes(file)) finding("DY003", file, `Only the expected literal changed (${expectationChanges(old.get(file)?.data.toString() ?? "", next.get(file)?.data.toString() ?? "").map((e) => `${e.before} to ${e.after}`).join(", ")}): test ${JSON.stringify(id)} ${mismatch}`, [original, current.run, reverse]);
652
+ }
653
+ }
654
+ }
655
+ if (current.run.exitCode === 0 && !testChanges.length && !intent) {
656
+ for (const probe of findProbes(old, next, isTest)) {
657
+ const values = probeInputs(probe.input);
658
+ const observed = [];
659
+ const probeRuns = [];
660
+ for (const [tree, label] of [[old, "probe / original code"], [next, "probe / new code"]]) {
661
+ const dir = join4(scratch, `probe-${result.runs.length + 1}`);
662
+ materialize(tree, dir, options.cwd);
663
+ let command, args;
664
+ if (probe.language === "js") {
665
+ const script = `const m=await import(${JSON.stringify("./" + probe.file)});const f=m[${JSON.stringify(probe.name)}]??m.default?.[${JSON.stringify(probe.name)}];console.log(JSON.stringify(${JSON.stringify(values)}.map(x=>f(x))));`;
666
+ writeFileSync4(join4(dir, ".proctor-probe.mjs"), script);
667
+ command = process.execPath;
668
+ args = [".proctor-probe.mjs"];
669
+ } else {
670
+ const script = `import importlib.util,json
671
+ s=importlib.util.spec_from_file_location('probe',${JSON.stringify(probe.file)})
672
+ m=importlib.util.module_from_spec(s)
673
+ s.loader.exec_module(m)
674
+ print(json.dumps([getattr(m,${JSON.stringify(probe.name)})(x) for x in json.loads(${JSON.stringify(JSON.stringify(values))})]))
675
+ `;
676
+ writeFileSync4(join4(dir, ".proctor-probe.py"), script);
677
+ command = "python3";
678
+ args = [".proctor-probe.py"];
679
+ }
680
+ const r = spawnSync4(command, args, { cwd: dir, encoding: "utf8", maxBuffer: 128 * 1024 });
681
+ if (r.status !== 0) {
682
+ observed.length = 0;
683
+ break;
684
+ }
685
+ let outputs;
686
+ try {
687
+ outputs = JSON.parse(r.stdout);
688
+ } catch {
689
+ observed.length = 0;
690
+ break;
691
+ }
692
+ if (!Array.isArray(outputs) || outputs.length !== values.length || outputs.some((n) => !["number", "string", "boolean"].includes(typeof n) || typeof n === "number" && !Number.isFinite(n))) {
693
+ observed.length = 0;
694
+ break;
695
+ }
696
+ observed.push(outputs);
697
+ const pr = { label, command: `${command} ${args.join(" ")} (${probe.file}:${probe.name}, inputs ${JSON.stringify(values)})`, exitCode: 0, passed: [], failed: [], detail: `outputs ${JSON.stringify(outputs)}` };
698
+ result.runs.push(pr);
699
+ probeRuns.push(pr);
700
+ }
701
+ if (observed.length === 2 && values.every((_, i) => i === 1 || observed[0][i] === observed[1][i]) && observed[0][1] !== observed[1][1]) {
702
+ finding("DY002", probe.file, `Literal branch in ${probe.name} contradicts the original simple reference only at ${JSON.stringify(probe.input)}: old ${JSON.stringify(observed[0])}, new ${JSON.stringify(observed[1])}, inputs ${JSON.stringify(values)}`, [original, current.run, ...probeRuns], probe.line);
703
+ }
704
+ }
705
+ }
706
+ if (original.failed.length && current.run.exitCode === 0 && !testChanges.length && !intent) {
707
+ try {
708
+ const coverage = coverageEnvironment(current.dir);
709
+ const control = runTests(nextAdapter, current.dir, "new tests / new code coverage control", coverage.env);
710
+ result.runs.push(control);
711
+ const samePasses = (a, b) => a.exitCode === 0 && b.exitCode === 0 && !a.failed.length && !b.failed.length && a.passed.length > 0 && a.passed.length === b.passed.length && a.passed.every((id) => b.passed.includes(id));
712
+ if (samePasses(current.run, control)) {
713
+ const calls = readCalls(coverage.path, current.dir);
714
+ let count = 0;
715
+ for (const [file, entry] of next) {
716
+ if (count >= MUTATION_LIMIT) break;
717
+ if (isTest(file) || !/\.(?:js|mjs|cjs|py)$/.test(file)) continue;
718
+ const before = old.get(file)?.data.toString() ?? "", source = entry.data.toString();
719
+ if (before === source) continue;
720
+ const path = join4(current.dir, file);
721
+ const candidates = file.endsWith(".py") ? pythonMutations(before, source, calls.python.get(path) ?? [], current.dir) : jsMutations(before, source, calls.js.get(path) ?? []);
722
+ for (const mutation of candidates) {
723
+ if (count >= MUTATION_LIMIT) break;
724
+ count++;
725
+ let mutant;
726
+ try {
727
+ writeFileSync4(path, mutation.source);
728
+ mutant = runTests(nextAdapter, current.dir, `mutation ${count}: ${mutation.operation}, ${file}:${mutation.line} (${mutation.functionName})`);
729
+ result.runs.push(mutant);
730
+ } catch {
731
+ } finally {
732
+ writeFileSync4(path, source);
733
+ }
734
+ if (mutant && samePasses(current.run, mutant)) {
735
+ const restored = runTests(nextAdapter, current.dir, `mutation ${count}: restored control`);
736
+ result.runs.push(restored);
737
+ if (samePasses(current.run, restored)) finding(
738
+ "DY004",
739
+ file,
740
+ `${mutation.operation} at ${file}:${mutation.line} in called function ${mutation.functionName} left every named test passing; this changed statement has no demonstrated test protection`,
741
+ [original, current.run, control, mutant, restored],
742
+ mutation.line
743
+ );
744
+ }
745
+ }
746
+ }
747
+ result.notes.push(`Mutation-lite: ${Math.min(count, MUTATION_LIMIT)} candidates run, cap ${MUTATION_LIMIT}; only parser-confirmed statements in called functions are eligible.`);
748
+ } else result.notes.push("not run: mutation-lite coverage control did not reproduce the green suite");
749
+ } catch (error) {
750
+ result.notes.push(`not run: mutation-lite could not establish reliable evidence: ${String(error)}`);
751
+ }
752
+ }
753
+ const raceFiles = testChanges.filter((p) => /(?:sleep\s*\(|setTimeout\s*\(|\bretry\b|\bretries\b)/.test(next.get(p)?.data.toString() ?? "") && (next.get(p)?.data.toString().match(/sleep\s*\(|setTimeout\s*\(|\bretry\b|\bretries\b/g)?.length ?? 0) > (old.get(p)?.data.toString().match(/sleep\s*\(|setTimeout\s*\(|\bretry\b|\bretries\b/g)?.length ?? 0));
754
+ if (raceFiles.length && current.run.exitCode === 0) {
755
+ const repeats = [current.run];
756
+ for (let i = 1; i <= 3; i++) {
757
+ const repeated = runTests(nextAdapter, current.dir, `new tests / new code repeat ${i}`);
758
+ result.runs.push(repeated);
759
+ repeats.push(repeated);
760
+ }
761
+ const ids = new Set(repeats.flatMap((r) => r.failed));
762
+ for (const id of ids) {
763
+ const file = locateTest(id, next, raceFiles);
764
+ if (file && repeats.some((r) => r.passed.includes(id)) && repeats.filter((r) => r.failed.includes(id)).length >= 2) {
765
+ finding("DY005", file, `Test ${JSON.stringify(id)} has inconsistent outcomes across four runs after adding a sleep or retry; this can also be order dependence`, repeats);
766
+ }
767
+ }
768
+ }
769
+ } catch (error) {
770
+ result.status = "skipped";
771
+ result.reason = String(error).replace(/^Error: /, "");
772
+ result.findings = [];
773
+ }
774
+ var run;
775
+ var withTests;
776
+ process.stdout.write(JSON.stringify(result));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kavishdua/proctor",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Catch AI agents gaming their test suites: deleted tests, skipped tests, weakened assertions, hardcoded outputs",
5
5
  "homepage": "https://github.com/catfish-1234/proctor#readme",
6
6
  "bugs": {
@@ -61,6 +61,7 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@anthropic-ai/sdk": "^0.132.0",
64
+ "acorn": "^8.19.0",
64
65
  "brace-expansion": "^5.0.12",
65
66
  "commander": "^14.0.3",
66
67
  "parse-diff": "0.12.0",
@@ -5,6 +5,18 @@
5
5
  "description": "Configuration for proctor, AI agent test-tampering guard",
6
6
  "type": "object",
7
7
  "properties": {
8
+ "dynamic": {
9
+ "type": "boolean",
10
+ "default": false,
11
+ "description": "Opt in to isolated execution of the project tests for dynamic checks."
12
+ },
13
+ "dynamicTimeoutMs": {
14
+ "type": "integer",
15
+ "minimum": 1,
16
+ "maximum": 2147483647,
17
+ "default": 60000,
18
+ "description": "Overall dynamic budget in milliseconds, including snapshot preparation."
19
+ },
8
20
  "enabled": {
9
21
  "type": "array",
10
22
  "items": { "type": "string", "pattern": "^(?:RH|WI)\\d{3}$" },