jules-orchestrator-kit 0.66.0 → 0.68.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/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
  );
@@ -2194,7 +2203,22 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2194
2203
  { pattern: /\bXCTSkip(?:If|Unless|IfNot)?\s*\(/, desc: "Injected XCTest skip (XCTSkip())" },
2195
2204
  { pattern: /\b(?:xit|xdescribe|xcontext|xspecify)\b\s*["\x27]/i, desc: "Injected RSpec disabled example (xit)" },
2196
2205
  { pattern: /,\s*skip:\s*(?:true|["\x27])/i, desc: "Injected RSpec skip metadata (skip:)" },
2206
+ // node-tap, node:test and ava pass it as a property of an options object,
2207
+ // so the comma sits before the brace and not before the key.
2208
+ { pattern: /\{[^}]*\bskip\s*:\s*true/i, desc: "Injected skip option ({ skip: true })" },
2209
+ { pattern: /\{[^}]*\btodo\s*:\s*true/i, desc: "Injected todo option ({ todo: true })" },
2197
2210
  { pattern: /^\s*(?:skip|pending)\s*(?:["\x27(]|$)/i, desc: "Injected Minitest/RSpec skip statement" },
2211
+ // Skipping from inside the body, which is how these ecosystems actually
2212
+ // do it. Only the decorator and annotation forms were covered, so a test
2213
+ // could be silenced with the standard library's own method and the guard
2214
+ // said nothing: measured silent on six of seven in-body forms.
2215
+ { pattern: /\bself\.skipTest\s*\(/i, desc: "Injected unittest skip (self.skipTest())" },
2216
+ { pattern: /\braise\s+(?:unittest\.)?SkipTest\b/i, desc: "Injected unittest skip (raise SkipTest)" },
2217
+ { pattern: /\bpytest\.skip\s*\(/i, desc: "Injected Pytest skip call (pytest.skip())" },
2218
+ { pattern: /\bpytest\.xfail\s*\(/i, desc: "Injected Pytest expected-failure (pytest.xfail())" },
2219
+ { pattern: /\bthis\.skip\s*\(/i, desc: "Injected Mocha skip (this.skip())" },
2220
+ { pattern: /\b(?:it|test|describe|context)\.todo\s*\(/i, desc: "Injected todo placeholder (test.todo())" },
2221
+ { pattern: /\bt\.Skip(?:Now|f)?\s*\(/, desc: "Injected Go test skip (t.Skip/t.Skipf/t.SkipNow)" },
2198
2222
  ];
2199
2223
 
2200
2224
  // `#` and `--` belong here for the same reason the dialects belong in
@@ -2226,7 +2250,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2226
2250
  // indistinguishable from an untouched one. The lookahead keeps prose and
2227
2251
  // identifiers — `assertion`, `asserts`, `asserted` — out of the count.
2228
2252
  const ASSERTION_PATTERN =
2229
- /(?:\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;
2253
+ /(?:\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;
2230
2254
  // The loose net. Not a verdict and never a block — its only job is to
2231
2255
  // notice that a line was plainly an assertion in *some* dialect that
2232
2256
  // ASSERTION_PATTERN did not recognise. Without it, adding the seventh
@@ -2369,6 +2393,79 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2369
2393
  }
2370
2394
  }
2371
2395
 
2396
+ // An assertion is a statement, not a line.
2397
+ //
2398
+ // Counting `+`/`-` lines missed the commonest shape in every language with
2399
+ // multi-line calls: `self.assertEqual(` sits on an unchanged context line
2400
+ // and only its argument lines are edited. Nothing among the changed lines
2401
+ // matched an assertion pattern, nothing looked assertion-shaped either, so
2402
+ // the guard reported `assertionsSeen: 0` and — because no line looked
2403
+ // suspicious — a clean PASS. A five-element expected list rewritten to one
2404
+ // element to match broken output sailed through five green phases.
2405
+ //
2406
+ // The statement machinery that already exists for pairing knows better:
2407
+ // it assembles context lines together with changed ones. Ask it.
2408
+ for (const [file, stats] of fileAssertions.entries()) {
2409
+ const lang = langForTestFile(file);
2410
+ let touched = 0;
2411
+ let stmtSpecificRemoved = 0;
2412
+ let stmtSpecificAdded = 0;
2413
+ for (const hunk of stats.hunks) {
2414
+ const oldSlice = [];
2415
+ const newSlice = [];
2416
+ for (const L of hunk.lines) {
2417
+ if (L.kind !== "+") oldSlice.push(L);
2418
+ if (L.kind !== "-") newSlice.push(L);
2419
+ }
2420
+ // Specific assertions are counted per side, on the reassembled
2421
+ // statement. The weakening check was still line-based: `assert (` alone
2422
+ // on a line names no value, so Black or Ruff splitting one assertion
2423
+ // across three lines removed one specific assertion and added none, and
2424
+ // a reformat was reported as CRITICAL tampering. Collapsing the joined
2425
+ // statement is what lets the bare-comparison form be recognised at all
2426
+ // — its pattern cannot cross a newline.
2427
+ const sides = [
2428
+ { stmts: assembleStatements(oldSlice, lang), kind: "-" },
2429
+ { stmts: assembleStatements(newSlice, lang), kind: "+" },
2430
+ ];
2431
+ for (const { stmts, kind } of sides) {
2432
+ for (const st of stmts) {
2433
+ const changed = kind === "-" ? (st.removedLines?.length || 0) > 0 : (st.addedLines?.length || 0) > 0;
2434
+ if (!changed) continue;
2435
+ const clean = stripComments(st.text, lang);
2436
+ if (ASSERTION_PATTERN.test(clean)) touched++;
2437
+ if (isSpecificAssertion(collapseWhitespace(clean))) {
2438
+ if (kind === "-") stmtSpecificRemoved++;
2439
+ else stmtSpecificAdded++;
2440
+ }
2441
+ }
2442
+ }
2443
+ }
2444
+ stats.statementAssertions = touched;
2445
+ stats.stmtSpecificRemoved = stmtSpecificRemoved;
2446
+ stats.stmtSpecificAdded = stmtSpecificAdded;
2447
+ }
2448
+
2449
+ // Assertions added in some *other* file of this same diff.
2450
+ //
2451
+ // Tracking is strictly per file, so moving a test from one file to another
2452
+ // — ordinary refactoring — read as deleting three assertions and was
2453
+ // reported as CRITICAL tampering. The assertion still exists and still
2454
+ // runs; it is in a different file. What the removal check is for is
2455
+ // verification that *disappeared*, and this has not.
2456
+ //
2457
+ // Deliberately exact on the assertion text: something that changed on the
2458
+ // way across is not a move, and is judged normally.
2459
+ const movedIn = new Map();
2460
+ for (const [file, stats] of fileAssertions.entries()) {
2461
+ for (const t of stats.addedTexts || []) {
2462
+ const key = collapseWhitespace(String(t.text ?? t)).trim();
2463
+ if (!key) continue;
2464
+ if (!movedIn.has(key)) movedIn.set(key, []);
2465
+ movedIn.get(key).push(file);
2466
+ }
2467
+ }
2468
+
2372
2469
  for (const [file, stats] of fileAssertions.entries()) {
2373
2470
  // An expectation that was rewritten rather than removed.
2374
2471
  //
@@ -2386,6 +2483,15 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2386
2483
  if (stats.removed.length > stats.added) {
2387
2484
  const unreplaced = stats.removed.slice(stats.added);
2388
2485
  for (const item of unreplaced) {
2486
+ // Did this exact assertion arrive somewhere else in the same diff?
2487
+ const key = collapseWhitespace(item.text).trim();
2488
+ const landing = movedIn.get(key);
2489
+ const elsewhere = landing ? landing.findIndex((f) => f !== file) : -1;
2490
+ if (elsewhere !== -1) {
2491
+ // Consume the landing so two removals cannot both claim one arrival.
2492
+ landing.splice(elsewhere, 1);
2493
+ continue;
2494
+ }
2389
2495
  violations.push({
2390
2496
  file,
2391
2497
  line: item.line,
@@ -2406,7 +2512,12 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2406
2512
  const alreadyReportedSpecific = stats.removed
2407
2513
  .slice(stats.added)
2408
2514
  .filter((item) => isSpecificAssertion(item.text)).length;
2409
- const specificLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
2515
+ // Believe whichever unit saw more arrive. A statement is the honest unit,
2516
+ // but the line count still carries cases the statement scanner cannot
2517
+ // assemble, so the loss is only what *both* agree was lost.
2518
+ const lineLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
2519
+ const stmtLost = Math.max(0, (stats.stmtSpecificRemoved || 0) - (stats.stmtSpecificAdded || 0));
2520
+ const specificLost = Math.min(lineLost, stmtLost);
2410
2521
  const weakenedCount = Math.max(0, specificLost - alreadyReportedSpecific);
2411
2522
 
2412
2523
  if (weakenedCount > 0) {
@@ -2445,38 +2556,6 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2445
2556
  // understood. `UNREADABLE` is the state that has no business being silent
2446
2557
  // — assertion-shaped lines were present and none of them parsed, which
2447
2558
  // means this repository speaks a dialect the guard does not.
2448
- // An assertion is a statement, not a line.
2449
- //
2450
- // Counting `+`/`-` lines missed the commonest shape in every language with
2451
- // multi-line calls: `self.assertEqual(` sits on an unchanged context line
2452
- // and only its argument lines are edited. Nothing among the changed lines
2453
- // matched an assertion pattern, nothing looked assertion-shaped either, so
2454
- // the guard reported `assertionsSeen: 0` and — because no line looked
2455
- // suspicious — a clean PASS. A five-element expected list rewritten to one
2456
- // element to match broken output sailed through five green phases.
2457
- //
2458
- // The statement machinery that already exists for pairing knows better:
2459
- // it assembles context lines together with changed ones. Ask it.
2460
- for (const [file, stats] of fileAssertions.entries()) {
2461
- const lang = langForTestFile(file);
2462
- let touched = 0;
2463
- for (const hunk of stats.hunks) {
2464
- const oldSlice = [];
2465
- const newSlice = [];
2466
- for (const L of hunk.lines) {
2467
- if (L.kind !== "+") oldSlice.push(L);
2468
- if (L.kind !== "-") newSlice.push(L);
2469
- }
2470
- for (const stmts of [assembleStatements(oldSlice, lang), assembleStatements(newSlice, lang)]) {
2471
- for (const st of stmts) {
2472
- const changed = (st.removedLines?.length || 0) + (st.addedLines?.length || 0) > 0;
2473
- if (changed && ASSERTION_PATTERN.test(stripComments(st.text, lang))) touched++;
2474
- }
2475
- }
2476
- }
2477
- stats.statementAssertions = touched;
2478
- }
2479
-
2480
2559
  let examined = 0;
2481
2560
  let assertionsSeen = 0;
2482
2561
  const unreadable = [];
@@ -2577,24 +2656,41 @@ export function scanDiff(diffTextStr = "", options = {}) {
2577
2656
  // dialect warning into the latter meant it reached nobody. The guard
2578
2657
  // computed `UNREADABLE`, and the operator was shown an unblemished pass.
2579
2658
  // A boundary that is not reported is not a boundary.
2659
+ //
2660
+ // And it blocks, because the previous wording was the defect it described.
2661
+ // Printing "this change was NOT checked for tampering ... it is not an
2662
+ // approval either" and then returning APPROVED (Exit 0) is the exact shape
2663
+ // this project exists to refuse: a verdict from a check that examined
2664
+ // nothing, dressed as a pass. A second cold-start trial walked a tampered
2665
+ // test and broken production code straight through on node-tap, and again
2666
+ // on BATS. `verify.tamperGuard: "warn"` is how a repository whose dialect
2667
+ // is genuinely unsupported opts out — deliberately, and on the record.
2668
+ let unreadableBlocks = false;
2580
2669
  if (tamperingRes.status === "UNREADABLE") {
2670
+ const mode = options.tamperGuard === "warn" || options.allowUnreadableTests === true ? "warn" : "block";
2671
+ unreadableBlocks = mode === "block";
2581
2672
  const where = (tamperingRes.unreadable || []).map((u) => u.file);
2582
2673
  const sample = tamperingRes.unreadable?.[0]?.samples?.[0];
2583
2674
  findings.push({
2584
- severity: "MEDIUM",
2675
+ severity: unreadableBlocks ? "CRITICAL" : "MEDIUM",
2585
2676
  type: "TEST_DIALECT_UNREADABLE",
2586
2677
  file: where[0] ?? null,
2587
2678
  line: null,
2588
2679
  description:
2589
2680
  `Test Tamper Guard: changed ${tamperingRes.inputsSeen} line(s) in ${tamperingRes.filesSeen} test file(s) ` +
2590
2681
  `and recognised no assertion among them${sample ? ` (e.g. ${JSON.stringify(sample)})` : ""}. ` +
2591
- `This change was NOT checked for tampering. That is not a failure — an unlisted assertion library is ` +
2592
- `normal — but it is not an approval either, so it is reported rather than passed silently.`,
2682
+ `This change was NOT checked for tampering${unreadableBlocks ? ", so it cannot be approved" : ""}. ` +
2683
+ (unreadableBlocks
2684
+ ? `If this repository's assertion library is genuinely unsupported, say so once in .agent/config.yml ` +
2685
+ `with verify.tamperGuard: "warn", or allow this run with --allow-unreadable-tests. Reporting the ` +
2686
+ `dialect is more useful than either: the guard covers Node, pytest, Go, Rust, JUnit, RSpec, PHPUnit, ` +
2687
+ `Minitest, XCTest, chai and node-tap.`
2688
+ : `Reported only, because verify.tamperGuard is set to "warn".`),
2593
2689
  });
2594
2690
  }
2595
2691
 
2596
2692
  return {
2597
- ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
2693
+ ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok && !unreadableBlocks,
2598
2694
  findings,
2599
2695
  };
2600
2696
  }
@@ -849,6 +849,36 @@ export function resolveWorkspaceBoundary(changedFiles = [], root = process.cwd()
849
849
  };
850
850
  }
851
851
 
852
+ /**
853
+ * Does this repository contain any JavaScript for a parse oracle to check?
854
+ *
855
+ * A `package.json` is not an answer. A CSS library, an icon set or a font
856
+ * package has one and no JavaScript at all, and `bootstrap` happily wrote a
857
+ * `node --test .agent/smoke.test.mjs` config into it — so the very next gate
858
+ * run died on the generated suite's own assertion, "No JavaScript sources
859
+ * found to verify". The assertion was right; it just arrived after the config
860
+ * had been written, which left the user deadlocked by the repair advice.
861
+ */
862
+ function hasJsSources(root, dir = root, depth = 0) {
863
+ if (depth > 6) return false;
864
+ let entries;
865
+ try {
866
+ entries = readdirSync(dir, { withFileTypes: true });
867
+ } catch (_) {
868
+ return false;
869
+ }
870
+ for (const entry of entries) {
871
+ if (entry.name.startsWith(".")) continue;
872
+ if (["node_modules", "dist", "build", "coverage", "vendor", "target"].includes(entry.name)) continue;
873
+ if (entry.isDirectory()) {
874
+ if (hasJsSources(root, join(dir, entry.name), depth + 1)) return true;
875
+ } else if (/\.(mjs|cjs|jsx?|tsx?)$/.test(entry.name)) {
876
+ return true;
877
+ }
878
+ }
879
+ return false;
880
+ }
881
+
852
882
  export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
853
883
  const detected = detectPolyglotStack(root);
854
884
  const configPath = join(root, ".agent", "config.yml");
@@ -898,9 +928,24 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
898
928
  testCmd = "go vet ./...";
899
929
  } else if (existsSync(join(root, "tsconfig.json"))) {
900
930
  testCmd = "npx tsc --noEmit";
901
- } else {
931
+ } else if (hasJsSources(root)) {
902
932
  const smokePath = generateSmokeTestScript(root);
903
933
  testCmd = `node --test ${smokePath}`;
934
+ } else {
935
+ // Refuse rather than write a config whose first run cannot pass. Saying
936
+ // "I cannot build an oracle here" is a usable answer; deadlocking the
937
+ // user with one that asserts its own impossibility is not.
938
+ return {
939
+ bootstrapped: false,
940
+ reason: "NO_ORACLE_POSSIBLE",
941
+ testCmd: "",
942
+ detail:
943
+ "This repository has no JavaScript sources to parse-check and no recognised " +
944
+ "test toolchain, so there is nothing an automatic oracle could verify. Set " +
945
+ "verify.test in .agent/config.yml to a command that checks this project — a " +
946
+ "linter, a build, a schema validation — or set verify.required: false to run " +
947
+ "the scope and secret phases only, deliberately.",
948
+ };
904
949
  }
905
950
 
906
951
  try {