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.
@@ -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
- if (args.length >= 3 && isPureStringLiteral(args[args.length - 1])) idx.add(args.length - 1);
1797
- if (args.length >= 2 && isPureStringLiteral(args[0])) idx.add(0);
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 (differsOnlyInMessage(r.clean, a.clean, lang)) continue;
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 (differsOnlyInMessage(r.clean, a.clean, lang)) continue;
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, "") && !differsOnlyInMessage(clr, cla, lang)) {
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
- const specificLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
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. That is not a failure — an unlisted assertion library is ` +
2632
- `normal — but it is not an approval either, so it is reported rather than passed silently.`,
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
  }
@@ -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 {