@hone-ai/cli 1.38.1 → 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.
@@ -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
- const head = `tests failed (exit ${run.code})`
269
- + (sum.count > 1 ? ` — ${sum.count} failure lines` : '');
270
- return {
271
- verdict: 'fail',
272
- exitCode: EXIT.TESTS_FAILED,
273
- detail: head + (sum.first ? `, first: ${sum.first}` : ''),
274
- failure: sum,
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: nonEmpty.slice(-maxLines).map(l => l.slice(0, 300)),
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.38.1",
3
+ "version": "1.40.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {