jules-orchestrator-kit 0.67.0 → 0.69.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/AGENTS.md +135 -0
- package/CHANGELOG.md +852 -0
- package/README.md +7 -3
- package/ROADMAP_V1.md +210 -0
- package/bin/agentctl.mjs +9 -0
- package/package.json +5 -2
- package/scripts/doc-sync-check.mjs +9 -0
- package/src/config.mjs +8 -2
- package/src/engine.mjs +44 -3
- package/src/git.mjs +39 -5
- package/src/guard-policy.mjs +136 -0
- package/src/ops/test-collection.mjs +21 -0
- package/src/security.mjs +196 -43
- package/src/stack-detector.mjs +56 -1
- package/src/wizard-init.mjs +60 -4
package/src/guard-policy.mjs
CHANGED
|
@@ -68,6 +68,62 @@ export const TEST_PATH_CASES = [
|
|
|
68
68
|
* the guard the layout the implementation was written from.
|
|
69
69
|
*/
|
|
70
70
|
export const TAMPER_CANARIES = [
|
|
71
|
+
// Argument order is not a detail. JUnit and PHPUnit document
|
|
72
|
+
// `assertEquals(expected, actual)`, and Python's containment helpers take
|
|
73
|
+
// the expected member first — all of which the guard used to read as "a
|
|
74
|
+
// human message was reworded" and dismiss. Each of these was a silent PASS.
|
|
75
|
+
{
|
|
76
|
+
id: "expectation/junit-expected-first",
|
|
77
|
+
file: "src/test/java/CalcTest.java",
|
|
78
|
+
removed: [' assertEquals("Hello World", out);'],
|
|
79
|
+
added: [' assertEquals("Hello Tampered", out);'],
|
|
80
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "expectation/phpunit-expected-first",
|
|
84
|
+
file: "tests/CalcTest.php",
|
|
85
|
+
removed: [' $this->assertSame("Hello World", $out);'],
|
|
86
|
+
added: [' $this->assertSame("Hello Tampered", $out);'],
|
|
87
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "expectation/unittest-assert-in",
|
|
91
|
+
file: "tests/test_cli.py",
|
|
92
|
+
removed: [' self.assertIn("Hello World", result.output)'],
|
|
93
|
+
added: [' self.assertIn("Hello Tampered", result.output)'],
|
|
94
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "expectation/unittest-assert-not-in",
|
|
98
|
+
file: "tests/test_cli.py",
|
|
99
|
+
removed: [' self.assertNotIn("error", result.output)'],
|
|
100
|
+
added: [' self.assertNotIn("zzz", result.output)'],
|
|
101
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
102
|
+
},
|
|
103
|
+
// A regex literal is an expected value. It never blanked, so the rewritten
|
|
104
|
+
// pattern never met its original in a shape bucket and the swap was
|
|
105
|
+
// reported as neither a change nor a loss.
|
|
106
|
+
{
|
|
107
|
+
id: "expectation/jest-to-match-regex",
|
|
108
|
+
file: "test/cli.test.js",
|
|
109
|
+
removed: [" expect(out).toMatch(/Hello World/);"],
|
|
110
|
+
added: [" expect(out).toMatch(/Hello Tampered/);"],
|
|
111
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "expectation/jest-to-throw-regex",
|
|
115
|
+
file: "test/cli.test.js",
|
|
116
|
+
removed: [" expect(fn).toThrow(/permission denied/);"],
|
|
117
|
+
added: [" expect(fn).toThrow(/x/);"],
|
|
118
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "expectation/rspec-match-regex",
|
|
122
|
+
file: "spec/cli_spec.rb",
|
|
123
|
+
removed: [" expect(out).to match(/Hello World/)"],
|
|
124
|
+
added: [" expect(out).to match(/Hello Tampered/)"],
|
|
125
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
126
|
+
},
|
|
71
127
|
{
|
|
72
128
|
id: "skip-injection/node",
|
|
73
129
|
file: "test/calc.test.js",
|
|
@@ -235,6 +291,42 @@ export const TAMPER_CANARIES = [
|
|
|
235
291
|
added: [" $this->markTestSkipped(\"later\");"],
|
|
236
292
|
expect: "TEST_SKIP_INJECTION",
|
|
237
293
|
},
|
|
294
|
+
// Dialects a second cold-start trial found silent. chai's `.to.equal(` is a
|
|
295
|
+
// dot chain where RSpec's is a space, so the RSpec branch never reached it;
|
|
296
|
+
// node-tap's assertions hang off whatever the sub-test callback named its
|
|
297
|
+
// argument, which is `ct` as often as `t`.
|
|
298
|
+
{
|
|
299
|
+
id: "weakening/chai",
|
|
300
|
+
file: "packages/core/test/signal.test.tsx",
|
|
301
|
+
context: "// signals",
|
|
302
|
+
removed: [" expect(s.value).to.equal(v);"],
|
|
303
|
+
added: [" expect(s.value).toBeDefined();"],
|
|
304
|
+
expect: "ASSERTION_WEAKENED",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: "expectation/chai",
|
|
308
|
+
file: "test/signal.test.js",
|
|
309
|
+
context: "// signals",
|
|
310
|
+
removed: [" expect(s.value).to.equal(3);"],
|
|
311
|
+
added: [" expect(s.value).to.equal(-1);"],
|
|
312
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "expectation/node-tap",
|
|
316
|
+
file: "tests/test-config.js",
|
|
317
|
+
context: "// config",
|
|
318
|
+
removed: [" ct.equal(process.env.BASIC, 'basic')"],
|
|
319
|
+
added: [" ct.equal(process.env.BASIC, 'BROKEN_OVERRIDE')"],
|
|
320
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "skip-injection/node-tap-options",
|
|
324
|
+
file: "tests/test-populate.js",
|
|
325
|
+
context: "// populate",
|
|
326
|
+
removed: ["t.test('does not write over keys', ct => {"],
|
|
327
|
+
added: ["t.test('does not write over keys', { skip: true }, ct => {"],
|
|
328
|
+
expect: "TEST_SKIP_INJECTION",
|
|
329
|
+
},
|
|
238
330
|
// Skipping from inside the test body, which is how every one of these
|
|
239
331
|
// ecosystems actually does it. The decorator and annotation forms were
|
|
240
332
|
// covered; the standard library's own method was not, so a test could be
|
|
@@ -362,6 +454,41 @@ export const SCOPE_CANARIES = [
|
|
|
362
454
|
* from never showed it.
|
|
363
455
|
*/
|
|
364
456
|
export const INNOCENT_EDITS = [
|
|
457
|
+
// Renaming a test is one of the most ordinary edits there is. On the
|
|
458
|
+
// one-line form the name blanked to the same shape as its replacement, the
|
|
459
|
+
// two paired, and the pair was reported as a rewritten expectation.
|
|
460
|
+
{
|
|
461
|
+
id: "rename-test-oneline/node",
|
|
462
|
+
file: "test/calc.test.js",
|
|
463
|
+
context: "// arithmetic",
|
|
464
|
+
removed: ['test("adds", () => { assert.strictEqual(add(2, 3), 5); });'],
|
|
465
|
+
added: ['test("adds positives", () => { assert.strictEqual(add(2, 3), 5); });'],
|
|
466
|
+
why: "the test name is prose about the test, not a value it asserts",
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
id: "rename-test-oneline/jest",
|
|
470
|
+
file: "test/calc.spec.js",
|
|
471
|
+
context: "// arithmetic",
|
|
472
|
+
removed: ['it("adds", () => { expect(add(2, 3)).toBe(5); });'],
|
|
473
|
+
added: ['it("adds two positives", () => { expect(add(2, 3)).toBe(5); });'],
|
|
474
|
+
why: "same rename, the dialect a new user is most likely to be in",
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
id: "rename-test-oneline/tap",
|
|
478
|
+
file: "test/calc.test.js",
|
|
479
|
+
context: "// arithmetic",
|
|
480
|
+
removed: ['t.test("adds", (ct) => { ct.equal(add(2, 3), 5); ct.end(); });'],
|
|
481
|
+
added: ['t.test("adds positives", (ct) => { ct.equal(add(2, 3), 5); ct.end(); });'],
|
|
482
|
+
why: "the receiver is a sub-test callback argument, not an assertion subject",
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: "reword-junit-message-first",
|
|
486
|
+
file: "src/test/java/CalcTest.java",
|
|
487
|
+
context: "// arithmetic",
|
|
488
|
+
removed: [' assertEquals("why", expected, out);'],
|
|
489
|
+
added: [' assertEquals("why this matters", expected, out);'],
|
|
490
|
+
why: "JUnit 4 puts the message first — rewording it changes no expectation",
|
|
491
|
+
},
|
|
365
492
|
{
|
|
366
493
|
id: "add-assertion-beside-comment/node",
|
|
367
494
|
file: "test/calc.test.js",
|
|
@@ -443,6 +570,15 @@ export const INNOCENT_EDITS = [
|
|
|
443
570
|
added: [" 3, // the correct answer", " );"],
|
|
444
571
|
why: "line comments were never stripped — `copyCode(i)` left `pending` at the comment and the copy after the loop put it back",
|
|
445
572
|
},
|
|
573
|
+
{
|
|
574
|
+
id: "reformat-assertion-multiline/python",
|
|
575
|
+
file: "tests/test_encoding.py",
|
|
576
|
+
context: "# encoding",
|
|
577
|
+
lead: ["def test_int_bytes(value, expect):", " enc = int_to_bytes(value)"],
|
|
578
|
+
removed: [" assert enc == expect"],
|
|
579
|
+
added: [" assert (", " enc == expect", " )"],
|
|
580
|
+
why: "Black and Ruff do this; `assert (` alone on a line names no value, so a line-level count read a reformat as a weakening",
|
|
581
|
+
},
|
|
446
582
|
{
|
|
447
583
|
id: "rename-helper/node",
|
|
448
584
|
file: "test/calc.test.js",
|
|
@@ -50,6 +50,27 @@ const COUNT_PATTERNS = [
|
|
|
50
50
|
{ name: "dotnet", re: /\bTotal(?:\s+tests)?:\s*(\d+)/i },
|
|
51
51
|
// swift test / XCTest — "Executed 12 tests"
|
|
52
52
|
{ name: "xctest", re: /\bExecuted\s+(\d+)\s+tests?/i },
|
|
53
|
+
// Every runner below states a count the gate used to read as silence, and
|
|
54
|
+
// silence is what makes `pnpm -r test` on a workspace with no package-level
|
|
55
|
+
// test script indistinguishable from a suite that ran. The floor stays
|
|
56
|
+
// one-sided — an unrecognised runner still passes — so widening the list
|
|
57
|
+
// only ever converts an "I could not tell" into an answer.
|
|
58
|
+
// Python unittest — "Ran 12 tests in 0.003s"
|
|
59
|
+
{ name: "unittest", re: /^Ran\s+(\d+)\s+tests?\s+in\b/m },
|
|
60
|
+
// GoogleTest — "[==========] 12 tests from 3 test suites ran."
|
|
61
|
+
{ name: "gtest", re: /\[=+\]\s+(\d+)\s+tests?\s+from\b/ },
|
|
62
|
+
// Catch2 — "test cases: 12 | 12 passed"
|
|
63
|
+
{ name: "catch2", re: /\btest cases:\s*(\d+)/i },
|
|
64
|
+
// deno test — "ok | 12 passed | 0 failed"
|
|
65
|
+
{ name: "deno", re: /\|\s*(\d+)\s+passed\s*\|/ },
|
|
66
|
+
// bun test — "12 pass"
|
|
67
|
+
{ name: "bun", re: /^\s*(\d+)\s+pass\s*$/m },
|
|
68
|
+
// ava — "12 tests passed"
|
|
69
|
+
{ name: "ava", re: /^\s*(\d+)\s+tests?\s+passed\s*$/m },
|
|
70
|
+
// A bare TAP plan, which is how tape, bats and the TAP producers that
|
|
71
|
+
// print no summary state their count. Last of the count patterns: node:test
|
|
72
|
+
// emits TAP too, and its own line above is the more specific reading.
|
|
73
|
+
{ name: "tap", re: /^1\.\.(\d+)\s*$/m },
|
|
53
74
|
];
|
|
54
75
|
|
|
55
76
|
/** Per-test lines, which `go test` only prints under -v. */
|
package/src/security.mjs
CHANGED
|
@@ -1191,6 +1191,15 @@ const SPECIFIC_ASSERTION = new RegExp(
|
|
|
1191
1191
|
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.(?:to|not_to|to_not)\\s+(?:eq|eql|equal|be|be_within|match|include|contain_exactly|match_array|have_attributes|raise_error|start_with|end_with)\\b",
|
|
1192
1192
|
// XCTest
|
|
1193
1193
|
"\\bXCTAssert(?:Equal|NotEqual|EqualWithAccuracy|Identical|NotIdentical|GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual|ThrowsError|NoThrow)\\s*\\(",
|
|
1194
|
+
// chai — a dot chain, where RSpec's is a space. `expect(x).to.equal(3)`
|
|
1195
|
+
// never reached the RSpec branch, so swapping it for `.toBeDefined()`
|
|
1196
|
+
// lost no *specific* assertion and the weakening check stayed quiet.
|
|
1197
|
+
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.to(?:\\.[a-z]+)*\\.(?:equal|equals|eql|eqls|closeTo|match|include|contain|members|throw|string|lengthOf|above|below|least|most|within)\\s*\\(",
|
|
1198
|
+
// node-tap and its relatives, where the assertion hangs off whatever the
|
|
1199
|
+
// sub-test callback named its argument — `ct` as often as `t`. Bounded to
|
|
1200
|
+
// a short receiver so `results.match(...)` on an ordinary object is not
|
|
1201
|
+
// mistaken for an assertion; a heuristic, and stated as one.
|
|
1202
|
+
"\\b[a-z_$][a-z0-9_$]{0,2}\\.(?:equal|equals|same|strictSame|deepEqual|notEqual|notSame|match|hasStrict|type|throws|rejects)\\s*\\(",
|
|
1194
1203
|
].join("|"),
|
|
1195
1204
|
"i"
|
|
1196
1205
|
);
|
|
@@ -1210,6 +1219,21 @@ const isSpecificAssertion = (str) => SPECIFIC_ASSERTION.test(str);
|
|
|
1210
1219
|
const blankLiterals = (str) =>
|
|
1211
1220
|
str
|
|
1212
1221
|
.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "\u0000S")
|
|
1222
|
+
// A regex literal is an expected value like any other. Without this,
|
|
1223
|
+
// `toMatch(/Hello World/)` and `toMatch(/Hello Tampered/)` normalized to
|
|
1224
|
+
// two different shapes, never met in a bucket, and the rewrite was
|
|
1225
|
+
// reported as neither a change nor a loss — one specific assertion out,
|
|
1226
|
+
// one in, and silence. Runs after the string pass so a `/` inside a
|
|
1227
|
+
// string is already gone, and before the number pass so a pattern
|
|
1228
|
+
// containing digits collapses whole.
|
|
1229
|
+
//
|
|
1230
|
+
// The lookbehind is what separates a regex from a division: an operand
|
|
1231
|
+
// never precedes `/` here, only an opening paren, a comma, or an
|
|
1232
|
+
// operator, which is where a test's expected pattern actually sits.
|
|
1233
|
+
.replace(
|
|
1234
|
+
/(?<=[(,=:[!&|?{;]\s{0,8})\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n])+\/[dgimsuvy]*/g,
|
|
1235
|
+
"\u0000R"
|
|
1236
|
+
)
|
|
1213
1237
|
// The sign belongs to the literal: without it `3` and `-1` normalized to
|
|
1214
1238
|
// different shapes and the rewritten expectation was never paired.
|
|
1215
1239
|
.replace(
|
|
@@ -1793,8 +1817,22 @@ function isPureStringLiteral(arg) {
|
|
|
1793
1817
|
*/
|
|
1794
1818
|
function messageArgIndices(args) {
|
|
1795
1819
|
const idx = new Set();
|
|
1796
|
-
|
|
1797
|
-
if (
|
|
1820
|
+
const lastIsMessage = args.length >= 3 && isPureStringLiteral(args[args.length - 1]);
|
|
1821
|
+
if (lastIsMessage) idx.add(args.length - 1);
|
|
1822
|
+
|
|
1823
|
+
// JUnit 4 is the one common dialect that puts the message *first*:
|
|
1824
|
+
// `assertEquals("why this matters", expected, actual)`. It is also
|
|
1825
|
+
// distinguishable, because its trailing argument is the actual value rather
|
|
1826
|
+
// than prose — so a call that already carries a trailing message is not
|
|
1827
|
+
// that shape, whatever its first argument looks like.
|
|
1828
|
+
//
|
|
1829
|
+
// Reading argument 0 as prose whenever it happened to be a string is what
|
|
1830
|
+
// made a whole family of assertions invisible: `assertEquals(expected,
|
|
1831
|
+
// actual)` — JUnit's and PHPUnit's own two-argument order — along with
|
|
1832
|
+
// Python's `assertIn(member, container)` and `assertNotIn`. A rewritten
|
|
1833
|
+
// expectation in any of them was dismissed as a reworded message, and the
|
|
1834
|
+
// guard reported PASS on a check it had not performed.
|
|
1835
|
+
if (!lastIsMessage && args.length >= 3 && isPureStringLiteral(args[0])) idx.add(0);
|
|
1798
1836
|
return idx;
|
|
1799
1837
|
}
|
|
1800
1838
|
|
|
@@ -1837,6 +1875,63 @@ function splitTrailingMessage(clean) {
|
|
|
1837
1875
|
return { head: clean.slice(0, lastComma), msg: tail };
|
|
1838
1876
|
}
|
|
1839
1877
|
|
|
1878
|
+
// A test declaration whose first argument is the test's name. The name is
|
|
1879
|
+
// prose about the test, not a value the test asserts — `test("adds", ...)`
|
|
1880
|
+
// renamed to `test("adds positives", ...)` is the rename the diff says it is.
|
|
1881
|
+
const TEST_DECL_CALL =
|
|
1882
|
+
/\b(?:it|test|describe|context|suite|specify|bench|scenario)\s*\(|\b[a-z_$][\w$]{0,2}\.(?:test|Run|describe)\s*\(/;
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* Replace the name argument of a test declaration with a placeholder.
|
|
1886
|
+
*
|
|
1887
|
+
* @param {string} clean - comment-stripped statement text
|
|
1888
|
+
* @returns {string|null} the text with the name blanked, or null when the
|
|
1889
|
+
* statement is not a test declaration with a literal name.
|
|
1890
|
+
*/
|
|
1891
|
+
function blankTestName(clean) {
|
|
1892
|
+
const m = TEST_DECL_CALL.exec(clean);
|
|
1893
|
+
if (!m) return null;
|
|
1894
|
+
|
|
1895
|
+
let i = m.index + m[0].length;
|
|
1896
|
+
while (i < clean.length && /\s/.test(clean[i])) i++;
|
|
1897
|
+
const quote = clean[i];
|
|
1898
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") return null;
|
|
1899
|
+
|
|
1900
|
+
let j = i + 1;
|
|
1901
|
+
while (j < clean.length) {
|
|
1902
|
+
if (clean[j] === "\\") { j += 2; continue; }
|
|
1903
|
+
if (clean[j] === quote) break;
|
|
1904
|
+
j += 1;
|
|
1905
|
+
}
|
|
1906
|
+
if (j >= clean.length) return null;
|
|
1907
|
+
|
|
1908
|
+
return clean.slice(0, i) + "\u0000T" + clean.slice(j + 1);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* True when the only thing that changed was the test's name.
|
|
1913
|
+
*
|
|
1914
|
+
* A one-line `test("adds", () => { assert.strictEqual(add(2, 3), 5); });`
|
|
1915
|
+
* blanks to the same shape as its renamed copy, so the two pair — and the
|
|
1916
|
+
* pair was then reported as a rewritten expectation, quoting the whole line
|
|
1917
|
+
* back at an author who had renamed a test and nothing else. Renaming a test
|
|
1918
|
+
* is one of the most ordinary edits there is, and a gate that calls it
|
|
1919
|
+
* tampering is a gate that gets switched off.
|
|
1920
|
+
*
|
|
1921
|
+
* The multi-line form was never affected: NON_JOINING_CALL already keeps a
|
|
1922
|
+
* test name from joining to the assertion below it. This is the same rule for
|
|
1923
|
+
* the statements that fit on one line.
|
|
1924
|
+
*
|
|
1925
|
+
* A rename that also moves the expectation still differs after the name is
|
|
1926
|
+
* blanked, so it is still reported.
|
|
1927
|
+
*/
|
|
1928
|
+
function differsOnlyInTestName(cleanRemoved, cleanAdded) {
|
|
1929
|
+
const a = blankTestName(cleanRemoved);
|
|
1930
|
+
const b = blankTestName(cleanAdded);
|
|
1931
|
+
if (a === null || b === null) return false;
|
|
1932
|
+
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1840
1935
|
function differsOnlyInMessage(cleanRemoved, cleanAdded, lang) {
|
|
1841
1936
|
const ta = splitTrailingMessage(cleanRemoved);
|
|
1842
1937
|
const tb = splitTrailingMessage(cleanAdded);
|
|
@@ -1865,6 +1960,17 @@ function differsOnlyInMessage(cleanRemoved, cleanAdded, lang) {
|
|
|
1865
1960
|
return sawDifference;
|
|
1866
1961
|
}
|
|
1867
1962
|
|
|
1963
|
+
/**
|
|
1964
|
+
* True when a paired difference is something other than a rewritten
|
|
1965
|
+
* expectation — a reworded message, or a renamed test.
|
|
1966
|
+
*/
|
|
1967
|
+
function isNonExpectationDifference(cleanRemoved, cleanAdded, lang) {
|
|
1968
|
+
return (
|
|
1969
|
+
differsOnlyInMessage(cleanRemoved, cleanAdded, lang) ||
|
|
1970
|
+
differsOnlyInTestName(cleanRemoved, cleanAdded)
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1868
1974
|
/**
|
|
1869
1975
|
* Pair rewritten expectations across the removed and added images of every
|
|
1870
1976
|
* hunk of one file, and report each pair.
|
|
@@ -1979,7 +2085,7 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
|
|
|
1979
2085
|
pairedOld.add(r);
|
|
1980
2086
|
pairedNew.add(a);
|
|
1981
2087
|
if (r.canon === a.canon) continue;
|
|
1982
|
-
if (
|
|
2088
|
+
if (isNonExpectationDifference(r.clean, a.clean, lang)) continue;
|
|
1983
2089
|
pairs.push({ r: r.s, a: a.s });
|
|
1984
2090
|
}
|
|
1985
2091
|
}
|
|
@@ -2009,7 +2115,7 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
|
|
|
2009
2115
|
// different assertions that merely resemble each other.
|
|
2010
2116
|
if (!same(0)) continue;
|
|
2011
2117
|
if (ra.every((_, i) => same(i))) continue;
|
|
2012
|
-
if (
|
|
2118
|
+
if (isNonExpectationDifference(r.clean, a.clean, lang)) continue;
|
|
2013
2119
|
pairs.push({ r: r.s, a: a.s });
|
|
2014
2120
|
pairedOld.add(r);
|
|
2015
2121
|
pairedNew.add(a);
|
|
@@ -2030,7 +2136,7 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
|
|
|
2030
2136
|
if (sr === sa && hasLiteralPlaceholder(sr)) {
|
|
2031
2137
|
const clr = stripComments(r.text, lang);
|
|
2032
2138
|
const cla = stripComments(a.text, lang);
|
|
2033
|
-
if (clr.replace(/\s+/g, "") !== cla.replace(/\s+/g, "") && !
|
|
2139
|
+
if (clr.replace(/\s+/g, "") !== cla.replace(/\s+/g, "") && !isNonExpectationDifference(clr, cla, lang)) {
|
|
2034
2140
|
pairs.push({ r, a });
|
|
2035
2141
|
}
|
|
2036
2142
|
}
|
|
@@ -2194,6 +2300,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2194
2300
|
{ pattern: /\bXCTSkip(?:If|Unless|IfNot)?\s*\(/, desc: "Injected XCTest skip (XCTSkip())" },
|
|
2195
2301
|
{ pattern: /\b(?:xit|xdescribe|xcontext|xspecify)\b\s*["\x27]/i, desc: "Injected RSpec disabled example (xit)" },
|
|
2196
2302
|
{ pattern: /,\s*skip:\s*(?:true|["\x27])/i, desc: "Injected RSpec skip metadata (skip:)" },
|
|
2303
|
+
// node-tap, node:test and ava pass it as a property of an options object,
|
|
2304
|
+
// so the comma sits before the brace and not before the key.
|
|
2305
|
+
{ pattern: /\{[^}]*\bskip\s*:\s*true/i, desc: "Injected skip option ({ skip: true })" },
|
|
2306
|
+
{ pattern: /\{[^}]*\btodo\s*:\s*true/i, desc: "Injected todo option ({ todo: true })" },
|
|
2197
2307
|
{ pattern: /^\s*(?:skip|pending)\s*(?:["\x27(]|$)/i, desc: "Injected Minitest/RSpec skip statement" },
|
|
2198
2308
|
// Skipping from inside the body, which is how these ecosystems actually
|
|
2199
2309
|
// do it. Only the decorator and annotation forms were covered, so a test
|
|
@@ -2237,7 +2347,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2237
2347
|
// indistinguishable from an untouched one. The lookahead keeps prose and
|
|
2238
2348
|
// identifiers — `assertion`, `asserts`, `asserted` — out of the count.
|
|
2239
2349
|
const ASSERTION_PATTERN =
|
|
2240
|
-
/(?:\b(?:assert(?!ion|ing|ed\b|s\b)[a-zA-Z0-9_$]*(?:\.[a-zA-Z0-9_$]+)?|refute[a-zA-Z0-9_$]*|XCTAssert[a-zA-Z0-9_$]*|XCTFail|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
2350
|
+
/(?:\b(?:assert(?!ion|ing|ed\b|s\b)[a-zA-Z0-9_$]*(?:\.[a-zA-Z0-9_$]+)?|refute[a-zA-Z0-9_$]*|XCTAssert[a-zA-Z0-9_$]*|XCTFail|expect|[a-z_$][a-z0-9_$]{0,2}\.(?:equal|equals|same|strictSame|deepEqual|notEqual|notSame|match|hasStrict|type|throws|rejects|ok|notOk)|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
2241
2351
|
// The loose net. Not a verdict and never a block — its only job is to
|
|
2242
2352
|
// notice that a line was plainly an assertion in *some* dialect that
|
|
2243
2353
|
// ASSERTION_PATTERN did not recognise. Without it, adding the seventh
|
|
@@ -2380,6 +2490,59 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2380
2490
|
}
|
|
2381
2491
|
}
|
|
2382
2492
|
|
|
2493
|
+
// An assertion is a statement, not a line.
|
|
2494
|
+
//
|
|
2495
|
+
// Counting `+`/`-` lines missed the commonest shape in every language with
|
|
2496
|
+
// multi-line calls: `self.assertEqual(` sits on an unchanged context line
|
|
2497
|
+
// and only its argument lines are edited. Nothing among the changed lines
|
|
2498
|
+
// matched an assertion pattern, nothing looked assertion-shaped either, so
|
|
2499
|
+
// the guard reported `assertionsSeen: 0` and — because no line looked
|
|
2500
|
+
// suspicious — a clean PASS. A five-element expected list rewritten to one
|
|
2501
|
+
// element to match broken output sailed through five green phases.
|
|
2502
|
+
//
|
|
2503
|
+
// The statement machinery that already exists for pairing knows better:
|
|
2504
|
+
// it assembles context lines together with changed ones. Ask it.
|
|
2505
|
+
for (const [file, stats] of fileAssertions.entries()) {
|
|
2506
|
+
const lang = langForTestFile(file);
|
|
2507
|
+
let touched = 0;
|
|
2508
|
+
let stmtSpecificRemoved = 0;
|
|
2509
|
+
let stmtSpecificAdded = 0;
|
|
2510
|
+
for (const hunk of stats.hunks) {
|
|
2511
|
+
const oldSlice = [];
|
|
2512
|
+
const newSlice = [];
|
|
2513
|
+
for (const L of hunk.lines) {
|
|
2514
|
+
if (L.kind !== "+") oldSlice.push(L);
|
|
2515
|
+
if (L.kind !== "-") newSlice.push(L);
|
|
2516
|
+
}
|
|
2517
|
+
// Specific assertions are counted per side, on the reassembled
|
|
2518
|
+
// statement. The weakening check was still line-based: `assert (` alone
|
|
2519
|
+
// on a line names no value, so Black or Ruff splitting one assertion
|
|
2520
|
+
// across three lines removed one specific assertion and added none, and
|
|
2521
|
+
// a reformat was reported as CRITICAL tampering. Collapsing the joined
|
|
2522
|
+
// statement is what lets the bare-comparison form be recognised at all
|
|
2523
|
+
// — its pattern cannot cross a newline.
|
|
2524
|
+
const sides = [
|
|
2525
|
+
{ stmts: assembleStatements(oldSlice, lang), kind: "-" },
|
|
2526
|
+
{ stmts: assembleStatements(newSlice, lang), kind: "+" },
|
|
2527
|
+
];
|
|
2528
|
+
for (const { stmts, kind } of sides) {
|
|
2529
|
+
for (const st of stmts) {
|
|
2530
|
+
const changed = kind === "-" ? (st.removedLines?.length || 0) > 0 : (st.addedLines?.length || 0) > 0;
|
|
2531
|
+
if (!changed) continue;
|
|
2532
|
+
const clean = stripComments(st.text, lang);
|
|
2533
|
+
if (ASSERTION_PATTERN.test(clean)) touched++;
|
|
2534
|
+
if (isSpecificAssertion(collapseWhitespace(clean))) {
|
|
2535
|
+
if (kind === "-") stmtSpecificRemoved++;
|
|
2536
|
+
else stmtSpecificAdded++;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
stats.statementAssertions = touched;
|
|
2542
|
+
stats.stmtSpecificRemoved = stmtSpecificRemoved;
|
|
2543
|
+
stats.stmtSpecificAdded = stmtSpecificAdded;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2383
2546
|
// Assertions added in some *other* file of this same diff.
|
|
2384
2547
|
//
|
|
2385
2548
|
// Tracking is strictly per file, so moving a test from one file to another
|
|
@@ -2446,7 +2609,12 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2446
2609
|
const alreadyReportedSpecific = stats.removed
|
|
2447
2610
|
.slice(stats.added)
|
|
2448
2611
|
.filter((item) => isSpecificAssertion(item.text)).length;
|
|
2449
|
-
|
|
2612
|
+
// Believe whichever unit saw more arrive. A statement is the honest unit,
|
|
2613
|
+
// but the line count still carries cases the statement scanner cannot
|
|
2614
|
+
// assemble, so the loss is only what *both* agree was lost.
|
|
2615
|
+
const lineLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
|
|
2616
|
+
const stmtLost = Math.max(0, (stats.stmtSpecificRemoved || 0) - (stats.stmtSpecificAdded || 0));
|
|
2617
|
+
const specificLost = Math.min(lineLost, stmtLost);
|
|
2450
2618
|
const weakenedCount = Math.max(0, specificLost - alreadyReportedSpecific);
|
|
2451
2619
|
|
|
2452
2620
|
if (weakenedCount > 0) {
|
|
@@ -2485,38 +2653,6 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2485
2653
|
// understood. `UNREADABLE` is the state that has no business being silent
|
|
2486
2654
|
// — assertion-shaped lines were present and none of them parsed, which
|
|
2487
2655
|
// means this repository speaks a dialect the guard does not.
|
|
2488
|
-
// An assertion is a statement, not a line.
|
|
2489
|
-
//
|
|
2490
|
-
// Counting `+`/`-` lines missed the commonest shape in every language with
|
|
2491
|
-
// multi-line calls: `self.assertEqual(` sits on an unchanged context line
|
|
2492
|
-
// and only its argument lines are edited. Nothing among the changed lines
|
|
2493
|
-
// matched an assertion pattern, nothing looked assertion-shaped either, so
|
|
2494
|
-
// the guard reported `assertionsSeen: 0` and — because no line looked
|
|
2495
|
-
// suspicious — a clean PASS. A five-element expected list rewritten to one
|
|
2496
|
-
// element to match broken output sailed through five green phases.
|
|
2497
|
-
//
|
|
2498
|
-
// The statement machinery that already exists for pairing knows better:
|
|
2499
|
-
// it assembles context lines together with changed ones. Ask it.
|
|
2500
|
-
for (const [file, stats] of fileAssertions.entries()) {
|
|
2501
|
-
const lang = langForTestFile(file);
|
|
2502
|
-
let touched = 0;
|
|
2503
|
-
for (const hunk of stats.hunks) {
|
|
2504
|
-
const oldSlice = [];
|
|
2505
|
-
const newSlice = [];
|
|
2506
|
-
for (const L of hunk.lines) {
|
|
2507
|
-
if (L.kind !== "+") oldSlice.push(L);
|
|
2508
|
-
if (L.kind !== "-") newSlice.push(L);
|
|
2509
|
-
}
|
|
2510
|
-
for (const stmts of [assembleStatements(oldSlice, lang), assembleStatements(newSlice, lang)]) {
|
|
2511
|
-
for (const st of stmts) {
|
|
2512
|
-
const changed = (st.removedLines?.length || 0) + (st.addedLines?.length || 0) > 0;
|
|
2513
|
-
if (changed && ASSERTION_PATTERN.test(stripComments(st.text, lang))) touched++;
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
}
|
|
2517
|
-
stats.statementAssertions = touched;
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
2656
|
let examined = 0;
|
|
2521
2657
|
let assertionsSeen = 0;
|
|
2522
2658
|
const unreadable = [];
|
|
@@ -2617,24 +2753,41 @@ export function scanDiff(diffTextStr = "", options = {}) {
|
|
|
2617
2753
|
// dialect warning into the latter meant it reached nobody. The guard
|
|
2618
2754
|
// computed `UNREADABLE`, and the operator was shown an unblemished pass.
|
|
2619
2755
|
// A boundary that is not reported is not a boundary.
|
|
2756
|
+
//
|
|
2757
|
+
// And it blocks, because the previous wording was the defect it described.
|
|
2758
|
+
// Printing "this change was NOT checked for tampering ... it is not an
|
|
2759
|
+
// approval either" and then returning APPROVED (Exit 0) is the exact shape
|
|
2760
|
+
// this project exists to refuse: a verdict from a check that examined
|
|
2761
|
+
// nothing, dressed as a pass. A second cold-start trial walked a tampered
|
|
2762
|
+
// test and broken production code straight through on node-tap, and again
|
|
2763
|
+
// on BATS. `verify.tamperGuard: "warn"` is how a repository whose dialect
|
|
2764
|
+
// is genuinely unsupported opts out — deliberately, and on the record.
|
|
2765
|
+
let unreadableBlocks = false;
|
|
2620
2766
|
if (tamperingRes.status === "UNREADABLE") {
|
|
2767
|
+
const mode = options.tamperGuard === "warn" || options.allowUnreadableTests === true ? "warn" : "block";
|
|
2768
|
+
unreadableBlocks = mode === "block";
|
|
2621
2769
|
const where = (tamperingRes.unreadable || []).map((u) => u.file);
|
|
2622
2770
|
const sample = tamperingRes.unreadable?.[0]?.samples?.[0];
|
|
2623
2771
|
findings.push({
|
|
2624
|
-
severity: "MEDIUM",
|
|
2772
|
+
severity: unreadableBlocks ? "CRITICAL" : "MEDIUM",
|
|
2625
2773
|
type: "TEST_DIALECT_UNREADABLE",
|
|
2626
2774
|
file: where[0] ?? null,
|
|
2627
2775
|
line: null,
|
|
2628
2776
|
description:
|
|
2629
2777
|
`Test Tamper Guard: changed ${tamperingRes.inputsSeen} line(s) in ${tamperingRes.filesSeen} test file(s) ` +
|
|
2630
2778
|
`and recognised no assertion among them${sample ? ` (e.g. ${JSON.stringify(sample)})` : ""}. ` +
|
|
2631
|
-
`This change was NOT checked for tampering
|
|
2632
|
-
|
|
2779
|
+
`This change was NOT checked for tampering${unreadableBlocks ? ", so it cannot be approved" : ""}. ` +
|
|
2780
|
+
(unreadableBlocks
|
|
2781
|
+
? `If this repository's assertion library is genuinely unsupported, say so once in .agent/config.yml ` +
|
|
2782
|
+
`with verify.tamperGuard: "warn", or allow this run with --allow-unreadable-tests. Reporting the ` +
|
|
2783
|
+
`dialect is more useful than either: the guard covers Node, pytest, Go, Rust, JUnit, RSpec, PHPUnit, ` +
|
|
2784
|
+
`Minitest, XCTest, chai and node-tap.`
|
|
2785
|
+
: `Reported only, because verify.tamperGuard is set to "warn".`),
|
|
2633
2786
|
});
|
|
2634
2787
|
}
|
|
2635
2788
|
|
|
2636
2789
|
return {
|
|
2637
|
-
ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
|
|
2790
|
+
ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok && !unreadableBlocks,
|
|
2638
2791
|
findings,
|
|
2639
2792
|
};
|
|
2640
2793
|
}
|
package/src/stack-detector.mjs
CHANGED
|
@@ -66,10 +66,20 @@ function makefileHasTestTarget(root) {
|
|
|
66
66
|
* placeholder by this definition, and correctly so: it exits non-zero, which
|
|
67
67
|
* fails loudly rather than certifying nothing.
|
|
68
68
|
*/
|
|
69
|
+
// An interpreter handed an empty program: `node -e ""`, `python3 -c ''`,
|
|
70
|
+
// `sh -c ""`. It reads as a test command at a glance and runs nothing.
|
|
71
|
+
//
|
|
72
|
+
// This does not enumerate every way to write a no-op — nothing can, and the
|
|
73
|
+
// collection floor is what reports the general case, one-sidedly and by
|
|
74
|
+
// design. It closes the spellings that look like work.
|
|
75
|
+
const EMPTY_PROGRAM =
|
|
76
|
+
/^(?:\S*[/\\])?(?:node|nodejs|deno|bun|python[\d.]*|ruby|perl|php|sh|bash|zsh|dash)(?:\s+-{1,2}[\w-]+)*\s+(?:-e|-c|--eval|--execute)(?:\s*(?:""|''|``))?\s*$/;
|
|
77
|
+
|
|
69
78
|
export function isPlaceholderTestScript(cmd) {
|
|
70
79
|
if (typeof cmd !== "string") return false;
|
|
71
80
|
const trimmed = cmd.trim();
|
|
72
81
|
if (!trimmed) return true;
|
|
82
|
+
if (EMPTY_PROGRAM.test(trimmed)) return true;
|
|
73
83
|
// Drop the announcements; what matters is what the shell is left doing.
|
|
74
84
|
const remainder = trimmed
|
|
75
85
|
.split(/&&|;/)
|
|
@@ -849,6 +859,36 @@ export function resolveWorkspaceBoundary(changedFiles = [], root = process.cwd()
|
|
|
849
859
|
};
|
|
850
860
|
}
|
|
851
861
|
|
|
862
|
+
/**
|
|
863
|
+
* Does this repository contain any JavaScript for a parse oracle to check?
|
|
864
|
+
*
|
|
865
|
+
* A `package.json` is not an answer. A CSS library, an icon set or a font
|
|
866
|
+
* package has one and no JavaScript at all, and `bootstrap` happily wrote a
|
|
867
|
+
* `node --test .agent/smoke.test.mjs` config into it — so the very next gate
|
|
868
|
+
* run died on the generated suite's own assertion, "No JavaScript sources
|
|
869
|
+
* found to verify". The assertion was right; it just arrived after the config
|
|
870
|
+
* had been written, which left the user deadlocked by the repair advice.
|
|
871
|
+
*/
|
|
872
|
+
function hasJsSources(root, dir = root, depth = 0) {
|
|
873
|
+
if (depth > 6) return false;
|
|
874
|
+
let entries;
|
|
875
|
+
try {
|
|
876
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
877
|
+
} catch (_) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
for (const entry of entries) {
|
|
881
|
+
if (entry.name.startsWith(".")) continue;
|
|
882
|
+
if (["node_modules", "dist", "build", "coverage", "vendor", "target"].includes(entry.name)) continue;
|
|
883
|
+
if (entry.isDirectory()) {
|
|
884
|
+
if (hasJsSources(root, join(dir, entry.name), depth + 1)) return true;
|
|
885
|
+
} else if (/\.(mjs|cjs|jsx?|tsx?)$/.test(entry.name)) {
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
|
|
852
892
|
export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
|
|
853
893
|
const detected = detectPolyglotStack(root);
|
|
854
894
|
const configPath = join(root, ".agent", "config.yml");
|
|
@@ -898,9 +938,24 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
|
|
|
898
938
|
testCmd = "go vet ./...";
|
|
899
939
|
} else if (existsSync(join(root, "tsconfig.json"))) {
|
|
900
940
|
testCmd = "npx tsc --noEmit";
|
|
901
|
-
} else {
|
|
941
|
+
} else if (hasJsSources(root)) {
|
|
902
942
|
const smokePath = generateSmokeTestScript(root);
|
|
903
943
|
testCmd = `node --test ${smokePath}`;
|
|
944
|
+
} else {
|
|
945
|
+
// Refuse rather than write a config whose first run cannot pass. Saying
|
|
946
|
+
// "I cannot build an oracle here" is a usable answer; deadlocking the
|
|
947
|
+
// user with one that asserts its own impossibility is not.
|
|
948
|
+
return {
|
|
949
|
+
bootstrapped: false,
|
|
950
|
+
reason: "NO_ORACLE_POSSIBLE",
|
|
951
|
+
testCmd: "",
|
|
952
|
+
detail:
|
|
953
|
+
"This repository has no JavaScript sources to parse-check and no recognised " +
|
|
954
|
+
"test toolchain, so there is nothing an automatic oracle could verify. Set " +
|
|
955
|
+
"verify.test in .agent/config.yml to a command that checks this project — a " +
|
|
956
|
+
"linter, a build, a schema validation — or set verify.required: false to run " +
|
|
957
|
+
"the scope and secret phases only, deliberately.",
|
|
958
|
+
};
|
|
904
959
|
}
|
|
905
960
|
|
|
906
961
|
try {
|