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/AGENTS.md +135 -0
- package/CHANGELOG.md +824 -0
- package/README.md +4 -3
- package/ROADMAP_V1.md +202 -0
- package/bin/agentctl.mjs +20 -1
- package/package.json +5 -2
- package/scripts/doc-sync-check.mjs +9 -0
- package/scripts/guard-reach-check.mjs +24 -0
- package/scripts/run-tests.mjs +18 -1
- package/src/engine.mjs +39 -2
- package/src/guard-policy.mjs +151 -0
- package/src/security.mjs +134 -38
- package/src/stack-detector.mjs +46 -1
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
|
-
|
|
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
|
|
2592
|
-
|
|
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
|
}
|
package/src/stack-detector.mjs
CHANGED
|
@@ -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 {
|