jules-orchestrator-kit 0.67.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,6 +2203,10 @@ 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" },
2198
2211
  // Skipping from inside the body, which is how these ecosystems actually
2199
2212
  // do it. Only the decorator and annotation forms were covered, so a test
@@ -2237,7 +2250,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2237
2250
  // indistinguishable from an untouched one. The lookahead keeps prose and
2238
2251
  // identifiers — `assertion`, `asserts`, `asserted` — out of the count.
2239
2252
  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;
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;
2241
2254
  // The loose net. Not a verdict and never a block — its only job is to
2242
2255
  // notice that a line was plainly an assertion in *some* dialect that
2243
2256
  // ASSERTION_PATTERN did not recognise. Without it, adding the seventh
@@ -2380,6 +2393,59 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2380
2393
  }
2381
2394
  }
2382
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
+
2383
2449
  // Assertions added in some *other* file of this same diff.
2384
2450
  //
2385
2451
  // Tracking is strictly per file, so moving a test from one file to another
@@ -2446,7 +2512,12 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2446
2512
  const alreadyReportedSpecific = stats.removed
2447
2513
  .slice(stats.added)
2448
2514
  .filter((item) => isSpecificAssertion(item.text)).length;
2449
- 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);
2450
2521
  const weakenedCount = Math.max(0, specificLost - alreadyReportedSpecific);
2451
2522
 
2452
2523
  if (weakenedCount > 0) {
@@ -2485,38 +2556,6 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2485
2556
  // understood. `UNREADABLE` is the state that has no business being silent
2486
2557
  // — assertion-shaped lines were present and none of them parsed, which
2487
2558
  // 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
2559
  let examined = 0;
2521
2560
  let assertionsSeen = 0;
2522
2561
  const unreadable = [];
@@ -2617,24 +2656,41 @@ export function scanDiff(diffTextStr = "", options = {}) {
2617
2656
  // dialect warning into the latter meant it reached nobody. The guard
2618
2657
  // computed `UNREADABLE`, and the operator was shown an unblemished pass.
2619
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;
2620
2669
  if (tamperingRes.status === "UNREADABLE") {
2670
+ const mode = options.tamperGuard === "warn" || options.allowUnreadableTests === true ? "warn" : "block";
2671
+ unreadableBlocks = mode === "block";
2621
2672
  const where = (tamperingRes.unreadable || []).map((u) => u.file);
2622
2673
  const sample = tamperingRes.unreadable?.[0]?.samples?.[0];
2623
2674
  findings.push({
2624
- severity: "MEDIUM",
2675
+ severity: unreadableBlocks ? "CRITICAL" : "MEDIUM",
2625
2676
  type: "TEST_DIALECT_UNREADABLE",
2626
2677
  file: where[0] ?? null,
2627
2678
  line: null,
2628
2679
  description:
2629
2680
  `Test Tamper Guard: changed ${tamperingRes.inputsSeen} line(s) in ${tamperingRes.filesSeen} test file(s) ` +
2630
2681
  `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.`,
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".`),
2633
2689
  });
2634
2690
  }
2635
2691
 
2636
2692
  return {
2637
- ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
2693
+ ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok && !unreadableBlocks,
2638
2694
  findings,
2639
2695
  };
2640
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 {