jules-orchestrator-kit 0.58.0 → 0.60.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 +2 -2
- package/bin/agentctl.mjs +6 -0
- package/package.json +1 -1
- package/src/coverage.mjs +2 -6
- package/src/engine.mjs +7 -5
- package/src/evidence.mjs +2 -14
- package/src/mutation.mjs +2 -8
- package/src/security.mjs +921 -97
- package/src/test-paths.mjs +68 -0
package/README.md
CHANGED
|
@@ -204,7 +204,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
204
204
|
* **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
|
|
205
205
|
* **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
|
|
206
206
|
* **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
|
|
207
|
-
* **Verified Test Suite:** Tested with **
|
|
207
|
+
* **Verified Test Suite:** Tested with **957 unit tests across 134 suites passing in < 15.0s**.
|
|
208
208
|
|
|
209
209
|
<br/>
|
|
210
210
|
|
|
@@ -237,7 +237,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
237
237
|
| `doctor` | `agentctl doctor [--probe] [--json]` | Diagnostic check runner. `--probe` additionally starts the configured provider's CLI to confirm it answers, rather than only finding it on `PATH`. | `0` (Healthy), `1` (Failures) |
|
|
238
238
|
| `queue` | `agentctl queue [--dag] [--concurrency <n>] [--dry-run] [--json]` | Consumes and executes task envelopes in `.agent/jules-queue/` with Kahn's DAG dependency resolution. Non-task files (manifests, `README.md`) are skipped, and `--dry-run` previews without moving anything. | `0` (Complete) |
|
|
239
239
|
| `swarm` | `agentctl swarm [--json]` | Runs parallel multi-agent swarm across worker slots with PID liveness detection. | `0` (Complete) |
|
|
240
|
-
| `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--allow-protected] [--allow-test-
|
|
240
|
+
| `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--allow-protected] [--allow-test-change <kind>] [--json] [--json-report <path>]` | Runs security, secret scanning, rules budget audit, and tiered verification gates (with declarative assertion support) against working tree or branch. | `0` (Approved), `1` (Budget/Arg), `3` (Scope), `4` (Verify), `5` (Diff >75K), `6` (Secret), `8` (Flaky) |
|
|
241
241
|
| `mutate` / `mutation` | `agentctl mutate [--min-score <n>] [--max-mutants <n>] [--cmd <testCmd>] [--json]` | Runs zero-dependency diff mutation testing harness on changed hunks with operator inversion and safety rollback. | `0` (Passed), `1` (Score Low) |
|
|
242
242
|
| `coverage` | `agentctl coverage [--min <pct>] [--cmd <testCmd>] [--base <ref>] [--json]` | Runs native zero-dependency V8 diff coverage check against added diff lines. | `0` (Passed), `1` (Low Coverage) |
|
|
243
243
|
| `probe` / `stability` | `agentctl probe [--repeat <n>] [--min <passRate>] [--cmd <testCmd>] [--json]` | Probes test suite flakiness across N consecutive iterations with oscillation detection. | `0` (Passed), `1` (Flaky) |
|
package/bin/agentctl.mjs
CHANGED
|
@@ -388,7 +388,12 @@ async function main() {
|
|
|
388
388
|
// of spec, which necessarily rewrites what a test expects, hit a
|
|
389
389
|
// CRITICAL finding at exit 6 with no documented way past it. A guard
|
|
390
390
|
// with no override is not a guard, it is an outage.
|
|
391
|
+
//
|
|
392
|
+
// This one is the blunt form and turns off all six checks. Prefer
|
|
393
|
+
// `--allow-test-change <kind>`: answering one finding should not
|
|
394
|
+
// silence five other checks nobody looked at.
|
|
391
395
|
"allow-test-modifications": { type: "boolean" },
|
|
396
|
+
"allow-test-change": { type: "string", multiple: true },
|
|
392
397
|
json: { type: "boolean", short: "j" },
|
|
393
398
|
"json-report": { type: "string" },
|
|
394
399
|
"dry-run": { type: "boolean", short: "d" },
|
|
@@ -409,6 +414,7 @@ async function main() {
|
|
|
409
414
|
fix: values.fix,
|
|
410
415
|
allowProtected: values["allow-protected"],
|
|
411
416
|
allowTestModifications: values["allow-test-modifications"],
|
|
417
|
+
allowTestChanges: values["allow-test-change"],
|
|
412
418
|
jsonReport: values["json-report"],
|
|
413
419
|
});
|
|
414
420
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/coverage.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -18,12 +19,7 @@ export function isExcludedFromCoverage(filePath = "") {
|
|
|
18
19
|
norm.startsWith(".agent/") ||
|
|
19
20
|
norm.startsWith(".github/") ||
|
|
20
21
|
norm.startsWith(".git/") ||
|
|
21
|
-
norm
|
|
22
|
-
norm.startsWith("tests/") ||
|
|
23
|
-
norm.includes("/__tests__/") ||
|
|
24
|
-
norm.includes(".test.") ||
|
|
25
|
-
norm.includes(".spec.") ||
|
|
26
|
-
norm.includes("_test.") ||
|
|
22
|
+
isTestPath(norm) ||
|
|
27
23
|
norm.endsWith(".md") ||
|
|
28
24
|
norm.endsWith(".json") ||
|
|
29
25
|
norm.endsWith(".yml") ||
|
package/src/engine.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
|
|
3
4
|
import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd } from "./git.mjs";
|
|
4
5
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
@@ -274,7 +275,11 @@ export async function gate(opts = {}) {
|
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
// Phase 3: Diff Secret Scanner & Security Checks
|
|
277
|
-
const secretResult = scanDiff(diffStr, {
|
|
278
|
+
const secretResult = scanDiff(diffStr, {
|
|
279
|
+
root,
|
|
280
|
+
allowTestModifications: opts.allowTestModifications === true,
|
|
281
|
+
allowTestChanges: opts.allowTestChanges,
|
|
282
|
+
});
|
|
278
283
|
// A binary file reaches the scanner as one summary line, so its contents were
|
|
279
284
|
// never looked at — a NUL byte in front of a token was enough to hide it.
|
|
280
285
|
// Inspect those files directly and fold the verdict in.
|
|
@@ -494,10 +499,7 @@ export async function gate(opts = {}) {
|
|
|
494
499
|
const postTestHash = postTestHashResult.treeHash;
|
|
495
500
|
let testTampered = false;
|
|
496
501
|
if (config.evidence?.strictTestLock && !opts.allowTestModifications && preTestHashResult.fileCount > 0) {
|
|
497
|
-
const changedTestFile = files.find((f) =>
|
|
498
|
-
const lower = f.toLowerCase();
|
|
499
|
-
return lower.startsWith("test/") || lower.startsWith("tests/") || lower.includes(".test.") || lower.includes(".spec.");
|
|
500
|
-
});
|
|
502
|
+
const changedTestFile = files.find((f) => isTestPath(f));
|
|
501
503
|
if (changedTestFile && preTestHash !== postTestHash) {
|
|
502
504
|
testTampered = true;
|
|
503
505
|
}
|
package/src/evidence.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { join, resolve, relative, isAbsolute, sep, extname } from "node:path";
|
|
15
15
|
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import { execSync } from "node:child_process";
|
|
17
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Normalizes file path to POSIX slashes.
|
|
@@ -164,20 +165,7 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
164
165
|
|
|
165
166
|
// Filter test files if specifically looking for test suites
|
|
166
167
|
if (options.testOnly) {
|
|
167
|
-
fileList = fileList.filter((f) =>
|
|
168
|
-
const lower = f.toLowerCase();
|
|
169
|
-
return (
|
|
170
|
-
lower.startsWith("test/") ||
|
|
171
|
-
lower.startsWith("tests/") ||
|
|
172
|
-
lower.startsWith("__tests__/") ||
|
|
173
|
-
lower.startsWith("spec/") ||
|
|
174
|
-
lower.includes(".test.") ||
|
|
175
|
-
lower.includes(".spec.") ||
|
|
176
|
-
lower.includes("_test.") ||
|
|
177
|
-
lower.endsWith("test.sol") ||
|
|
178
|
-
lower.endsWith(".t.sol")
|
|
179
|
-
);
|
|
180
|
-
});
|
|
168
|
+
fileList = fileList.filter((f) => isTestPath(f));
|
|
181
169
|
}
|
|
182
170
|
|
|
183
171
|
const fileHashes = {};
|
package/src/mutation.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { resolve, isAbsolute } from "node:path";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { diffText, runCmd } from "./git.mjs";
|
|
@@ -195,14 +196,7 @@ export function isExcludedFromMutation(filePath = "") {
|
|
|
195
196
|
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
196
197
|
|
|
197
198
|
// Exclude test files
|
|
198
|
-
if (
|
|
199
|
-
normalized.includes(".test.") ||
|
|
200
|
-
normalized.includes(".spec.") ||
|
|
201
|
-
normalized.includes("_test.") ||
|
|
202
|
-
normalized.includes("/test/") ||
|
|
203
|
-
normalized.includes("/tests/") ||
|
|
204
|
-
normalized.includes("/__tests__/")
|
|
205
|
-
) {
|
|
199
|
+
if (isTestPath(normalized)) {
|
|
206
200
|
return true;
|
|
207
201
|
}
|
|
208
202
|
|
package/src/security.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { openSync, readFileSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { isTestPath } from "./test-paths.mjs";
|
|
2
3
|
import { dirname, join, basename } from "node:path";
|
|
3
4
|
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
|
|
@@ -1101,6 +1102,886 @@ function locateFindingLine(lines, type, file = null) {
|
|
|
1101
1102
|
return null;
|
|
1102
1103
|
}
|
|
1103
1104
|
|
|
1105
|
+
// ---------------------------------------------------------------------------
|
|
1106
|
+
// Statement-level expectation-rewrite detection (multi-line aware)
|
|
1107
|
+
// ---------------------------------------------------------------------------
|
|
1108
|
+
//
|
|
1109
|
+
// The original pairing ran on physical lines. That caught
|
|
1110
|
+
// `assert.equal(add(1, 2), 3);` becoming `assert.equal(add(1, 2), -1);`, but
|
|
1111
|
+
// the same edit walked straight through the moment it was wrapped across
|
|
1112
|
+
// lines — which every formatter does the day a line runs long, and which an
|
|
1113
|
+
// agent doing an ordinary reformat does on its own:
|
|
1114
|
+
//
|
|
1115
|
+
// -assert.equal(
|
|
1116
|
+
// - add(1, 2),
|
|
1117
|
+
// - 3
|
|
1118
|
+
// -);
|
|
1119
|
+
// +assert.equal(
|
|
1120
|
+
// + add(1, 2),
|
|
1121
|
+
// + -1
|
|
1122
|
+
// +);
|
|
1123
|
+
//
|
|
1124
|
+
// The value lives on a line that carries no assertion keyword, so neither
|
|
1125
|
+
// side ever paired, and the suite went from checking that addition works to
|
|
1126
|
+
// certifying that it is broken. The statement, not the line, is the unit an
|
|
1127
|
+
// agent rewrites, so the pairing now runs on reassembled statements: a run
|
|
1128
|
+
// of physical lines joined while its delimiters are unbalanced, one of its
|
|
1129
|
+
// strings or comments is still open, a Python line-continuation is pending,
|
|
1130
|
+
// or the next line cannot start a statement of its own. The hunk's context
|
|
1131
|
+
// lines belong to both images and are what make the reassembly possible;
|
|
1132
|
+
// when they are absent (a zero-context diff) the only pair that can survive
|
|
1133
|
+
// is the one where each image is a single fragment, and that pair is taken
|
|
1134
|
+
// too, requiring a literal placeholder so a code change cannot masquerade as
|
|
1135
|
+
// a value change.
|
|
1136
|
+
//
|
|
1137
|
+
// The pairing rule is unchanged in spirit: the two sides must be the *same*
|
|
1138
|
+
// assertion — identical once every literal is blanked out — with different
|
|
1139
|
+
// values. That does not distinguish an attack from a deliberate change of
|
|
1140
|
+
// spec; nothing can, from a diff alone. This reports rather than decides,
|
|
1141
|
+
// and `--allow-test-change expectation` is the answer when the new
|
|
1142
|
+
// expectation is the correct one. Narrow on purpose: the blunt
|
|
1143
|
+
// `--allow-test-modifications` turns off the other five checks too, and a
|
|
1144
|
+
// check that can only be answered by disabling its neighbours ends up
|
|
1145
|
+
// disabling its neighbours.
|
|
1146
|
+
|
|
1147
|
+
// An assertion that states a *specific* expected value. Counting assertions
|
|
1148
|
+
// alone let a test be gutted while looking untouched: swapping
|
|
1149
|
+
// `assert.strictEqual(add(2,3), 5)` for `assert.ok(add(2,3) !== undefined)`
|
|
1150
|
+
// removes one and adds one, so `removed > added` stayed false and the guard
|
|
1151
|
+
// said nothing — while the suite stopped checking the answer.
|
|
1152
|
+
//
|
|
1153
|
+
// The `expect` argument span is a bounded lazy match rather than `[^)]*` so
|
|
1154
|
+
// that a call split across lines with a nested call in its arguments
|
|
1155
|
+
// (`expect(\n formatInvoice(bill)\n).toBe(…`) still recognises the chain.
|
|
1156
|
+
// The bound is a guess: an argument list longer than 240 characters is
|
|
1157
|
+
// rarer than a missed chain.
|
|
1158
|
+
const SPECIFIC_ASSERTION = new RegExp(
|
|
1159
|
+
[
|
|
1160
|
+
"\\bassert(?:\\.strict)?\\.?(?:strictEqual|deepStrictEqual|deepEqual|notStrictEqual|notDeepStrictEqual|equal|notEqual|match|doesNotMatch|throws|rejects|doesNotThrow)\\s*\\(",
|
|
1161
|
+
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.(?:toBe|toEqual|toStrictEqual|toMatch|toMatchObject|toContain|toHaveBeenCalledWith|toThrow|toHaveLength|toBeCloseTo)\\s*\\(",
|
|
1162
|
+
"\\bassert\\.(?:equals|deepEquals|include|lengthOf)\\s*\\(",
|
|
1163
|
+
"assert_eq!|assert_ne!",
|
|
1164
|
+
"\\bt\\.(?:Errorf|Fatalf)\\s*\\(",
|
|
1165
|
+
"\\brequire\\.(?:Equal|NotEqual|Len|Contains|Error|NoError)\\s*\\(",
|
|
1166
|
+
].join("|"),
|
|
1167
|
+
"i"
|
|
1168
|
+
);
|
|
1169
|
+
const isSpecificAssertion = (str) => SPECIFIC_ASSERTION.test(str);
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* An assertion with every literal value replaced by a placeholder.
|
|
1173
|
+
*
|
|
1174
|
+
* Two lines that normalize to the same string are the same assertion about
|
|
1175
|
+
* the same expression; whatever differs between them is a value.
|
|
1176
|
+
*
|
|
1177
|
+
* The number form covers hex, octal, binary, underscores and exponents: the
|
|
1178
|
+
* original decimal-only regex never blanked `0xFF`, so an expectation
|
|
1179
|
+
* rewritten from `0xFF` to `0xFE` normalized to two *different* shapes and
|
|
1180
|
+
* the pair was never formed.
|
|
1181
|
+
*/
|
|
1182
|
+
const blankLiterals = (str) =>
|
|
1183
|
+
str
|
|
1184
|
+
.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "\u0000S")
|
|
1185
|
+
// The sign belongs to the literal: without it `3` and `-1` normalized to
|
|
1186
|
+
// different shapes and the rewritten expectation was never paired.
|
|
1187
|
+
.replace(
|
|
1188
|
+
/(?<![\w$])(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|-?\d[\d_]*(?:\.[\d_]+)?(?:[eE][+-]?\d+)?)/g,
|
|
1189
|
+
"\u0000N"
|
|
1190
|
+
)
|
|
1191
|
+
.replace(/\b(?:true|false|null|undefined|None|True|False|nil)\b/g, "\u0000B")
|
|
1192
|
+
// Whitespace is dropped, not collapsed: the shape is compared for
|
|
1193
|
+
// equality only, and a reformatted statement must normalize to the same
|
|
1194
|
+
// shape as the original — ` <N> );` and ` <N>);` are the same assertion.
|
|
1195
|
+
.replace(/\s+/g, "");
|
|
1196
|
+
|
|
1197
|
+
// The test languages the gate runs over. The scanner below is written for
|
|
1198
|
+
// these four and nothing else; an unrecognised extension falls back to `js`,
|
|
1199
|
+
// which is the strictest of the four for line joining.
|
|
1200
|
+
const TEST_LANG_BY_EXT = new Map([
|
|
1201
|
+
[".js", "js"], [".mjs", "js"], [".cjs", "js"], [".jsx", "js"],
|
|
1202
|
+
[".ts", "js"], [".mts", "js"], [".cts", "js"], [".tsx", "js"],
|
|
1203
|
+
[".py", "python"], [".pyi", "python"],
|
|
1204
|
+
[".go", "go"],
|
|
1205
|
+
[".rs", "rust"],
|
|
1206
|
+
]);
|
|
1207
|
+
|
|
1208
|
+
function langForTestFile(file) {
|
|
1209
|
+
const n = String(file || "").toLowerCase();
|
|
1210
|
+
const dot = n.lastIndexOf(".");
|
|
1211
|
+
if (dot === -1) return "js";
|
|
1212
|
+
return TEST_LANG_BY_EXT.get(n.slice(dot)) || "js";
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function freshScanState() {
|
|
1216
|
+
// str: the open string, or null.
|
|
1217
|
+
// q the quote character
|
|
1218
|
+
// tri Python triple-quoted
|
|
1219
|
+
// raw raw string, no escapes (Go backtick)
|
|
1220
|
+
// rawHashes Rust raw string r#"…"#: terminator is " plus that many #
|
|
1221
|
+
// block: depth of an open /* … */ (nested only in Rust)
|
|
1222
|
+
// accDelta: counted delimiters still open in the current statement
|
|
1223
|
+
// specialStack: counted depth recorded at each non-joining call (see below)
|
|
1224
|
+
return { str: null, block: 0, accDelta: 0, specialStack: [] };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// A call whose opening paren must not join lines: the test name is not the
|
|
1228
|
+
// expectation. Without this, `it("old name", () => { expect(f()).toBe(3); })`
|
|
1229
|
+
// would pair against its renamed copy, because a name is a string and so
|
|
1230
|
+
// blanks to the same placeholder as any value change would. The closer of a
|
|
1231
|
+
// non-joining paren is recognised by the depth it was opened at, so the
|
|
1232
|
+
// running balance stays exact.
|
|
1233
|
+
const NON_JOINING_CALL = /\b(?:it|test|describe|context)\s*\($|\bt\.Run\s*\($/;
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Scan one physical line of source.
|
|
1237
|
+
*
|
|
1238
|
+
* Returns { delta, trailingBackslash }. `delta` is the net number of
|
|
1239
|
+
* still-open ( [ { delimiters outside strings and comments; a statement
|
|
1240
|
+
* continues to the next physical line while it is positive, while a string
|
|
1241
|
+
* or block comment is open (tracked on `state`), or on a Python line
|
|
1242
|
+
* continuation.
|
|
1243
|
+
*
|
|
1244
|
+
* This is not a parser, and the approximations are deliberate: JS regex
|
|
1245
|
+
* literals are detected with a one-token look-behind (a `/` that cannot
|
|
1246
|
+
* follow an identifier, number, `)` or `]` starts one), template
|
|
1247
|
+
* interpolation is treated as opaque string content, and Rust lifetimes are
|
|
1248
|
+
* told apart from char literals by shape alone. `stripComments` below
|
|
1249
|
+
* walks the same constructs, so the two must stay in lock-step.
|
|
1250
|
+
*/
|
|
1251
|
+
function scanSourceLine(text, lang, state) {
|
|
1252
|
+
let delta = 0;
|
|
1253
|
+
let lastSig = "\n";
|
|
1254
|
+
const n = text.length;
|
|
1255
|
+
let i = 0;
|
|
1256
|
+
|
|
1257
|
+
while (i < n) {
|
|
1258
|
+
const c = text[i];
|
|
1259
|
+
const c2 = i + 1 < n ? text[i + 1] : "";
|
|
1260
|
+
|
|
1261
|
+
if (state.str) {
|
|
1262
|
+
const s = state.str;
|
|
1263
|
+
let closed = false;
|
|
1264
|
+
if (s.rawHashes !== undefined) {
|
|
1265
|
+
if (c === '"') {
|
|
1266
|
+
let j = i + 1;
|
|
1267
|
+
let h = 0;
|
|
1268
|
+
while (j < n && text[j] === "#") { h++; j++; }
|
|
1269
|
+
if (h >= s.rawHashes) { i = j; closed = true; }
|
|
1270
|
+
}
|
|
1271
|
+
} else if (s.raw) {
|
|
1272
|
+
closed = c === s.q;
|
|
1273
|
+
} else if (c === "\\") {
|
|
1274
|
+
i += s.tri ? 1 : 2;
|
|
1275
|
+
continue;
|
|
1276
|
+
} else if (c === s.q) {
|
|
1277
|
+
if (s.tri) {
|
|
1278
|
+
if (text[i + 1] === s.q && text[i + 2] === s.q) { i += 3; closed = true; }
|
|
1279
|
+
else { i += 1; }
|
|
1280
|
+
} else {
|
|
1281
|
+
i += 1;
|
|
1282
|
+
closed = true;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (closed) { state.str = null; lastSig = s.q; continue; }
|
|
1286
|
+
i += 1;
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (state.block > 0) {
|
|
1291
|
+
if (c === "/" && c2 === "*") {
|
|
1292
|
+
if (lang === "rust") state.block += 1;
|
|
1293
|
+
i += 2;
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
if (c === "*" && c2 === "/") {
|
|
1297
|
+
state.block -= 1;
|
|
1298
|
+
i += 2;
|
|
1299
|
+
lastSig = "/";
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
i += 1;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// A line comment ends the line.
|
|
1307
|
+
if (c === "/" && c2 === "/") break;
|
|
1308
|
+
if (lang === "python" && c === "#") break;
|
|
1309
|
+
|
|
1310
|
+
if (c === "/" && c2 === "*") {
|
|
1311
|
+
state.block = 1;
|
|
1312
|
+
i += 2;
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// JS regex literal, best effort. Delimiters inside are not counted.
|
|
1317
|
+
if ((lang === "js" || lang === "ts") && c === "/" && !/[\w$)\]}]/.test(lastSig)) {
|
|
1318
|
+
i += 1;
|
|
1319
|
+
let inClass = false;
|
|
1320
|
+
while (i < n) {
|
|
1321
|
+
const rc = text[i];
|
|
1322
|
+
if (rc === "\\") { i += 2; continue; }
|
|
1323
|
+
if (rc === "[") inClass = true;
|
|
1324
|
+
else if (rc === "]") inClass = false;
|
|
1325
|
+
else if (rc === "/" && !inClass) { i += 1; break; }
|
|
1326
|
+
i += 1;
|
|
1327
|
+
}
|
|
1328
|
+
while (i < n && /[a-z]/i.test(text[i])) i += 1; // flags
|
|
1329
|
+
lastSig = "/";
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (c === '"' || c === "'" || (lang === "go" && c === "`")) {
|
|
1334
|
+
if (lang === "python" && c2 === c && text[i + 2] === c) {
|
|
1335
|
+
state.str = { q: c, tri: true };
|
|
1336
|
+
i += 3;
|
|
1337
|
+
} else if (lang === "rust" && c === '"') {
|
|
1338
|
+
if (i > 0 && text[i - 1] === "r") {
|
|
1339
|
+
let j = i - 1;
|
|
1340
|
+
let h = 0;
|
|
1341
|
+
while (j >= 1 && text[j - 1] === "#") { h++; j--; }
|
|
1342
|
+
state.str = { q: '"', rawHashes: h };
|
|
1343
|
+
i += 1;
|
|
1344
|
+
} else {
|
|
1345
|
+
state.str = { q: c };
|
|
1346
|
+
i += 1;
|
|
1347
|
+
}
|
|
1348
|
+
} else if (lang === "rust" && c === "'") {
|
|
1349
|
+
// A char literal is 'X' or '\X' within four characters; anything
|
|
1350
|
+
// else starting with a quote is a lifetime and only the quote is
|
|
1351
|
+
// skipped, or the next line would see a string that never closed.
|
|
1352
|
+
if (c2 === "\\") {
|
|
1353
|
+
const end = text.indexOf("'", i + 2);
|
|
1354
|
+
if (end !== -1 && end - i <= 4) { i = end + 1; lastSig = "'"; continue; }
|
|
1355
|
+
} else if (text[i + 2] === "'" && c2 !== "'") {
|
|
1356
|
+
i += 3;
|
|
1357
|
+
lastSig = "'";
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
i += 1;
|
|
1361
|
+
continue;
|
|
1362
|
+
} else {
|
|
1363
|
+
state.str = { q: c };
|
|
1364
|
+
i += 1;
|
|
1365
|
+
}
|
|
1366
|
+
lastSig = c;
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// A backslash on the very last character is a Python line continuation.
|
|
1371
|
+
if (c === "\\" && i + 1 === n) {
|
|
1372
|
+
return { delta, trailingBackslash: true };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (c === "(") {
|
|
1376
|
+
// `before` ends with the paren itself; NON_JOINING_CALL matches on it.
|
|
1377
|
+
const before = text.slice(0, i + 1).replace(/\s+$/, "");
|
|
1378
|
+
if (NON_JOINING_CALL.test(before)) state.specialStack.push(state.accDelta);
|
|
1379
|
+
else { delta += 1; state.accDelta += 1; }
|
|
1380
|
+
lastSig = c;
|
|
1381
|
+
i += 1;
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
if (c === "[") { delta += 1; state.accDelta += 1; lastSig = c; i += 1; continue; }
|
|
1385
|
+
if (c === "{") {
|
|
1386
|
+
// Go joins braces: the `if got != want { t.Errorf(…) }` block is the
|
|
1387
|
+
// idiomatic Go assertion, and the value lives on its first line. The
|
|
1388
|
+
// other three languages get no brace joining, so a rename of a test
|
|
1389
|
+
// inside a block cannot pair as a value change on its own.
|
|
1390
|
+
if (lang === "go") { delta += 1; state.accDelta += 1; }
|
|
1391
|
+
lastSig = c;
|
|
1392
|
+
i += 1;
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
if (c === ")") {
|
|
1396
|
+
const top = state.specialStack[state.specialStack.length - 1];
|
|
1397
|
+
if (top === state.accDelta) state.specialStack.pop();
|
|
1398
|
+
else { delta -= 1; state.accDelta -= 1; }
|
|
1399
|
+
lastSig = c;
|
|
1400
|
+
i += 1;
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (c === "]") { delta -= 1; state.accDelta -= 1; lastSig = c; i += 1; continue; }
|
|
1404
|
+
if (c === "}") {
|
|
1405
|
+
if (lang === "go") { delta -= 1; state.accDelta -= 1; }
|
|
1406
|
+
lastSig = c;
|
|
1407
|
+
i += 1;
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (!/\s/.test(c)) lastSig = c;
|
|
1412
|
+
i += 1;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return { delta, trailingBackslash: false };
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* The same source with every comment blanked out, strings and line
|
|
1420
|
+
* structure untouched. Shapes and keywords are computed on this text so a
|
|
1421
|
+
* commented-out `expect(…)` cannot make a block an assertion, and a number
|
|
1422
|
+
* changed inside a comment cannot pair as a value change.
|
|
1423
|
+
*/
|
|
1424
|
+
function stripComments(text, lang) {
|
|
1425
|
+
const state = freshScanState();
|
|
1426
|
+
return text.split("\n").map((line) => {
|
|
1427
|
+
let out = "";
|
|
1428
|
+
let pending = 0;
|
|
1429
|
+
const copyCode = (to) => {
|
|
1430
|
+
out += line.slice(pending, to);
|
|
1431
|
+
pending = to;
|
|
1432
|
+
};
|
|
1433
|
+
let i = 0;
|
|
1434
|
+
const n = line.length;
|
|
1435
|
+
|
|
1436
|
+
while (i < n) {
|
|
1437
|
+
const c = line[i];
|
|
1438
|
+
const c2 = i + 1 < n ? line[i + 1] : "";
|
|
1439
|
+
|
|
1440
|
+
if (state.block > 0) {
|
|
1441
|
+
if (c === "/" && c2 === "*") {
|
|
1442
|
+
if (lang === "rust") state.block += 1;
|
|
1443
|
+
i += 2;
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (c === "*" && c2 === "/") {
|
|
1447
|
+
state.block -= 1;
|
|
1448
|
+
i += 2;
|
|
1449
|
+
if (state.block === 0) pending = i;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
i += 1;
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (state.str) {
|
|
1457
|
+
const s = state.str;
|
|
1458
|
+
let closed = false;
|
|
1459
|
+
if (s.rawHashes !== undefined) {
|
|
1460
|
+
if (c === '"') {
|
|
1461
|
+
let j = i + 1;
|
|
1462
|
+
let h = 0;
|
|
1463
|
+
while (j < n && line[j] === "#") { h++; j++; }
|
|
1464
|
+
if (h >= s.rawHashes) { i = j; closed = true; }
|
|
1465
|
+
}
|
|
1466
|
+
} else if (s.raw) {
|
|
1467
|
+
closed = c === s.q;
|
|
1468
|
+
} else if (c === "\\") {
|
|
1469
|
+
i += s.tri ? 1 : 2;
|
|
1470
|
+
continue;
|
|
1471
|
+
} else if (c === s.q) {
|
|
1472
|
+
if (s.tri) {
|
|
1473
|
+
if (line[i + 1] === s.q && line[i + 2] === s.q) { i += 3; closed = true; }
|
|
1474
|
+
else { i += 1; }
|
|
1475
|
+
} else {
|
|
1476
|
+
i += 1;
|
|
1477
|
+
closed = true;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (closed) {
|
|
1481
|
+
state.str = null;
|
|
1482
|
+
copyCode(i);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
i += 1;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (c === "/" && c2 === "/") { copyCode(i); break; }
|
|
1490
|
+
if (lang === "python" && c === "#") { copyCode(i); break; }
|
|
1491
|
+
if (c === "/" && c2 === "*") {
|
|
1492
|
+
copyCode(i);
|
|
1493
|
+
state.block = 1;
|
|
1494
|
+
i += 2;
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
if (c === '"' || c === "'" || (lang === "go" && c === "`")) {
|
|
1498
|
+
copyCode(i);
|
|
1499
|
+
if (lang === "python" && c2 === c && line[i + 2] === c) {
|
|
1500
|
+
state.str = { q: c, tri: true };
|
|
1501
|
+
i += 3;
|
|
1502
|
+
} else if (lang === "rust" && c === '"') {
|
|
1503
|
+
if (i > 0 && line[i - 1] === "r") {
|
|
1504
|
+
let j = i - 1;
|
|
1505
|
+
let h = 0;
|
|
1506
|
+
while (j >= 1 && line[j - 1] === "#") { h++; j--; }
|
|
1507
|
+
state.str = { q: '"', rawHashes: h };
|
|
1508
|
+
i += 1;
|
|
1509
|
+
} else {
|
|
1510
|
+
state.str = { q: c };
|
|
1511
|
+
i += 1;
|
|
1512
|
+
}
|
|
1513
|
+
} else if (lang === "rust" && c === "'") {
|
|
1514
|
+
if (c2 === "\\") {
|
|
1515
|
+
const end = line.indexOf("'", i + 2);
|
|
1516
|
+
if (end !== -1 && end - i <= 4) { i = end + 1; copyCode(i); continue; }
|
|
1517
|
+
} else if (line[i + 2] === "'" && c2 !== "'") {
|
|
1518
|
+
i += 3;
|
|
1519
|
+
copyCode(i);
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
i += 1;
|
|
1523
|
+
copyCode(i);
|
|
1524
|
+
continue;
|
|
1525
|
+
} else {
|
|
1526
|
+
state.str = { q: c };
|
|
1527
|
+
i += 1;
|
|
1528
|
+
}
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
i += 1;
|
|
1532
|
+
}
|
|
1533
|
+
copyCode(n);
|
|
1534
|
+
return out;
|
|
1535
|
+
}).join("\n");
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// A line that cannot start a statement of its own continues the previous
|
|
1539
|
+
// statement: a closing delimiter, a member-access, or an operator.
|
|
1540
|
+
const CONTINUATION_START = /^[)\],.]/;
|
|
1541
|
+
const CONTINUATION_OP_START = /^[+\-*/%<>=&|^:]/;
|
|
1542
|
+
|
|
1543
|
+
// A scanner miscount (an unbalanced delimiter inside a regex literal is the
|
|
1544
|
+
// usual cause) must not be able to merge a whole file into one statement,
|
|
1545
|
+
// which would pair *any* literal change anywhere in the file.
|
|
1546
|
+
const MAX_STATEMENT_LINES = 100;
|
|
1547
|
+
const MAX_STATEMENT_CHARS = 12000;
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Reassemble physical lines into statements.
|
|
1551
|
+
*
|
|
1552
|
+
* `sliceLines` is one image of a hunk in file order: context lines plus the
|
|
1553
|
+
* removed (or added) lines. Context lines are ordinary file text; a
|
|
1554
|
+
* statement spans them freely, which is exactly what makes a value edit
|
|
1555
|
+
* inside a formatter-wrapped assertion visible to the pairing.
|
|
1556
|
+
*
|
|
1557
|
+
* @param {Array<{ kind: string, text: string, oldNo: number|null, newNo: number|null }>} sliceLines
|
|
1558
|
+
* @param {string} lang
|
|
1559
|
+
* @returns {Array<{ text: string, firstOld: number|null, lastOld: number|null, firstNew: number|null, lastNew: number|null, removedLines: Array, addedLines: Array }>}
|
|
1560
|
+
*/
|
|
1561
|
+
function assembleStatements(sliceLines, lang) {
|
|
1562
|
+
const stmts = [];
|
|
1563
|
+
let cur = null;
|
|
1564
|
+
|
|
1565
|
+
const flush = () => {
|
|
1566
|
+
if (!cur) return;
|
|
1567
|
+
stmts.push({
|
|
1568
|
+
text: cur.lines.join("\n"),
|
|
1569
|
+
firstOld: cur.firstOld,
|
|
1570
|
+
lastOld: cur.lastOld,
|
|
1571
|
+
firstNew: cur.firstNew,
|
|
1572
|
+
lastNew: cur.lastNew,
|
|
1573
|
+
removedLines: cur.removedLines,
|
|
1574
|
+
addedLines: cur.addedLines,
|
|
1575
|
+
});
|
|
1576
|
+
cur = null;
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
for (const L of sliceLines) {
|
|
1580
|
+
const trimmed = L.text.replace(/^\s+/, "");
|
|
1581
|
+
const joins =
|
|
1582
|
+
cur !== null &&
|
|
1583
|
+
(cur.delta > 0 ||
|
|
1584
|
+
cur.state.str !== null ||
|
|
1585
|
+
cur.state.block > 0 ||
|
|
1586
|
+
cur.trailingBackslash ||
|
|
1587
|
+
CONTINUATION_START.test(trimmed) ||
|
|
1588
|
+
CONTINUATION_OP_START.test(trimmed));
|
|
1589
|
+
|
|
1590
|
+
if (
|
|
1591
|
+
joins &&
|
|
1592
|
+
cur.lines.length < MAX_STATEMENT_LINES &&
|
|
1593
|
+
cur.chars + L.text.length + 1 <= MAX_STATEMENT_CHARS
|
|
1594
|
+
) {
|
|
1595
|
+
cur.lines.push(L.text);
|
|
1596
|
+
cur.chars += L.text.length + 1;
|
|
1597
|
+
const sc = scanSourceLine(L.text, lang, cur.state);
|
|
1598
|
+
cur.delta += sc.delta;
|
|
1599
|
+
cur.trailingBackslash = sc.trailingBackslash;
|
|
1600
|
+
cur.lastOld = L.oldNo;
|
|
1601
|
+
cur.lastNew = L.newNo;
|
|
1602
|
+
if (L.kind === "-") cur.removedLines.push(L);
|
|
1603
|
+
else if (L.kind === "+") cur.addedLines.push(L);
|
|
1604
|
+
} else {
|
|
1605
|
+
flush();
|
|
1606
|
+
const st = freshScanState();
|
|
1607
|
+
const sc = scanSourceLine(L.text, lang, st);
|
|
1608
|
+
cur = {
|
|
1609
|
+
lines: [L.text],
|
|
1610
|
+
chars: L.text.length,
|
|
1611
|
+
firstOld: L.oldNo,
|
|
1612
|
+
lastOld: L.oldNo,
|
|
1613
|
+
firstNew: L.newNo,
|
|
1614
|
+
lastNew: L.newNo,
|
|
1615
|
+
delta: sc.delta,
|
|
1616
|
+
state: st,
|
|
1617
|
+
trailingBackslash: sc.trailingBackslash,
|
|
1618
|
+
removedLines: L.kind === "-" ? [L] : [],
|
|
1619
|
+
addedLines: L.kind === "+" ? [L] : [],
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
flush();
|
|
1624
|
+
return stmts;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const hasLiteralPlaceholder = (shape) =>
|
|
1628
|
+
shape.includes("\u0000S") || shape.includes("\u0000N") || shape.includes("\u0000B");
|
|
1629
|
+
|
|
1630
|
+
const collapseWhitespace = (s) => s.replace(/\s+/g, " ").trim();
|
|
1631
|
+
const shorten = (s) => (s.length > 160 ? `${s.slice(0, 157)}…` : s);
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Split the argument list of the outermost assertion call in `clean`.
|
|
1635
|
+
*
|
|
1636
|
+
* Comments are already stripped by the caller, so only string state has to be
|
|
1637
|
+
* tracked. Returns null whenever the shape is not confidently understood — a
|
|
1638
|
+
* truncated fragment, an unbalanced hunk, a quoting form not handled here —
|
|
1639
|
+
* because every caller uses this to *suppress* a finding, and failing to
|
|
1640
|
+
* understand a statement must never become a reason to stay quiet about it.
|
|
1641
|
+
*
|
|
1642
|
+
* @param {string} clean - comment-stripped statement text
|
|
1643
|
+
* @param {string} lang
|
|
1644
|
+
* @returns {string[] | null} top-level arguments, trimmed
|
|
1645
|
+
*/
|
|
1646
|
+
function splitAssertionArgs(clean, lang) {
|
|
1647
|
+
SPECIFIC_ASSERTION.lastIndex = 0;
|
|
1648
|
+
const m = SPECIFIC_ASSERTION.exec(clean);
|
|
1649
|
+
if (!m) return null;
|
|
1650
|
+
|
|
1651
|
+
let i = m.index + m[0].length; // just past the opening paren
|
|
1652
|
+
let depth = 1;
|
|
1653
|
+
let quote = null;
|
|
1654
|
+
let triple = false;
|
|
1655
|
+
const args = [];
|
|
1656
|
+
let start = i;
|
|
1657
|
+
|
|
1658
|
+
while (i < clean.length) {
|
|
1659
|
+
const c = clean[i];
|
|
1660
|
+
|
|
1661
|
+
if (quote !== null) {
|
|
1662
|
+
if (c === "\\") { i += 2; continue; }
|
|
1663
|
+
if (triple && c === quote && clean[i + 1] === quote && clean[i + 2] === quote) {
|
|
1664
|
+
quote = null; triple = false; i += 3; continue;
|
|
1665
|
+
}
|
|
1666
|
+
if (!triple && c === quote) { quote = null; i += 1; continue; }
|
|
1667
|
+
i += 1;
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
1672
|
+
if (lang === "python" && clean[i + 1] === c && clean[i + 2] === c) {
|
|
1673
|
+
quote = c; triple = true; i += 3; continue;
|
|
1674
|
+
}
|
|
1675
|
+
quote = c; i += 1; continue;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (c === "(" || c === "[" || c === "{") { depth += 1; i += 1; continue; }
|
|
1679
|
+
if (c === ")" || c === "]" || c === "}") {
|
|
1680
|
+
depth -= 1;
|
|
1681
|
+
if (depth === 0) {
|
|
1682
|
+
args.push(clean.slice(start, i).trim());
|
|
1683
|
+
return args;
|
|
1684
|
+
}
|
|
1685
|
+
i += 1;
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
if (c === "," && depth === 1) {
|
|
1689
|
+
args.push(clean.slice(start, i).trim());
|
|
1690
|
+
start = i + 1;
|
|
1691
|
+
i += 1;
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
i += 1;
|
|
1695
|
+
}
|
|
1696
|
+
return null; // never closed: an unbalanced fragment, so no suppression
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** One plain string literal and nothing else. */
|
|
1700
|
+
const PURE_STRING_LITERAL = new RegExp(
|
|
1701
|
+
[
|
|
1702
|
+
"^'(?:\\\\.|[^'\\\\])*'$",
|
|
1703
|
+
'^"(?:\\\\.|[^"\\\\])*"$',
|
|
1704
|
+
"^`(?:\\\\.|[^`\\\\])*`$",
|
|
1705
|
+
'^"""[\\s\\S]*"""$',
|
|
1706
|
+
"^'''[\\s\\S]*'''$",
|
|
1707
|
+
].join("|")
|
|
1708
|
+
);
|
|
1709
|
+
|
|
1710
|
+
function isPureStringLiteral(arg) {
|
|
1711
|
+
if (!arg) return false;
|
|
1712
|
+
return PURE_STRING_LITERAL.test(arg.trim());
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* Argument positions that carry a message for a human rather than an expected
|
|
1717
|
+
* value.
|
|
1718
|
+
*
|
|
1719
|
+
* Trailing, for `assert.equal(got, want, "message")` and
|
|
1720
|
+
* `assert_eq!(a, b, "message")`; leading, for Go's
|
|
1721
|
+
* `t.Errorf("got %d want %d", got, want)`. Two arguments is the classic
|
|
1722
|
+
* `(actual, expected)` shape, so a string in last position *there* is the
|
|
1723
|
+
* expected value: `assert.equal(name, "Alice")` must still be judged when
|
|
1724
|
+
* "Alice" becomes "Bob".
|
|
1725
|
+
*/
|
|
1726
|
+
function messageArgIndices(args) {
|
|
1727
|
+
const idx = new Set();
|
|
1728
|
+
if (args.length >= 3 && isPureStringLiteral(args[args.length - 1])) idx.add(args.length - 1);
|
|
1729
|
+
if (args.length >= 2 && isPureStringLiteral(args[0])) idx.add(0);
|
|
1730
|
+
return idx;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* True when two assertions differ only in text written to be read by a person.
|
|
1735
|
+
*
|
|
1736
|
+
* Rewording the message on a failing assertion is among the most common edits
|
|
1737
|
+
* any test file receives, and it says nothing whatsoever about what the suite
|
|
1738
|
+
* checks. But a message is a literal, so blanking literals made the two
|
|
1739
|
+
* statements the same shape and the pairing reported a rewritten expectation
|
|
1740
|
+
* every time somebody improved the wording of a failure. Firing on that is
|
|
1741
|
+
* how an operator learns to pass the override without reading it.
|
|
1742
|
+
*/
|
|
1743
|
+
function differsOnlyInMessage(cleanRemoved, cleanAdded, lang) {
|
|
1744
|
+
const a = splitAssertionArgs(cleanRemoved, lang);
|
|
1745
|
+
const b = splitAssertionArgs(cleanAdded, lang);
|
|
1746
|
+
if (!a || !b || a.length !== b.length || a.length === 0) return false;
|
|
1747
|
+
|
|
1748
|
+
const msgIdx = messageArgIndices(a);
|
|
1749
|
+
if (msgIdx.size === 0) return false;
|
|
1750
|
+
|
|
1751
|
+
let sawDifference = false;
|
|
1752
|
+
for (let i = 0; i < a.length; i++) {
|
|
1753
|
+
if (a[i].replace(/\s+/g, "") === b[i].replace(/\s+/g, "")) continue;
|
|
1754
|
+
// A difference outside a message position, or in a position that stopped
|
|
1755
|
+
// being a plain string, is a real change.
|
|
1756
|
+
if (!msgIdx.has(i) || !isPureStringLiteral(b[i])) return false;
|
|
1757
|
+
sawDifference = true;
|
|
1758
|
+
}
|
|
1759
|
+
return sawDifference;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Pair rewritten expectations across the removed and added images of every
|
|
1764
|
+
* hunk of one file, and report each pair.
|
|
1765
|
+
*
|
|
1766
|
+
* A statement is only a candidate when it actually contains a removed
|
|
1767
|
+
* (resp. added) line: a context-only statement is unchanged text on both
|
|
1768
|
+
* sides, and letting it pair would flag a genuinely new assertion that
|
|
1769
|
+
* merely has the same shape as one that stayed put.
|
|
1770
|
+
*
|
|
1771
|
+
* @param {string} file
|
|
1772
|
+
* @param {Array<{ lines: Array }>} hunks
|
|
1773
|
+
* @param {object} stats - per-file stats; the paired physical lines are
|
|
1774
|
+
* spliced out of the count pools so the count-based checks below do not
|
|
1775
|
+
* report the same edit a second time.
|
|
1776
|
+
* @param {Array} violations
|
|
1777
|
+
* @returns {Array<{ r: object, a: object }>} the pairs, for the caller
|
|
1778
|
+
*/
|
|
1779
|
+
function detectExpectationRewrites(file, hunks, stats, violations) {
|
|
1780
|
+
const lang = langForTestFile(file);
|
|
1781
|
+
const allPairs = [];
|
|
1782
|
+
|
|
1783
|
+
for (const hunk of hunks) {
|
|
1784
|
+
const oldSlice = [];
|
|
1785
|
+
const newSlice = [];
|
|
1786
|
+
for (const L of hunk.lines) {
|
|
1787
|
+
if (L.kind !== "+") oldSlice.push(L);
|
|
1788
|
+
if (L.kind !== "-") newSlice.push(L);
|
|
1789
|
+
}
|
|
1790
|
+
const oldStmts = assembleStatements(oldSlice, lang);
|
|
1791
|
+
const newStmts = assembleStatements(newSlice, lang);
|
|
1792
|
+
|
|
1793
|
+
// Candidate statements in file order, per image.
|
|
1794
|
+
const oldCands = [];
|
|
1795
|
+
for (const s of oldStmts) {
|
|
1796
|
+
if (s.removedLines.length === 0) continue;
|
|
1797
|
+
const clean = stripComments(s.text, lang);
|
|
1798
|
+
if (!isSpecificAssertion(clean)) continue;
|
|
1799
|
+
oldCands.push({ s, clean, shape: blankLiterals(clean), canon: clean.replace(/\s+/g, "") });
|
|
1800
|
+
}
|
|
1801
|
+
const newCands = [];
|
|
1802
|
+
for (const s of newStmts) {
|
|
1803
|
+
if (s.addedLines.length === 0) continue;
|
|
1804
|
+
const clean = stripComments(s.text, lang);
|
|
1805
|
+
if (!isSpecificAssertion(clean)) continue;
|
|
1806
|
+
newCands.push({ s, clean, shape: blankLiterals(clean), canon: clean.replace(/\s+/g, "") });
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// The t-th removed candidate of a shape pairs with the t-th added
|
|
1810
|
+
// candidate of the same shape. Position alignment is what keeps a
|
|
1811
|
+
// formatter run over a block of same-shape assertions silent: a greedy
|
|
1812
|
+
// "first different text" pairing would match a re-indented
|
|
1813
|
+
// `assert.equal(f(0), 0)` against its neighbour's value and report a
|
|
1814
|
+
// rewrite that did not happen. It also keeps the pairing linear in the
|
|
1815
|
+
// number of statements, which a 4000-line same-shape table would not
|
|
1816
|
+
// survive as a square of comparisons.
|
|
1817
|
+
const oldByShape = new Map();
|
|
1818
|
+
const newByShape = new Map();
|
|
1819
|
+
for (const c of oldCands) {
|
|
1820
|
+
let arr = oldByShape.get(c.shape);
|
|
1821
|
+
if (!arr) arr = oldByShape.set(c.shape, []).get(c.shape);
|
|
1822
|
+
arr.push(c);
|
|
1823
|
+
}
|
|
1824
|
+
for (const c of newCands) {
|
|
1825
|
+
let arr = newByShape.get(c.shape);
|
|
1826
|
+
if (!arr) arr = newByShape.set(c.shape, []).get(c.shape);
|
|
1827
|
+
arr.push(c);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const pairs = [];
|
|
1831
|
+
for (const [shape, olds] of oldByShape) {
|
|
1832
|
+
const news = newByShape.get(shape) || [];
|
|
1833
|
+
|
|
1834
|
+
// Cancel the assertions that are byte-identical on both sides before
|
|
1835
|
+
// aligning anything.
|
|
1836
|
+
//
|
|
1837
|
+
// Reordering two assertions removes both and adds both back unchanged.
|
|
1838
|
+
// Positional alignment then matched the first removed against the first
|
|
1839
|
+
// added — a different assertion — and reported two rewritten
|
|
1840
|
+
// expectations for an edit that changed no expected value at all. The
|
|
1841
|
+
// same happened to an assertion that simply moved within its block.
|
|
1842
|
+
// What is present unchanged on both sides did not change; only the
|
|
1843
|
+
// residue can have been rewritten.
|
|
1844
|
+
const survivingNew = news.slice();
|
|
1845
|
+
const survivingOld = [];
|
|
1846
|
+
for (const o of olds) {
|
|
1847
|
+
const twin = survivingNew.findIndex((n) => n.canon === o.canon);
|
|
1848
|
+
if (twin === -1) survivingOld.push(o);
|
|
1849
|
+
else survivingNew.splice(twin, 1);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const k = Math.min(survivingOld.length, survivingNew.length);
|
|
1853
|
+
for (let t = 0; t < k; t++) {
|
|
1854
|
+
const r = survivingOld[t];
|
|
1855
|
+
const a = survivingNew[t];
|
|
1856
|
+
if (r.canon === a.canon) continue;
|
|
1857
|
+
if (differsOnlyInMessage(r.clean, a.clean, lang)) continue;
|
|
1858
|
+
pairs.push({ r: r.s, a: a.s });
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// Zero-context hunk: each image is a single fragment and the assertion
|
|
1863
|
+
// keyword may sit outside the hunk entirely. The fragment pair is taken
|
|
1864
|
+
// only when both sides normalize to the same shape *and* that shape
|
|
1865
|
+
// holds a literal — a code change cannot fake it.
|
|
1866
|
+
if (pairs.length === 0 && oldStmts.length === 1 && newStmts.length === 1) {
|
|
1867
|
+
const r = oldStmts[0];
|
|
1868
|
+
const a = newStmts[0];
|
|
1869
|
+
if (r.removedLines.length > 0 && a.addedLines.length > 0) {
|
|
1870
|
+
const sr = blankLiterals(stripComments(r.text, lang));
|
|
1871
|
+
const sa = blankLiterals(stripComments(a.text, lang));
|
|
1872
|
+
if (sr === sa && hasLiteralPlaceholder(sr)) {
|
|
1873
|
+
const clr = stripComments(r.text, lang);
|
|
1874
|
+
const cla = stripComments(a.text, lang);
|
|
1875
|
+
if (clr.replace(/\s+/g, "") !== cla.replace(/\s+/g, "") && !differsOnlyInMessage(clr, cla, lang)) {
|
|
1876
|
+
pairs.push({ r, a });
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
for (const p of pairs) {
|
|
1883
|
+
allPairs.push(p);
|
|
1884
|
+
const addedNo = p.a.addedLines.length > 0 ? p.a.addedLines[0].newNo : (p.r.removedLines[0] ? p.r.removedLines[0].oldNo : null);
|
|
1885
|
+
violations.push({
|
|
1886
|
+
file,
|
|
1887
|
+
line: addedNo,
|
|
1888
|
+
type: "ASSERTION_EXPECTATION_CHANGED",
|
|
1889
|
+
reason:
|
|
1890
|
+
`Test Tamper Guard: Expected value rewritten in ${file}${addedNo ? `:${addedNo}` : ""} — ` +
|
|
1891
|
+
`"${shorten(collapseWhitespace(p.r.text))}" became "${shorten(collapseWhitespace(p.a.text))}". ` +
|
|
1892
|
+
`A deliberately changed spec looks identical to a test bent to match broken ` +
|
|
1893
|
+
`output, and a diff alone cannot tell the two apart, so this is flagged for ` +
|
|
1894
|
+
`review rather than assumed. If the new expectation is the correct one, ` +
|
|
1895
|
+
`re-run with --allow-test-change expectation — which allows exactly this ` +
|
|
1896
|
+
`check and leaves the skip, vacuous, commented, removal and weakening ` +
|
|
1897
|
+
`checks doing their job.`,
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
// Both sides are accounted for here, so they must not also feed the
|
|
1901
|
+
// count-based checks below — the same line reported twice under two
|
|
1902
|
+
// names tells the operator nothing extra. Consumption is symmetric so
|
|
1903
|
+
// a statement that absorbed two old assertion lines but only one new
|
|
1904
|
+
// one still leaves the surplus to the removal check.
|
|
1905
|
+
const removedMatches = p.r.removedLines.filter(
|
|
1906
|
+
(L) => stats.removed.some((e) => e.line === L.oldNo && e.text === L.text)
|
|
1907
|
+
);
|
|
1908
|
+
const addedMatches = p.a.addedLines.filter(
|
|
1909
|
+
(L) => stats.addedTexts.some((e) => e.line === L.newNo && e.text === L.text)
|
|
1910
|
+
);
|
|
1911
|
+
const take = Math.min(removedMatches.length, addedMatches.length);
|
|
1912
|
+
for (let k = 0; k < take; k++) {
|
|
1913
|
+
const L = removedMatches[k];
|
|
1914
|
+
const idx = stats.removed.findIndex((e) => e.line === L.oldNo && e.text === L.text);
|
|
1915
|
+
if (idx === -1) continue;
|
|
1916
|
+
const entry = stats.removed.splice(idx, 1)[0];
|
|
1917
|
+
const rsIdx = stats.removedSpecific.indexOf(entry);
|
|
1918
|
+
if (rsIdx !== -1) {
|
|
1919
|
+
stats.removedSpecific.splice(rsIdx, 1);
|
|
1920
|
+
stats.addedSpecific--;
|
|
1921
|
+
}
|
|
1922
|
+
stats.added--;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
return allPairs;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* The tamper checks, by the name an operator uses to allow one of them.
|
|
1932
|
+
*
|
|
1933
|
+
* There was one override for all six, and it was a switch marked "off". A
|
|
1934
|
+
* deliberate change of spec rewrites what a test expects, which is
|
|
1935
|
+
* indistinguishable from bending a test to match broken output — so the honest
|
|
1936
|
+
* answer to that finding is sometimes an override. But reaching for it also
|
|
1937
|
+
* silenced injected `.skip()`, `expect(true).toBe(true)`, commented-out
|
|
1938
|
+
* assertions and outright deletions, none of which the operator had looked at.
|
|
1939
|
+
* The check with the highest firing rate therefore set the ceiling for every
|
|
1940
|
+
* other check in the bundle: the more useful this one became, the more often
|
|
1941
|
+
* it would be used to turn the others off.
|
|
1942
|
+
*/
|
|
1943
|
+
export const TAMPER_KINDS = new Map([
|
|
1944
|
+
["TEST_SKIP_INJECTION", "skip"],
|
|
1945
|
+
["VACUOUS_ASSERTION", "vacuous"],
|
|
1946
|
+
["COMMENTED_ASSERTION", "commented"],
|
|
1947
|
+
["ASSERTION_REMOVAL", "removal"],
|
|
1948
|
+
["ASSERTION_WEAKENED", "weakening"],
|
|
1949
|
+
["ASSERTION_EXPECTATION_CHANGED", "expectation"],
|
|
1950
|
+
]);
|
|
1951
|
+
|
|
1952
|
+
/** Every kind name, for CLI validation and help text. */
|
|
1953
|
+
export const TAMPER_KIND_NAMES = Object.freeze([...new Set(TAMPER_KINDS.values())].sort());
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Which tamper checks this run is allowed to stay quiet about.
|
|
1957
|
+
*
|
|
1958
|
+
* @param {object} options
|
|
1959
|
+
* @param {boolean} [options.allowTestModifications] - the blunt form: all of them.
|
|
1960
|
+
* @param {string|string[]} [options.allowTestChanges] - kind names, comma-separated or an array.
|
|
1961
|
+
* @returns {{ all: boolean, kinds: Set<string>, unknown: string[] }}
|
|
1962
|
+
*/
|
|
1963
|
+
export function resolveAllowedTamperKinds(options = {}) {
|
|
1964
|
+
if (options.allowTestModifications === true) {
|
|
1965
|
+
return { all: true, kinds: new Set(TAMPER_KIND_NAMES), unknown: [] };
|
|
1966
|
+
}
|
|
1967
|
+
const raw = options.allowTestChanges;
|
|
1968
|
+
const list = (Array.isArray(raw) ? raw : [raw])
|
|
1969
|
+
.flatMap((v) => String(v == null ? "" : v).split(","))
|
|
1970
|
+
.map((v) => v.trim().toLowerCase())
|
|
1971
|
+
.filter(Boolean);
|
|
1972
|
+
|
|
1973
|
+
if (list.includes("all")) {
|
|
1974
|
+
return { all: true, kinds: new Set(TAMPER_KIND_NAMES), unknown: [] };
|
|
1975
|
+
}
|
|
1976
|
+
const kinds = new Set();
|
|
1977
|
+
const unknown = [];
|
|
1978
|
+
for (const name of list) {
|
|
1979
|
+
if (TAMPER_KIND_NAMES.includes(name)) kinds.add(name);
|
|
1980
|
+
else unknown.push(name);
|
|
1981
|
+
}
|
|
1982
|
+
return { all: false, kinds, unknown };
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1104
1985
|
/**
|
|
1105
1986
|
* Detects test file assertion tampering, weakening, or test skips.
|
|
1106
1987
|
*
|
|
@@ -1111,7 +1992,8 @@ function locateFindingLine(lines, type, file = null) {
|
|
|
1111
1992
|
*/
|
|
1112
1993
|
export function checkTestTampering(diffOrText = "", options = {}) {
|
|
1113
1994
|
if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
|
|
1114
|
-
|
|
1995
|
+
const allowed = resolveAllowedTamperKinds(options);
|
|
1996
|
+
if (allowed.all) return { ok: true, violations: [] };
|
|
1115
1997
|
|
|
1116
1998
|
const violations = [];
|
|
1117
1999
|
const lines = diffOrText.split("\n");
|
|
@@ -1120,18 +2002,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1120
2002
|
let currentOldLineNo = null;
|
|
1121
2003
|
let currentNewLineNo = null;
|
|
1122
2004
|
|
|
1123
|
-
const isTestFile =
|
|
1124
|
-
if (!f) return false;
|
|
1125
|
-
const n = f.replace(/\\/g, "/").toLowerCase();
|
|
1126
|
-
return (
|
|
1127
|
-
n.includes(".test.") ||
|
|
1128
|
-
n.includes(".spec.") ||
|
|
1129
|
-
n.includes("_test.") ||
|
|
1130
|
-
n.includes("/test/") ||
|
|
1131
|
-
n.includes("/tests/") ||
|
|
1132
|
-
n.includes("/__tests__/")
|
|
1133
|
-
);
|
|
1134
|
-
};
|
|
2005
|
+
const isTestFile = isTestPath;
|
|
1135
2006
|
|
|
1136
2007
|
const SKIP_INJECTIONS = [
|
|
1137
2008
|
{ pattern: /\b(?:it|test|describe|context)\.skip\s*\(/i, desc: "Injected test skip (.skip())" },
|
|
@@ -1159,41 +2030,8 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1159
2030
|
const ASSERTION_PATTERN = /(?:\b(?:assert(?:\.[a-zA-Z0-9_$]+)?|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
1160
2031
|
const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
|
|
1161
2032
|
|
|
1162
|
-
// An assertion that states a *specific* expected value. Counting assertions
|
|
1163
|
-
// alone let a test be gutted while looking untouched: swapping
|
|
1164
|
-
// `assert.strictEqual(add(2,3), 5)` for `assert.ok(add(2,3) !== undefined)`
|
|
1165
|
-
// removes one and adds one, so `removed > added` stayed false and the guard
|
|
1166
|
-
// said nothing — while the suite stopped checking the answer.
|
|
1167
|
-
const SPECIFIC_ASSERTION = new RegExp(
|
|
1168
|
-
[
|
|
1169
|
-
"\\bassert(?:\\.strict)?\\.?(?:strictEqual|deepStrictEqual|deepEqual|notStrictEqual|notDeepStrictEqual|equal|notEqual|match|doesNotMatch|throws|rejects|doesNotThrow)\\s*\\(",
|
|
1170
|
-
"\\bexpect\\s*\\([^)]*\\)\\s*\\.(?:toBe|toEqual|toStrictEqual|toMatch|toMatchObject|toContain|toHaveBeenCalledWith|toThrow|toHaveLength|toBeCloseTo)\\s*\\(",
|
|
1171
|
-
"\\bassert\\.(?:equals|deepEquals|include|lengthOf)\\s*\\(",
|
|
1172
|
-
"assert_eq!|assert_ne!",
|
|
1173
|
-
"\\bt\\.(?:Errorf|Fatalf)\\s*\\(",
|
|
1174
|
-
"\\brequire\\.(?:Equal|NotEqual|Len|Contains|Error|NoError)\\s*\\(",
|
|
1175
|
-
].join("|"),
|
|
1176
|
-
"i"
|
|
1177
|
-
);
|
|
1178
|
-
const isSpecificAssertion = (str) => SPECIFIC_ASSERTION.test(str);
|
|
1179
|
-
|
|
1180
|
-
/**
|
|
1181
|
-
* An assertion with every literal value replaced by a placeholder.
|
|
1182
|
-
*
|
|
1183
|
-
* Two lines that normalize to the same string are the same assertion about
|
|
1184
|
-
* the same expression; whatever differs between them is a value.
|
|
1185
|
-
*/
|
|
1186
|
-
const blankLiterals = (str) =>
|
|
1187
|
-
str
|
|
1188
|
-
.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "\u0000S")
|
|
1189
|
-
// The sign belongs to the literal: without it `3` and `-1` normalized to
|
|
1190
|
-
// different shapes and the rewritten expectation was never paired.
|
|
1191
|
-
.replace(/(?<![\w$])-?\d+(?:\.\d+)?\b/g, "\u0000N")
|
|
1192
|
-
.replace(/\b(?:true|false|null|undefined|None|True|False|nil)\b/g, "\u0000B")
|
|
1193
|
-
.replace(/\s+/g, " ")
|
|
1194
|
-
.trim();
|
|
1195
|
-
|
|
1196
2033
|
const fileAssertions = new Map();
|
|
2034
|
+
let pendingHunk = false;
|
|
1197
2035
|
|
|
1198
2036
|
for (let i = 0; i < lines.length; i++) {
|
|
1199
2037
|
const line = lines[i];
|
|
@@ -1209,6 +2047,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1209
2047
|
currentFile = target && target !== "/dev/null" ? target : lastOldFile;
|
|
1210
2048
|
currentOldLineNo = null;
|
|
1211
2049
|
currentNewLineNo = null;
|
|
2050
|
+
pendingHunk = false;
|
|
1212
2051
|
continue;
|
|
1213
2052
|
}
|
|
1214
2053
|
|
|
@@ -1216,20 +2055,28 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1216
2055
|
if (hunkMatch) {
|
|
1217
2056
|
currentOldLineNo = Number(hunkMatch[1]);
|
|
1218
2057
|
currentNewLineNo = Number(hunkMatch[2]);
|
|
2058
|
+
pendingHunk = true;
|
|
1219
2059
|
continue;
|
|
1220
2060
|
}
|
|
1221
2061
|
|
|
1222
2062
|
if (!currentFile || !isTestFile(currentFile)) {
|
|
2063
|
+
pendingHunk = false;
|
|
1223
2064
|
continue;
|
|
1224
2065
|
}
|
|
1225
2066
|
|
|
1226
2067
|
if (!fileAssertions.has(currentFile)) {
|
|
1227
|
-
fileAssertions.set(currentFile, { removed: [], added: 0, addedTexts: [], removedSpecific: [], addedSpecific: 0 });
|
|
2068
|
+
fileAssertions.set(currentFile, { removed: [], added: 0, addedTexts: [], removedSpecific: [], addedSpecific: 0, hunks: [] });
|
|
1228
2069
|
}
|
|
1229
2070
|
const fileStats = fileAssertions.get(currentFile);
|
|
2071
|
+
if (pendingHunk) {
|
|
2072
|
+
fileStats.hunks.push({ lines: [] });
|
|
2073
|
+
pendingHunk = false;
|
|
2074
|
+
}
|
|
2075
|
+
const hunk = fileStats.hunks.length > 0 ? fileStats.hunks[fileStats.hunks.length - 1] : null;
|
|
1230
2076
|
|
|
1231
2077
|
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
1232
2078
|
const deletedText = line.slice(1);
|
|
2079
|
+
if (hunk) hunk.lines.push({ kind: "-", text: deletedText, oldNo: currentOldLineNo, newNo: null });
|
|
1233
2080
|
if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
|
|
1234
2081
|
fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
|
|
1235
2082
|
if (isSpecificAssertion(deletedText)) {
|
|
@@ -1239,6 +2086,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1239
2086
|
if (currentOldLineNo !== null) currentOldLineNo++;
|
|
1240
2087
|
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
1241
2088
|
const addedText = line.slice(1);
|
|
2089
|
+
if (hunk) hunk.lines.push({ kind: "+", text: addedText, oldNo: null, newNo: currentNewLineNo });
|
|
1242
2090
|
let isVacuous = false;
|
|
1243
2091
|
|
|
1244
2092
|
// Check skip injections
|
|
@@ -1287,6 +2135,13 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1287
2135
|
|
|
1288
2136
|
if (currentNewLineNo !== null) currentNewLineNo++;
|
|
1289
2137
|
} else if (!line.startsWith("\\")) {
|
|
2138
|
+
// Context line. It is file text in both images, so the statement
|
|
2139
|
+
// assembler needs it to reassemble assertions that span a changed
|
|
2140
|
+
// line; `diff --git`/`index` lines that sneak in here are not
|
|
2141
|
+
// diff body and are not collected.
|
|
2142
|
+
if (hunk && (line.startsWith(" ") || line === "")) {
|
|
2143
|
+
hunk.lines.push({ kind: " ", text: line.slice(1), oldNo: currentOldLineNo, newNo: currentNewLineNo });
|
|
2144
|
+
}
|
|
1290
2145
|
if (currentOldLineNo !== null) currentOldLineNo++;
|
|
1291
2146
|
if (currentNewLineNo !== null) currentNewLineNo++;
|
|
1292
2147
|
}
|
|
@@ -1297,53 +2152,14 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1297
2152
|
//
|
|
1298
2153
|
// Counting assertions cannot see this one: `assert.equal(add(1,2), 3)`
|
|
1299
2154
|
// becoming `assert.equal(add(1,2), -1)` takes one specific assertion out
|
|
1300
|
-
// and puts one specific assertion back, so every total stayed level and
|
|
1301
|
-
// guard said nothing — while the suite went from checking that
|
|
1302
|
-
// works to certifying that it is broken. It is the single
|
|
1303
|
-
// make a red suite green, and the one this tool exists
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
|
|
1308
|
-
// spec — nothing can, from the diff alone — so this reports rather than
|
|
1309
|
-
// decides, and `--allow-test-modifications` is the answer when the new
|
|
1310
|
-
// expectation is the correct one.
|
|
1311
|
-
// Shapes are computed once per added line, not once per (removed, added)
|
|
1312
|
-
// pair: a large test refactor is O(n²) comparisons and the normalisation is
|
|
1313
|
-
// the expensive half of each one.
|
|
1314
|
-
const unpairedAdded = stats.addedTexts
|
|
1315
|
-
.filter((a) => isSpecificAssertion(a.text))
|
|
1316
|
-
.map((a) => ({ ...a, shape: blankLiterals(a.text) }));
|
|
1317
|
-
for (const removedItem of stats.removed.slice()) {
|
|
1318
|
-
if (!isSpecificAssertion(removedItem.text)) continue;
|
|
1319
|
-
const shape = blankLiterals(removedItem.text);
|
|
1320
|
-
const idx = unpairedAdded.findIndex(
|
|
1321
|
-
(a) => a.shape === shape && a.text.trim() !== removedItem.text.trim()
|
|
1322
|
-
);
|
|
1323
|
-
if (idx === -1) continue;
|
|
1324
|
-
const addedItem = unpairedAdded.splice(idx, 1)[0];
|
|
1325
|
-
|
|
1326
|
-
violations.push({
|
|
1327
|
-
file,
|
|
1328
|
-
line: addedItem.line ?? removedItem.line,
|
|
1329
|
-
type: "ASSERTION_EXPECTATION_CHANGED",
|
|
1330
|
-
reason:
|
|
1331
|
-
`Test Tamper Guard: Expected value rewritten in ${file}${addedItem.line ? `:${addedItem.line}` : ""} — ` +
|
|
1332
|
-
`"${removedItem.text.trim()}" became "${addedItem.text.trim()}". ` +
|
|
1333
|
-
`If the new value is the correct one, re-run with --allow-test-modifications.`,
|
|
1334
|
-
});
|
|
1335
|
-
|
|
1336
|
-
// Both sides are accounted for here, so they must not also feed the
|
|
1337
|
-
// count-based checks below — the same line reported twice under two
|
|
1338
|
-
// names tells the operator nothing extra.
|
|
1339
|
-
stats.removed.splice(stats.removed.indexOf(removedItem), 1);
|
|
1340
|
-
const rsIdx = stats.removedSpecific.indexOf(removedItem);
|
|
1341
|
-
if (rsIdx !== -1) {
|
|
1342
|
-
stats.removedSpecific.splice(rsIdx, 1);
|
|
1343
|
-
stats.addedSpecific--;
|
|
1344
|
-
}
|
|
1345
|
-
stats.added--;
|
|
1346
|
-
}
|
|
2155
|
+
// and puts one specific assertion back, so every total stayed level and
|
|
2156
|
+
// the guard said nothing — while the suite went from checking that
|
|
2157
|
+
// addition works to certifying that it is broken. It is the single
|
|
2158
|
+
// cheapest way to make a red suite green, and the one this tool exists
|
|
2159
|
+
// to refuse. The line-based version of this pairing could only see the
|
|
2160
|
+
// single-line spelling of the edit; the reassembled-statement version
|
|
2161
|
+
// above sees every spelling, and explains why on the way.
|
|
2162
|
+
detectExpectationRewrites(file, stats.hunks, stats, violations);
|
|
1347
2163
|
|
|
1348
2164
|
if (stats.removed.length > stats.added) {
|
|
1349
2165
|
const unreplaced = stats.removed.slice(stats.added);
|
|
@@ -1383,9 +2199,17 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1383
2199
|
}
|
|
1384
2200
|
}
|
|
1385
2201
|
|
|
2202
|
+
// A kind the operator has already looked at and accepted is dropped here
|
|
2203
|
+
// rather than never being computed, so the reasoning above stays one code
|
|
2204
|
+
// path regardless of what any given run allows.
|
|
2205
|
+
const reported =
|
|
2206
|
+
allowed.kinds.size === 0
|
|
2207
|
+
? violations
|
|
2208
|
+
: violations.filter((v) => !allowed.kinds.has(TAMPER_KINDS.get(v.type)));
|
|
2209
|
+
|
|
1386
2210
|
return {
|
|
1387
|
-
ok:
|
|
1388
|
-
violations,
|
|
2211
|
+
ok: reported.length === 0,
|
|
2212
|
+
violations: reported,
|
|
1389
2213
|
};
|
|
1390
2214
|
}
|
|
1391
2215
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One answer to "is this path a test file?".
|
|
3
|
+
*
|
|
4
|
+
* There were five, in five modules, and they disagreed with each other:
|
|
5
|
+
*
|
|
6
|
+
* - `security.mjs` matched the substring `/test/`, so a file in the
|
|
7
|
+
* repository's own root-level `tests/` directory had no leading slash and
|
|
8
|
+
* was not a test file. The entire tamper guard — skip injection, vacuous
|
|
9
|
+
* assertions, commented-out assertions, removal, weakening, expectation
|
|
10
|
+
* rewrites — was therefore switched off for the standard pytest layout
|
|
11
|
+
* (`tests/test_calc.py`), the standard Rust integration layout
|
|
12
|
+
* (`tests/integration.rs`) and every RSpec suite (`spec/`).
|
|
13
|
+
* - `mutation.mjs` had the same substring bug, in the opposite direction:
|
|
14
|
+
* those same files were not excluded, so the harness mutated operators
|
|
15
|
+
* inside the tests themselves and scored the result.
|
|
16
|
+
* - `engine.mjs` never looked for `_test.`, so `strictTestLock` did not
|
|
17
|
+
* consider a Go test file to be a test file.
|
|
18
|
+
* - `coverage.mjs` and `evidence.mjs` each had a third and fourth spelling.
|
|
19
|
+
*
|
|
20
|
+
* A predicate this load-bearing cannot have five definitions. This is the one
|
|
21
|
+
* they all use.
|
|
22
|
+
*
|
|
23
|
+
* The classification errs toward "yes". Four of the five callers get stricter
|
|
24
|
+
* when it does — the tamper guard applies, the integrity hash covers more, the
|
|
25
|
+
* lock triggers — and the fifth (mutation) has nothing to lose, because
|
|
26
|
+
* mutating an operator inside a test proves nothing about the code under test.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Directory names that mean "everything below here is a test".
|
|
31
|
+
*
|
|
32
|
+
* Matched as whole path segments, not substrings: `latest/` is not `test/`,
|
|
33
|
+
* and `myspec/` is not `spec/`.
|
|
34
|
+
*/
|
|
35
|
+
const TEST_DIR_SEGMENTS = new Set(["test", "tests", "spec", "specs", "__tests__", "__test__"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True when `file` names a test file under any convention the kit supports.
|
|
39
|
+
*
|
|
40
|
+
* Covers, by directory: `test/`, `tests/`, `spec/`, `specs/`, `__tests__/` at
|
|
41
|
+
* any depth including the repository root. By filename: `foo.test.js`,
|
|
42
|
+
* `foo.spec.ts`, `foo_test.go`, `foo_spec.rb`, `test_calc.py` (pytest),
|
|
43
|
+
* `spec_helper.rb`, and Foundry's `Foo.t.sol` / `FooTest.sol`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} file - Repo-relative path, either separator.
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isTestPath(file) {
|
|
49
|
+
if (!file || typeof file !== "string") return false;
|
|
50
|
+
const segments = file.replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean);
|
|
51
|
+
if (segments.length === 0) return false;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
54
|
+
if (TEST_DIR_SEGMENTS.has(segments[i])) return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const base = segments[segments.length - 1];
|
|
58
|
+
return (
|
|
59
|
+
base.includes(".test.") ||
|
|
60
|
+
base.includes(".spec.") ||
|
|
61
|
+
base.includes("_test.") ||
|
|
62
|
+
base.includes("_spec.") ||
|
|
63
|
+
base.startsWith("test_") ||
|
|
64
|
+
base.startsWith("spec_") ||
|
|
65
|
+
base.endsWith("test.sol") ||
|
|
66
|
+
base.endsWith(".t.sol")
|
|
67
|
+
);
|
|
68
|
+
}
|