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