@tiens.nguyen/gu-cli 1.0.832 → 1.0.833

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.
@@ -8024,7 +8024,9 @@ def _run_command_prompt_line(shell, windows=None):
8024
8024
  "interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
8025
8025
  "stop_server closes it. " + _BG_LINE)
8026
8026
  return (
8027
- " run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
8027
+ " run_command IS A SHELL (bash), and it KEEPS its folder and exported variables from one "
8028
+ "call to the next in this turn (`cd sub`, then `npm test` runs in sub; an explicit workdir "
8029
+ "wins). Pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
8028
8030
  "work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
8029
8031
  "`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
8030
8032
  "triple-quoted string.\n"
@@ -8037,6 +8039,13 @@ def _run_command_prompt_line(shell, windows=None):
8037
8039
  " GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
8038
8040
  "`gh issue comment 12 --body-file - <<'EOF'` … `EOF` (same for gh issue create / gh pr "
8039
8041
  "create) — never write a body file first, and do not run `gh auth status` first.\n"
8042
+ # #362: how Claude Code checks work — scripts as heredocs, the one function not the app,
8043
+ # cheapest check first, and never a pipe between a test run and the decision it feeds.
8044
+ " CHECKS: a Python/Node script goes as a heredoc — `python3 - <<'EOF'` … `EOF` (node: "
8045
+ "`node --input-type=module - <<'EOF'`) — never `python3 -c` built from joined strings. To "
8046
+ "check one function, load its module and call it with real input instead of running the "
8047
+ "app. Order: syntax check → the ONE test → the suite once. Test output arrives with a TEST "
8048
+ "SUMMARY of each failure first, so never pipe it into tail/grep (a pipe hides the exit code).\n"
8040
8049
  " Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
8041
8050
  "are blocked; piping into sh/python and writing or deleting outside the project ask the "
8042
8051
  "user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
@@ -8156,9 +8165,28 @@ def _test_commands_line(root: str) -> str:
8156
8165
  cmds = _project_test_commands(root) if root else []
8157
8166
  if not cmds:
8158
8167
  return ""
8159
- return (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
8168
+ line = (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
8160
8169
  "(a missing pytest fails silently behind a pipe): "
8161
8170
  + "; ".join(f"{d} → {c}" for d, c in cmds) + "\n")
8171
+ try:
8172
+ import gu_test_digest as _td
8173
+ forms = _td.one_test_forms(c for _, c in cmds)
8174
+ except Exception: # noqa: BLE001
8175
+ forms = []
8176
+ if forms:
8177
+ # #362: check a fix with ONE test (seconds), run the suite once at the end.
8178
+ line += (" ONE TEST while fixing (the suite once at the end): " + " · ".join(forms) + "\n")
8179
+ return line
8180
+
8181
+
8182
+ def _test_digest(output: str, cwd: str = "") -> str:
8183
+ """gu_test_digest.digest, or "" if the module is missing — a summary must never fail a command."""
8184
+ try:
8185
+ import gu_test_digest as _td
8186
+ return _td.digest(output or "", cwd or "")
8187
+ except Exception as e: # noqa: BLE001
8188
+ _log(f"test digest skip: {e}")
8189
+ return ""
8162
8190
 
8163
8191
 
8164
8192
  # bash's words for a line whose quotes do not pair up.
@@ -9051,10 +9079,10 @@ def _ws_cap_servers(max_live: int = 3) -> list:
9051
9079
  # running" link and the Shell details view (shell-details.mjs).
9052
9080
  #
9053
9081
  # THE EXIT CODE OUTLIVES THE AGENT. The agent is one process per turn; a build that ends after the
9054
- # turn has no parent left to wait() on it. So a shell line runs as `"$0" -c "$1"; rc=$?; echo $rc
9055
- # > "$2"; exit $rc` — the command text untouched, its exit code written to `exit_file` AND passed
9056
- # on (without the `exit $rc` every failure read as EXITED 0). The argv-only path (Windows)
9057
- # has no such wrapper and says "exit code unknown" instead of guessing.
9082
+ # turn has no parent left to wait() on it. So a shell line runs as `eval "$1"` under an EXIT trap
9083
+ # that writes its exit code to `exit_file` AND exits with it (without that every failure read as
9084
+ # EXITED 0) — see _run_state_wrapper, which also keeps the folder and exports for the next call.
9085
+ # The argv-only path has no such wrapper and says "exit code unknown" instead of guessing.
9058
9086
  _SHELLS_KEEP = 20 # newest records kept, running or not
9059
9087
  _SHELLS_KEEP_DONE_S = 86400 # a finished shell is listed for a day
9060
9088
 
@@ -9301,6 +9329,77 @@ def _shells_context_line(shells: list, now=None) -> str:
9301
9329
  + ". Read one with shell_output(<id>); stop one with stop_server(<id>).\n")
9302
9330
 
9303
9331
 
9332
+ # THE SHELL KEEPS ITS FOLDER AND EXPORTS BETWEEN CALLS (#362), the way Claude Code's Bash tool does.
9333
+ # Every run_command was a fresh bash, so `cd tools/x` or `source .venv/bin/activate` lasted one call
9334
+ # and every line started `cd … &&`. Not a long-lived pty (a hung one would wedge the turn): each
9335
+ # line runs in the outer shell via `eval`, and an EXIT trap — which also fires on `exit 3` — saves
9336
+ # `export -p` and `pwd` to this turn's state file; the next call sources it and starts in that
9337
+ # folder. One turn only: the file is removed when the agent exits. An explicit workdir wins.
9338
+ _RUN_STATE = {"path": ""}
9339
+ # Builtins that change the shell itself. As argv they run nothing (`export` is no program; macOS's
9340
+ # /usr/bin/cd exits 0 having changed nothing) — with a shell, a line using one goes to bash.
9341
+ _SHELL_STATE_BUILTINS = {"cd", "export", "source", ".", "set", "unset", "alias", "unalias", "pushd",
9342
+ "popd", "umask", "shopt", "declare", "typeset", "readonly", "trap", "ulimit",
9343
+ "hash", "eval"}
9344
+ _RUN_STATE_SKIP = r"^declare -x (PWD|OLDPWD|SHLVL|_)="
9345
+
9346
+
9347
+ def _run_state_path() -> str:
9348
+ import atexit
9349
+ import time as _t
9350
+ if not _RUN_STATE["path"]:
9351
+ d = os.path.join(os.path.expanduser("~"), ".gonext", "run-state")
9352
+ os.makedirs(d, exist_ok=True)
9353
+ # A background shell that ends after its turn rewrites its file; sweep day-old ones.
9354
+ try:
9355
+ for n in os.listdir(d):
9356
+ fp = os.path.join(d, n)
9357
+ if _t.time() - os.path.getmtime(fp) > 86400:
9358
+ os.remove(fp)
9359
+ except OSError:
9360
+ pass
9361
+ _RUN_STATE["path"] = os.path.join(d, f"{os.getpid()}.sh")
9362
+
9363
+ def _clean(base=_RUN_STATE["path"]):
9364
+ for ext in ("", ".cwd", ".tmp"):
9365
+ try:
9366
+ os.remove(base + ext)
9367
+ except OSError:
9368
+ pass
9369
+ atexit.register(_clean)
9370
+ return _RUN_STATE["path"]
9371
+
9372
+
9373
+ def _run_state_cwd() -> str:
9374
+ """The folder the last shell line ended in, this turn — or ""."""
9375
+ p = _RUN_STATE["path"]
9376
+ if not p:
9377
+ return ""
9378
+ try:
9379
+ with open(p + ".cwd", encoding="utf-8") as fh:
9380
+ return fh.read().strip()
9381
+ except OSError:
9382
+ return ""
9383
+
9384
+
9385
+ def _run_state_set_cwd(cwd: str) -> None:
9386
+ """Record the folder an && chain ended in — the chain runs its `cd` itself, not in bash."""
9387
+ try:
9388
+ with open(_run_state_path() + ".cwd", "w", encoding="utf-8") as fh:
9389
+ fh.write(cwd)
9390
+ except OSError:
9391
+ pass
9392
+
9393
+
9394
+ def _run_state_wrapper() -> str:
9395
+ """The outer script: $1 the line, $2 the exit file, $3 the state file."""
9396
+ return ('[ -f "$3" ] && . "$3" >/dev/null 2>&1; '
9397
+ "trap '__gu_rc=$?; echo $__gu_rc > \"$2\"; "
9398
+ f"export -p | grep -vE \"{_RUN_STATE_SKIP}\" > \"$3.tmp\" 2>/dev/null && mv -f \"$3.tmp\" \"$3\"; "
9399
+ "pwd > \"$3.cwd\"; exit $__gu_rc' EXIT; "
9400
+ 'eval "$1"')
9401
+
9402
+
9304
9403
  def _ws_scrubbed_env() -> dict:
9305
9404
  """Minimal child env for run_command. The worker process env holds secrets
9306
9405
  (GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
@@ -14684,6 +14783,14 @@ def run_agent_chat(cfg):
14684
14783
  if os.path.isdir(os.path.join(_active, _stripped)):
14685
14784
  _wd_in = _stripped
14686
14785
  wd = _rag_read_allowed(_wd_in or _default_ws_root())
14786
+ _wd_before = wd
14787
+ if not _wd_in and _run_shell_bin():
14788
+ _kept = _run_state_cwd()
14789
+ if _kept and os.path.isdir(_kept):
14790
+ try:
14791
+ wd = _rag_read_allowed(_kept)
14792
+ except Exception: # noqa: BLE001 — left the allowed folders: start over
14793
+ wd = _wd_before
14687
14794
  w = _ws_for_path(wd)
14688
14795
  if w is None or not w.get("allowRun"):
14689
14796
  # Contextual nudge (task #102): the #1 reason the model shells out here is
@@ -14707,7 +14814,12 @@ def run_agent_chat(cfg):
14707
14814
  + (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
14708
14815
  _last_obs["text"] = msg
14709
14816
  return msg
14710
- _use_shell = bool(_scan) and _shell_needed(_scan)
14817
+ # Once this turn has shell state (a cd, an activated venv), every line goes through
14818
+ # the shell so it starts from that state — not only lines with shell syntax.
14819
+ _words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] if _scan else []
14820
+ _builtin = sorted({w for w in _words if w in _SHELL_STATE_BUILTINS})
14821
+ _use_shell = bool(_scan) and (_shell_needed(_scan) or bool(_builtin) or bool(
14822
+ _RUN_STATE["path"] and os.path.exists(_RUN_STATE["path"])))
14711
14823
  _bg_err = _shell_background_error(_scan) if _use_shell else None
14712
14824
  if _bg_err:
14713
14825
  _last_obs["text"] = _bg_err
@@ -14720,7 +14832,10 @@ def run_agent_chat(cfg):
14720
14832
  # AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
14721
14833
  # allowlist, risk gate, approval, server detection, all of it — in order, and
14722
14834
  # the first failure stops the rest. `cd DIR` sets the folder for what follows.
14723
- _chain = _and_chain_parts(command)
14835
+ # A chain runs each part as a program; `export`/`source` in it would run nothing,
14836
+ # so with a shell such a line goes to bash whole. (`cd` the chain does itself.)
14837
+ _chain = None if (_use_shell and [b for b in _builtin if b != "cd"]) \
14838
+ else _and_chain_parts(command)
14724
14839
  if _chain:
14725
14840
  _chain_start = _t.time()
14726
14841
  _cur_wd = wd
@@ -14764,6 +14879,10 @@ def run_agent_chat(cfg):
14764
14879
  + (": " + " && ".join(_rest) if _rest else "")
14765
14880
  if _stopped else f"CHAIN DONE — all {len(_chain)} parts ran")
14766
14881
  result = _summary + "\n" + "\n".join(_outs)
14882
+ if _shell_bin and _cur_wd != wd:
14883
+ _run_state_set_cwd(_cur_wd) # the next call starts where the chain ended
14884
+ result += (f"\n(the shell is now in {_cur_wd} — the next run_command "
14885
+ "without a workdir starts there)")
14767
14886
  _last_obs["text"] = result
14768
14887
  return result
14769
14888
  # REFUSE SHELL SYNTAX BEFORE SPAWNING (task #204). Without this, `cd X && Y`
@@ -14884,12 +15003,13 @@ def run_agent_chat(cfg):
14884
15003
  if _use_shell:
14885
15004
  _cmd_text, _attrib_note = _with_attribution_text(
14886
15005
  command, _scan, _attribution_settings(wd))
14887
- # The exit code is written to a file so it outlives this turn's process —
14888
- # the line itself runs untouched as $1 (see BACKGROUND SHELLS).
15006
+ # The exit code is written to a file so it outlives this turn's process, and
15007
+ # the folder + exports are kept for the next call — the line itself runs
15008
+ # untouched as $1 (see BACKGROUND SHELLS, _run_state_wrapper).
14889
15009
  # Git Bash reads C:/x, not C:\x, inside the line it runs.
14890
15010
  _fs = (lambda x: x.replace("\\", "/")) if _IS_WINDOWS else (lambda x: x)
14891
- argv = [_shell_bin, "-c", '"$0" -c "$1"; rc=$?; echo $rc > "$2"; exit $rc',
14892
- _fs(_shell_bin), _cmd_text, _fs(exit_path)]
15011
+ argv = [_shell_bin, "-c", _run_state_wrapper(),
15012
+ _fs(_shell_bin), _cmd_text, _fs(exit_path), _fs(_run_state_path())]
14893
15013
  else:
14894
15014
  argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
14895
15015
  if _attrib_note:
@@ -14939,7 +15059,11 @@ def run_agent_chat(cfg):
14939
15059
  while True:
14940
15060
  rc = proc.poll()
14941
15061
  if rc is not None:
14942
- out = _ws_distill_output(_read_log())
15062
+ _raw = _read_log()
15063
+ out = _ws_distill_output(_raw)
15064
+ # The result FIRST (#362): counts and each failure with file:line, read
15065
+ # from the WHOLE output — the observation below is capped, this is not.
15066
+ _digest = _test_digest(_raw, wd)
14943
15067
  # Task #90 Phase 1 — truthful perception: never claim success the
14944
15068
  # tool didn't observe. exit 0 is a FACT about the process, not
14945
15069
  # proof of the GOAL (e.g. `npm start` exits 0 when its port is
@@ -14962,7 +15086,7 @@ def run_agent_chat(cfg):
14962
15086
  # what `cd X && Y` used to do — and the model read the silence as a
14963
15087
  # clean pass. An absence cannot be reasoned about; a stated fact can.
14964
15088
  result = (
14965
- f"{status}\n{out}" if out.strip()
15089
+ f"{status}\n" + (f"{_digest}\n\n" if _digest else "") + out if out.strip()
14966
15090
  else f"{status}\nNO OUTPUT: the command printed nothing on stdout or "
14967
15091
  "stderr. If you expected output, it probably did not do what "
14968
15092
  "you intended — check the command before repeating it."
@@ -14982,6 +15106,11 @@ def run_agent_chat(cfg):
14982
15106
  f"Command failed behind a pipe → {_trim_words(_hidden)}"})
14983
15107
  if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
14984
15108
  result += _HEREDOC_HINT
15109
+ # Say where the shell now is, so a next `cd sub` is not done twice (#362).
15110
+ _now = _run_state_cwd() if _use_shell else ""
15111
+ if _now and os.path.realpath(_now) != os.path.realpath(wd):
15112
+ result += (f"\n(the shell is now in {_now} — the next run_command "
15113
+ "without a workdir starts there)")
14985
15114
  if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
14986
15115
  _f = _ws_access_facts()
14987
15116
  result = f"{result}\n\n{_ws_access_report(_f, _ws_paths_in(command))}"
@@ -15197,6 +15326,14 @@ def run_agent_chat(cfg):
15197
15326
  if st["state"] == "exited":
15198
15327
  head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
15199
15328
  "the output and VERIFY.")
15329
+ if st["state"] != "running":
15330
+ try:
15331
+ with open(sh.get("log") or "", encoding="utf-8", errors="replace") as fh:
15332
+ _dg = _test_digest(fh.read(), str(sh.get("workdir") or ""))
15333
+ except OSError:
15334
+ _dg = ""
15335
+ if _dg:
15336
+ head += "\n" + _dg
15200
15337
  tail_note = ""
15201
15338
  if not new.strip() and int(sh.get("read_offset") or 0) == 0:
15202
15339
  lf = _shell_log_file(str(sh.get("command") or ""), str(sh.get("workdir") or ""))
@@ -0,0 +1,352 @@
1
+ """Test-result digest for run_command (task #362, GitHub #142) — PURE.
2
+
3
+ THE MODEL READ TEST OUTPUT THROUGH `| tail` AND `| grep`. Measured 2026-09-23 → 10-14 in local
4
+ Mongo: 84 of 397 turns ran tests, 32 of them spent 4+ steps doing it, and test output was piped
5
+ into tail/head/grep to find the result — the pipe that hid `No module named pytest` and let a
6
+ commit go in untested (#140 review). The answer is not a smaller window onto the output; it is
7
+ the result first: the counts, then each failing test with its message and file:line, computed
8
+ from the WHOLE output (the observation itself is capped, a digest never is).
9
+
10
+ One parser per runner, each recognising its own summary line, so a line that runs two runners
11
+ (`python3 -m unittest … && node --test`, this repo's npm test) gets both. Nothing recognised →
12
+ "" and the output is shown exactly as before. Names are the ones the runner prints, which are the
13
+ ones its run-one-test form takes (unittest's dotted id, pytest's node id, go's TestName…).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+
20
+ MAX_FAILURES = 10 # per runner; the rest are counted
21
+ MAX_MESSAGE = 220 # chars of one failure's message
22
+
23
+
24
+ def _clip(text: str, n: int = MAX_MESSAGE) -> str:
25
+ # Line breaks become one space; runs of spaces INSIDE a line stay — 'GU AGENT' vs 'GU AGENT'
26
+ # is the whole failure, and collapsing it printed two identical strings.
27
+ text = re.sub(r"[ \t]*\n[ \t]*", " ", (text or "").strip())
28
+ return text if len(text) <= n else text[: n - 1] + "…"
29
+
30
+
31
+ def _rel(path: str, cwd: str) -> str:
32
+ path = (path or "").strip().strip("'\"")
33
+ if path.startswith("file://"):
34
+ path = path[7:]
35
+ if cwd:
36
+ for base in {cwd, os.path.realpath(cwd)}:
37
+ if base and path.startswith(base.rstrip("/") + "/"):
38
+ return path[len(base.rstrip("/")) + 1:]
39
+ return path
40
+
41
+
42
+ def _fail(name, message="", where="") -> dict:
43
+ return {"name": _clip(name, 160), "message": _clip(message), "where": where}
44
+
45
+
46
+ # ---- node --test, spec reporter (the default) ---------------------------------------------------
47
+ _NODE_SPEC_SUM = re.compile(r"^ℹ (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
48
+
49
+
50
+ def _node_spec(out: str, cwd: str):
51
+ counts = {}
52
+ for k, v in _NODE_SPEC_SUM.findall(out):
53
+ counts[k] = counts.get(k, 0) + int(v)
54
+ if "tests" not in counts:
55
+ return None
56
+ fails = []
57
+ block = out.split("✖ failing tests:", 1)
58
+ if len(block) == 2:
59
+ # "test at FILE:L:C" / "✖ name (ms)" / indented message lines, until the next "test at".
60
+ for m in re.finditer(r"^test at (\S+?):(\d+):\d+\n✖ (.+?) \([\d.]+m?s\)[^\n]*\n((?:[ \t].*\n?|\n)*)",
61
+ block[1], re.M):
62
+ body = [ln.strip() for ln in m.group(4).splitlines()]
63
+ msg = []
64
+ for ln in body:
65
+ if ln.startswith("at ") or ln.startswith("{") or ln.startswith("generatedMessage"):
66
+ break
67
+ if ln and ln not in ("+ actual - expected", "^"):
68
+ msg.append(ln)
69
+ text = " ".join(msg[:5])
70
+ if text.strip("'\"") == "test failed":
71
+ # A FILE that failed to load: the runner only says 'test failed'; the cause
72
+ # (ERR_MODULE_NOT_FOUND, a SyntaxError) was printed above the summary.
73
+ err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
74
+ text = _rel_in(err.group(1), cwd) if err else text
75
+ fails.append(_fail(m.group(3), text, f"{_rel(m.group(1), cwd)}:{m.group(2)}"))
76
+ else:
77
+ # A file that crashed before any test ran: "✖ tests/c.test.mjs (78ms)", the error above it.
78
+ err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
79
+ for m in re.finditer(r"^✖ (.+?) \([\d.]+m?s\)", out, re.M):
80
+ fails.append(_fail(m.group(1), _rel_in(err.group(1), cwd) if err else ""))
81
+ return {"runner": "node --test", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
82
+ "skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
83
+ "failures": fails}
84
+
85
+
86
+ def _rel_in(text: str, cwd: str) -> str:
87
+ """Absolute paths under cwd inside a message, shortened."""
88
+ if not cwd:
89
+ return text
90
+ for base in {cwd.rstrip("/"), os.path.realpath(cwd).rstrip("/")}:
91
+ text = text.replace("file://" + base + "/", "").replace(base + "/", "")
92
+ return text
93
+
94
+
95
+ # ---- node --test / anything TAP ------------------------------------------------------------------
96
+ _TAP_SUM = re.compile(r"^# (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
97
+
98
+
99
+ def _tap(out: str, cwd: str):
100
+ counts = {}
101
+ for k, v in _TAP_SUM.findall(out):
102
+ counts[k] = counts.get(k, 0) + int(v)
103
+ if "tests" not in counts or "TAP version" not in out and "# Subtest" not in out:
104
+ return None
105
+ fails = []
106
+ for m in re.finditer(r"^[ \t]*not ok \d+ - (.+?)(?: # .*)?\n((?:[ \t]{2,}.*\n?|[ \t]*\n)*)", out, re.M):
107
+ yaml = m.group(2)
108
+ loc = re.search(r"location: '([^']+?):(\d+):\d+'", yaml)
109
+ err = re.search(r"error: \|-?\n((?:[ \t]{4,}.*\n?|[ \t]*\n)+)", yaml) or re.search(r"error: '([^']*)'", yaml)
110
+ msg = ""
111
+ if err:
112
+ msg = " ".join(ln.strip() for ln in err.group(1).splitlines()
113
+ if ln.strip() and ln.strip() not in ("+ actual - expected", "^"))
114
+ fails.append(_fail(m.group(1), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
115
+ return {"runner": "node --test (TAP)", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
116
+ "skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
117
+ "failures": fails}
118
+
119
+
120
+ # ---- python unittest ------------------------------------------------------------------------------
121
+ def _unittest(out: str, cwd: str):
122
+ ran = re.findall(r"^Ran (\d+) tests? in [\d.]+s\s*$", out, re.M)
123
+ if not ran:
124
+ return None
125
+ total = sum(int(x) for x in ran)
126
+ failures = errors = skipped = 0
127
+ for line in re.findall(r"^(?:OK|FAILED)(?: \((.*)\))?\s*$", out, re.M):
128
+ for k, v in re.findall(r"(\w[\w ]*?)=(\d+)", line or ""):
129
+ k = k.strip()
130
+ if k == "failures":
131
+ failures += int(v)
132
+ elif k == "errors":
133
+ errors += int(v)
134
+ elif k == "skipped":
135
+ skipped += int(v)
136
+ fails = []
137
+ for m in re.finditer(r"^(FAIL|ERROR): (\S+) \((\S+?)\)[^\n]*\n-{20,}\n(.*?)(?=^={20,}|^-{20,}\nRan |\Z)",
138
+ out, re.M | re.S):
139
+ kind, short, dotted, body = m.groups()
140
+ lines = [ln for ln in body.rstrip().splitlines() if ln.strip()]
141
+ msg = lines[-1].strip() if lines else kind
142
+ locs = [(f, ln) for f, ln in re.findall(r'File "([^"]+)", line (\d+)', body)
143
+ if "/lib/python" not in f and "unittest/" not in f]
144
+ where = f"{_rel(locs[-1][0], cwd)}:{locs[-1][1]}" if locs else ""
145
+ fails.append(_fail(dotted if "." in dotted else short, msg, where))
146
+ return {"runner": "unittest", "passed": max(0, total - failures - errors - skipped), "failed": failures,
147
+ "skipped": skipped, "errors": errors, "failures": fails}
148
+
149
+
150
+ # ---- pytest ---------------------------------------------------------------------------------------
151
+ _PYTEST_SUM = re.compile(r"^=+ ((?:\d+ \w+(?:, )?)+) in [\d.]+s(?: \([^)]*\))? =+\s*$", re.M)
152
+
153
+
154
+ def _pytest(out: str, cwd: str):
155
+ sums = _PYTEST_SUM.findall(out)
156
+ if not sums:
157
+ return None
158
+ c = {}
159
+ for k_v in ", ".join(sums).split(", "):
160
+ m = re.match(r"(\d+) (\w+)", k_v.strip())
161
+ if m:
162
+ c[m.group(2)] = c.get(m.group(2), 0) + int(m.group(1))
163
+ fails = []
164
+ for m in re.finditer(r"^(FAILED|ERROR) (\S+?)(?: - (.*))?$", out, re.M):
165
+ nodeid = m.group(2)
166
+ loc = re.search(rf"^({re.escape(nodeid.split('::')[0])}):(\d+): ", out, re.M)
167
+ fails.append(_fail(nodeid, m.group(3) or m.group(1), f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
168
+ return {"runner": "pytest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
169
+ "skipped": c.get("skipped", 0) + c.get("xfailed", 0), "errors": c.get("error", 0) + c.get("errors", 0),
170
+ "failures": fails}
171
+
172
+
173
+ # ---- jest / vitest --------------------------------------------------------------------------------
174
+ def _jest(out: str, cwd: str):
175
+ m = re.search(r"^Tests:\s+(.*\d+ total)\s*$", out, re.M)
176
+ if not m:
177
+ return None
178
+ c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
179
+ fails = []
180
+ for f in re.finditer(r"^\s*● (.+?)\n\n((?:.*\n){1,6})", out, re.M):
181
+ if f.group(1).startswith("Console"):
182
+ continue
183
+ msg = next((ln.strip() for ln in f.group(2).splitlines() if ln.strip()), "")
184
+ loc = re.search(r"\(([^()\s]+?):(\d+):\d+\)", f.group(2)) or re.search(r"at .*?([\w./-]+\.\w+):(\d+):\d+", out)
185
+ fails.append(_fail(f.group(1).replace(" › ", " > "), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
186
+ return {"runner": "jest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
187
+ "skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
188
+
189
+
190
+ def _vitest(out: str, cwd: str):
191
+ m = re.search(r"^\s*Tests\s+(.*?)\s*\((\d+)\)\s*$", out, re.M)
192
+ if not m or "|" not in m.group(1) and not re.search(r"\d+ (passed|failed)", m.group(1)):
193
+ return None
194
+ c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
195
+ fails = []
196
+ for f in re.finditer(r"^[ \t]*FAIL[ \t]+(\S+) > (.+?)\n((?:.*\n){0,4})", out, re.M):
197
+ msg = next((ln.strip() for ln in f.group(3).splitlines() if re.search(r"Error|expected", ln)), "")
198
+ loc = re.search(r"❯ (\S+?):(\d+):\d+", f.group(3))
199
+ fails.append(_fail(f"{f.group(1)} > {f.group(2).strip()}", msg,
200
+ f"{loc.group(1)}:{loc.group(2)}" if loc else f.group(1)))
201
+ if not fails: # no "Failed Tests" section (a reporter without it): the × lines
202
+ for f in re.finditer(r"^[ \t]*× (.+?)(?:[ \t]+\d+ms)?\n[ \t]*→ (.*)$", out, re.M):
203
+ fails.append(_fail(f.group(1), f.group(2)))
204
+ return {"runner": "vitest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
205
+ "skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
206
+
207
+
208
+ # ---- go test ---------------------------------------------------------------------------------------
209
+ def _gotest(out: str, cwd: str):
210
+ pkgs = re.findall(r"^(ok|FAIL|---? FAIL)\s+(\S+)\s+(?:[\d.]+s|\(cached\)|\[)", out, re.M)
211
+ fails_raw = list(re.finditer(r"^[ \t]*--- FAIL: (\S+) \([\d.]+s\)", out, re.M))
212
+ if not pkgs and not fails_raw:
213
+ return None
214
+ passed = len(re.findall(r"^\s*--- PASS: ", out, re.M))
215
+ skipped = len(re.findall(r"^\s*--- SKIP: ", out, re.M))
216
+ fails = []
217
+ for f in fails_raw:
218
+ # Since Go 1.14 a test's t.Error lines are printed between its "=== RUN" and "--- FAIL".
219
+ run = re.search(rf"^=== RUN\s+{re.escape(f.group(1))}\n((?:[ \t]{{4,}}.*\n)*)", out, re.M)
220
+ first = next((ln.strip() for ln in (run.group(1) if run else "").splitlines() if ln.strip()), "")
221
+ loc = re.match(r"([\w./-]+\.go):(\d+): (.*)", first)
222
+ fails.append(_fail(f.group(1), loc.group(3) if loc else first, f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
223
+ ok_pkgs = sum(1 for k, _ in pkgs if k == "ok")
224
+ bad_pkgs = sum(1 for k, _ in pkgs if k == "FAIL")
225
+ return {"runner": "go test", "passed": passed, "failed": len(fails_raw) or bad_pkgs, "skipped": skipped,
226
+ "errors": 0, "failures": fails,
227
+ "note": f"{ok_pkgs} package(s) ok, {bad_pkgs} FAIL" if pkgs else ""}
228
+
229
+
230
+ # ---- cargo test ------------------------------------------------------------------------------------
231
+ def _cargo(out: str, cwd: str):
232
+ res = re.findall(r"^test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored", out, re.M)
233
+ if not res:
234
+ return None
235
+ p = sum(int(a) for a, _, _ in res)
236
+ f = sum(int(b) for _, b, _ in res)
237
+ s = sum(int(c) for _, _, c in res)
238
+ fails = []
239
+ for m in re.finditer(r"^---- (\S+) stdout ----\n((?:.*\n){1,4})", out, re.M):
240
+ body = m.group(2)
241
+ loc = re.search(r"panicked at ([^\s:]+):(\d+):\d+", body)
242
+ msg = [ln.strip() for ln in body.splitlines() if ln.strip() and "panicked at" not in ln]
243
+ fails.append(_fail(m.group(1), msg[0] if msg else "panicked", f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
244
+ return {"runner": "cargo test", "passed": p, "failed": f, "skipped": s, "errors": 0, "failures": fails}
245
+
246
+
247
+ # ---- tsc / eslint (checks, not tests — same shape: count, then the first problems) ----------------
248
+ def _tsc(out: str, cwd: str):
249
+ errs = re.findall(r"^(.+?)\((\d+),\d+\): error (TS\d+): (.+)$", out, re.M)
250
+ if not errs:
251
+ return None
252
+ fails = [_fail(code, msg, f"{_rel(f, cwd)}:{ln}") for f, ln, code, msg in errs]
253
+ return {"runner": "tsc", "check": True, "errors": len(errs),
254
+ "files": len({e[0] for e in errs}), "failures": fails}
255
+
256
+
257
+ def _eslint(out: str, cwd: str):
258
+ m = re.search(r"^✖ (\d+) problems? \((\d+) errors?, (\d+) warnings?\)", out, re.M)
259
+ if not m:
260
+ return None
261
+ fails, cur = [], ""
262
+ for ln in out.splitlines():
263
+ if ln and not ln.startswith(" ") and not ln.startswith("✖"):
264
+ cur = _rel(ln.strip(), cwd)
265
+ continue
266
+ e = re.match(r"^\s+(\d+):\d+\s+error\s+(.+?)\s{2,}(\S+)\s*$", ln)
267
+ if e and cur:
268
+ fails.append(_fail(e.group(3), e.group(2), f"{cur}:{e.group(1)}"))
269
+ return {"runner": "eslint", "check": True, "errors": int(m.group(2)), "warnings": int(m.group(3)),
270
+ "failures": fails}
271
+
272
+
273
+ _PARSERS = (_unittest, _pytest, _node_spec, _tap, _jest, _vitest, _gotest, _cargo, _tsc, _eslint)
274
+
275
+
276
+ def parse(output: str, cwd: str = "") -> list[dict]:
277
+ """Every runner whose summary appears in `output`, in that order. Never raises."""
278
+ found = []
279
+ for p in _PARSERS:
280
+ try:
281
+ r = p(output or "", cwd)
282
+ except Exception: # noqa: BLE001 — a digest must never break a command result
283
+ r = None
284
+ if r:
285
+ found.append(r)
286
+ return found
287
+
288
+
289
+ def _counts(r: dict) -> str:
290
+ if r.get("check"):
291
+ bits = [f"{r['errors']} error{'s' if r['errors'] != 1 else ''}"]
292
+ if r.get("warnings"):
293
+ bits.append(f"{r['warnings']} warning{'s' if r['warnings'] != 1 else ''}")
294
+ return " · ".join(bits)
295
+ bits = [f"{r['passed']} passed"]
296
+ if r["failed"]:
297
+ bits.append(f"{r['failed']} failed")
298
+ if r.get("errors"):
299
+ bits.append(f"{r['errors']} error{'s' if r['errors'] != 1 else ''}")
300
+ if r.get("skipped"):
301
+ bits.append(f"{r['skipped']} skipped")
302
+ if r.get("note"):
303
+ bits.append(r["note"])
304
+ return " · ".join(bits)
305
+
306
+
307
+ def digest(output: str, cwd: str = "") -> str:
308
+ """The block run_command puts ABOVE the output: counts per runner, then each failure as
309
+ `✗ name — message (file:line)`. "" when no runner was recognised."""
310
+ runs = parse(output, cwd)
311
+ if not runs:
312
+ return ""
313
+ bad = [r for r in runs if r.get("failed") or r.get("errors") or r.get("failures")]
314
+ if not bad:
315
+ return "TEST SUMMARY: " + " · ".join(f"{r['runner']} {_counts(r)}" for r in runs) + " — all passed."
316
+ lines = ["TEST SUMMARY (from the whole output — read this before the log below):"]
317
+ for r in runs:
318
+ lines.append(f" {r['runner']}: {_counts(r)}")
319
+ for f in r["failures"][:MAX_FAILURES]:
320
+ tail = f" ({f['where']})" if f["where"] else ""
321
+ lines.append(f" ✗ {f['name']}" + (f" — {f['message']}" if f["message"] else "") + tail)
322
+ if len(r["failures"]) > MAX_FAILURES:
323
+ lines.append(f" … and {len(r['failures']) - MAX_FAILURES} more")
324
+ return "\n".join(lines)
325
+
326
+
327
+ # ---- running ONE test ------------------------------------------------------------------------------
328
+ # Per runner, the form that runs a single test — what the model needs after the first full run, so
329
+ # a fix is checked in seconds and the suite runs once at the end. Keyed on what the project's own
330
+ # test command contains.
331
+ ONE_TEST = (
332
+ (r"\bnode --test\b", "node --test --test-name-pattern='<name>' <file>"),
333
+ (r"\bunittest\b", "python3 -m unittest <module>.<Class>.<test_name>"),
334
+ (r"\bpytest\b", "python3 -m pytest '<file>::<test>' -x"),
335
+ (r"\bvitest\b", "npx vitest run <file> -t '<name>'"),
336
+ (r"\bjest\b", "npx jest <file> -t '<name>'"),
337
+ (r"\bgo test\b", "go test -run '^<TestName>$' ./<pkg>"),
338
+ (r"\bcargo test\b", "cargo test <name>"),
339
+ (r"\bmvn\b.*\btest\b", "mvn test -Dtest=<Class>#<method>"),
340
+ (r"\bgradlew?\b.*\btest\b", "./gradlew test --tests '<Class.method>'"),
341
+ (r"\brspec\b", "bundle exec rspec <file>:<line>"),
342
+ )
343
+
344
+
345
+ def one_test_forms(commands) -> list[str]:
346
+ """Distinct run-one-test forms for the runners named in these test commands."""
347
+ seen = []
348
+ for cmd in commands or []:
349
+ for pat, form in ONE_TEST:
350
+ if re.search(pat, cmd or "") and form not in seen:
351
+ seen.append(form)
352
+ return seen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.832",
3
+ "version": "1.0.833",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -135,6 +135,7 @@
135
135
  "gonext_agent_chat.py",
136
136
  "gu_mcp.py",
137
137
  "gu_skills.py",
138
+ "gu_test_digest.py",
138
139
  "gu_hooks.py",
139
140
  "prompt_scaffold_check.py",
140
141
  "gonext_transcribe.py",