@hone-ai/cli 1.39.0 → 1.40.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/hone-cli.js +152 -48
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +230 -9
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +20 -3
- package/lib/judge-provider.js +20 -11
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/security-scanner.js +7 -4
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
package/lib/verify-patch.js
CHANGED
|
@@ -262,23 +262,97 @@ function verdictFromRun(run, provisioning = null) {
|
|
|
262
262
|
missing: absent.missing,
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
|
-
const sum = summarizeFailure(run);
|
|
265
|
+
const sum = summarizeFailure(run, { runner: true });
|
|
266
266
|
// HC-GF-023: lead with HOW MANY. "1 of 8 named" and "the only failure" are
|
|
267
267
|
// different situations that this line used to render identically.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
268
|
+
// HC-GF-363: and with WHICH, from the runner's own failure records. This detail is the only verification
|
|
269
|
+
// text a retried builder receives, so it names the failing tests, not lines that merely contain "Error".
|
|
270
|
+
// The wording never writes "<n> tests failed/failing": the failure router reads that as a test failure
|
|
271
|
+
// (FAIL_COUNT_RE in server/src/services/failure-router.js) and a builder that echoed it would be re-routed.
|
|
272
|
+
const head = `tests failed (exit ${run.code})`;
|
|
273
|
+
const first = sum.first ? `, first: ${routerSafe(sum.first)}` : '';
|
|
274
|
+
let detail;
|
|
275
|
+
if (sum.source === 'runner') {
|
|
276
|
+
const named = sum.tests.map((t) => routerSafe(t.slice(0, 160))).join('; ');
|
|
277
|
+
const more = sum.count > sum.tests.length ? `; +${sum.count - sum.tests.length} more` : '';
|
|
278
|
+
detail = `${head} — ${sum.count} failing ${sum.count === 1 ? 'test' : 'tests'} (runner records): ${named}${more}${first}`;
|
|
279
|
+
} else {
|
|
280
|
+
detail = head + (sum.count > 1 ? ` — ${sum.count} failure lines (pattern match, no runner records)` : '') + first;
|
|
281
|
+
}
|
|
282
|
+
return { verdict: 'fail', exitCode: EXIT.TESTS_FAILED, detail, failure: sum };
|
|
276
283
|
}
|
|
277
284
|
|
|
278
285
|
/** Lines that look like a runner reporting a failure. Deliberately broad — this
|
|
279
286
|
* counts EVIDENCE of failure across pytest/node/jest/go/cargo, not test cases. */
|
|
280
287
|
const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\b|\w+Error\b|panic:|AssertionError/;
|
|
281
288
|
|
|
289
|
+
/**
|
|
290
|
+
* HC-GF-363 (review MED-1): adopter text (a test's name, a log line) rides inside the detail, and the detail can be
|
|
291
|
+
* echoed into step_4's output, which the server's failure router READS. A failing test named `reports 2 tests failed`
|
|
292
|
+
* would route as `test_fail`, and a linter line `3 errors, 0 warnings` as `lint_fail`. This quotes the phrases the
|
|
293
|
+
* router's step_4 and step_5 detectors match (a count before "tests failed/failing" or "errors", "lint failed",
|
|
294
|
+
* "eslint error:", the `[UNKNOWN … operator must supply]` blocker, a `REVIEW SUMMARY:` verdict) so none can match;
|
|
295
|
+
* the words stay readable. Idempotent. tests/regression/HC-GF-363-auto-reject.test.js runs the REAL detectFailure.
|
|
296
|
+
*/
|
|
297
|
+
function routerSafe(text) {
|
|
298
|
+
return String(text == null ? '' : text)
|
|
299
|
+
.replace(/(\d+)(\s+)(tests?)(\s+)(failing|failed)\b/gi, '$1$2"$3"$4$5')
|
|
300
|
+
.replace(/(\d+)(\s+)(errors?)\b/gi, '$1$2"$3"')
|
|
301
|
+
.replace(/\b(lint(?:ing)?)(\s+)(failed)\b/gi, '$1$2"$3"')
|
|
302
|
+
.replace(/\b(eslint|ruff|mypy)(\s+)(error)(\s*:)/gi, '$1$2"$3"$4')
|
|
303
|
+
.replace(/\[UNKNOWN\b/gi, '[ UNKNOWN')
|
|
304
|
+
.replace(/REVIEW(\s+)SUMMARY:/gi, 'REVIEW$1"SUMMARY":')
|
|
305
|
+
.replace(/##(\s+)Abort(\s+)Reason:/gi, '# #$1Abort$2Reason:');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* HC-GF-363 — the failure records a test RUNNER prints, anchored at the start of the line. A line that merely
|
|
310
|
+
* contains "Error" or "FAIL" in a test's name (`✔ parseGhError recognizes …`, `ok 7 - … FAIL …`) is a PASS record
|
|
311
|
+
* and matches none of these: on the pilot's log 118 of the 124 lines FAILURE_LINE_RE flagged were passes.
|
|
312
|
+
* Each returns the failing test's name, or null when the line is not that runner's failure record.
|
|
313
|
+
*/
|
|
314
|
+
const RUNNER_RECORDS = Object.freeze([
|
|
315
|
+
// TAP (node --test, tap, prove): `not ok 3 - name`. A `# TODO` / `# SKIP` directive is not a failure.
|
|
316
|
+
['tap', (l) => {
|
|
317
|
+
const m = l.match(/^\s*not ok\b(.*)$/);
|
|
318
|
+
if (!m || /#\s*(?:TODO|SKIP)\b/i.test(m[1])) return null;
|
|
319
|
+
return m[1].replace(/^\s*\d*\s*(?:-\s*)?/, '').trim() || 'not ok';
|
|
320
|
+
}],
|
|
321
|
+
// node:test spec reporter: `✖ name (12ms)` (U+2716), also printed again in its `✖ failing tests:` recap. eslint's
|
|
322
|
+
// own `✖ 3 problems` summary is not a test.
|
|
323
|
+
['spec', (l) => {
|
|
324
|
+
const m = l.match(/^\s*\u2716\s+(.*)$/);
|
|
325
|
+
if (!m || /^failing tests\b/i.test(m[1]) || /^\d+\s+problems?\b/i.test(m[1]) || /#\s*(?:TODO|SKIP)\b/i.test(m[1])) return null;
|
|
326
|
+
return m[1].replace(/\s*\(\d+(?:\.\d+)?\s*ms\)\s*$/, '').trim() || null;
|
|
327
|
+
}],
|
|
328
|
+
// jest: `✕ name (3 ms)` (U+2715)
|
|
329
|
+
['jest', (l) => {
|
|
330
|
+
const m = l.match(/^\s*\u2715\s+(.*)$/);
|
|
331
|
+
return m ? (m[1].replace(/\s*\(\d+(?:\.\d+)?\s*ms\)\s*$/, '').trim() || null) : null;
|
|
332
|
+
}],
|
|
333
|
+
// pytest: the short summary `FAILED path::test - reason` / `ERROR path::test`, and verbose `path::test FAILED`
|
|
334
|
+
['pytest', (l) => {
|
|
335
|
+
const m = l.match(/^(?:FAILED|ERROR)\s+(\S+\.py::\S*)/) || l.match(/^(\S+\.py::\S+)\s+FAILED\b/);
|
|
336
|
+
return m ? m[1] : null;
|
|
337
|
+
}],
|
|
338
|
+
// go test: `--- FAIL: TestX (0.00s)` (subtests are indented)
|
|
339
|
+
['go', (l) => { const m = l.match(/^\s*--- FAIL:\s+(\S+)/); return m ? m[1] : null; }],
|
|
340
|
+
// cargo test: `test path::name ... FAILED`
|
|
341
|
+
['cargo', (l) => { const m = l.match(/^test\s+(\S+)\s+\.\.\.\s+FAILED$/); return m ? m[1] : null; }],
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
/** The failing test a runner record names, or null. Pure. */
|
|
345
|
+
function runnerFailure(line) {
|
|
346
|
+
for (const [, read] of RUNNER_RECORDS) {
|
|
347
|
+
const name = read(line);
|
|
348
|
+
if (name) return name;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A line the runner prints for a test that did NOT fail (or an announcement, or a TODO/SKIP): never "the first failure". */
|
|
354
|
+
const RUNNER_NON_FAILURE_RE = /^\s*(?:ok\b|not ok\b[^\n]*#\s*(?:TODO|SKIP)\b|#\s*Subtest:|[\u2714\u2713]\s|--- (?:PASS|SKIP):|=== (?:RUN|PAUSE|CONT)\b|test \S+ \.\.\. ok$|\S+::\S+\s+PASSED\b|PASSED\s)/;
|
|
355
|
+
|
|
282
356
|
/**
|
|
283
357
|
* HC-GF-023 — summarise a failed run instead of quoting one line of it.
|
|
284
358
|
*
|
|
@@ -290,19 +364,52 @@ const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\
|
|
|
290
364
|
* re-run. A one-line quote of a multi-failure run is not a summary; it is a
|
|
291
365
|
* sample presented as a conclusion.
|
|
292
366
|
*
|
|
367
|
+
* HC-GF-363: `runner: true` (verdictFromRun only) counts and names the runner's own failure records first
|
|
368
|
+
* (RUNNER_RECORDS) and says which it used. `first` is then the first broad-pattern line that is not a runner PASS
|
|
369
|
+
* record, so a SyntaxError still leads the FAILED names (HC-GF-023) and a passing test never does. With no runner
|
|
370
|
+
* record, or without the option (classifyGateRun: a linter prints `✖ N problems`, which is not a test), it is the
|
|
371
|
+
* broad pattern exactly as before.
|
|
372
|
+
*
|
|
293
373
|
* Pure: returns the count, the FIRST failure (usually nearer the cause than the
|
|
294
374
|
* last) and a tail excerpt. Writing the full log is IO and stays in the CLI.
|
|
295
375
|
*
|
|
296
|
-
* @returns {{ count: number, first: string, excerpt: string[] }}
|
|
376
|
+
* @returns {{ count: number, first: string, excerpt: string[], tests: string[], source: 'runner'|'pattern' }}
|
|
377
|
+
* `tests`: up to 10 failing test names (200 chars each) from runner records, in order, repeats removed;
|
|
378
|
+
* `count`: the number of distinct runner records (source 'runner') or of broad-pattern lines ('pattern')
|
|
297
379
|
*/
|
|
298
|
-
function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12 } = {}) {
|
|
380
|
+
function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12, runner = false } = {}) {
|
|
299
381
|
const lines = `${stdout}\n${stderr}`.split('\n').map(l => l.replace(/\s+$/, ''));
|
|
300
382
|
const nonEmpty = lines.filter(l => l.trim());
|
|
383
|
+
const excerpt = nonEmpty.slice(-maxLines).map(l => l.slice(0, 300));
|
|
301
384
|
const failing = nonEmpty.filter(l => FAILURE_LINE_RE.test(l));
|
|
385
|
+
if (runner) {
|
|
386
|
+
const names = [];
|
|
387
|
+
let firstRecord = '';
|
|
388
|
+
for (const l of nonEmpty) {
|
|
389
|
+
const n = runnerFailure(l);
|
|
390
|
+
if (!n) continue;
|
|
391
|
+
if (!firstRecord) firstRecord = l;
|
|
392
|
+
if (!names.includes(n)) names.push(n);
|
|
393
|
+
}
|
|
394
|
+
if (names.length) {
|
|
395
|
+
// TAP prints a test's own stdout as `# …` comment lines, so a passing test that logs a TypeError must not lead.
|
|
396
|
+
const tap = nonEmpty.some(l => /^\s*not ok\b/.test(l));
|
|
397
|
+
const lead = failing.find(l => !RUNNER_NON_FAILURE_RE.test(l) && !(tap && /^\s*#\s/.test(l))) || firstRecord;
|
|
398
|
+
return {
|
|
399
|
+
count: names.length,
|
|
400
|
+
first: lead.trim().slice(0, 200),
|
|
401
|
+
excerpt,
|
|
402
|
+
tests: names.slice(0, 10).map(n => n.slice(0, 200)),
|
|
403
|
+
source: 'runner',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
302
407
|
return {
|
|
303
408
|
count: failing.length,
|
|
304
409
|
first: (failing[0] || nonEmpty[nonEmpty.length - 1] || '').trim().slice(0, 200),
|
|
305
|
-
excerpt
|
|
410
|
+
excerpt,
|
|
411
|
+
tests: [],
|
|
412
|
+
source: 'pattern',
|
|
306
413
|
};
|
|
307
414
|
}
|
|
308
415
|
|
|
@@ -419,6 +526,8 @@ function classifyGateRun(run) {
|
|
|
419
526
|
return { verdict: 'unavailable', detail: 'output exceeded the capture buffer (ENOBUFS) — gate NOT run to completion' };
|
|
420
527
|
}
|
|
421
528
|
if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
|
|
529
|
+
// HC-GF-363: no `runner` option, on purpose. A linter's `✖ N problems` is not a test record, and this detail is
|
|
530
|
+
// pinned byte for byte by the first-finding ordering below.
|
|
422
531
|
const sum = summarizeFailure(run, { maxLines: 6 });
|
|
423
532
|
// A linter prints its findings from the TOP, so unlike a test runner the most
|
|
424
533
|
// useful line is the first, not the last. summarizeFailure's fallback is the
|
|
@@ -438,4 +547,5 @@ module.exports = {
|
|
|
438
547
|
EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND,
|
|
439
548
|
AUX_GATES, AUX_DETECT, resolveAuxGates, classifyGateRun,
|
|
440
549
|
verdictFromRun, commandAbsence, verifyCommandEnv, lastMeaningfulLine, summarizeFailure, FAILURE_LINE_RE,
|
|
550
|
+
RUNNER_RECORDS, runnerFailure, routerSafe,
|
|
441
551
|
};
|