@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.
- package/gonext_agent_chat.py +151 -14
- package/gu_test_digest.py +352 -0
- package/package.json +2 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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 `
|
|
9055
|
-
#
|
|
9056
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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{
|
|
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.
|
|
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",
|