@appchy/jarvis 0.1.76 → 0.1.78
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/dist/bin.js +12 -8
- package/dist/bin.js.map +1 -1
- package/harness/harness/align.py +1 -1
- package/harness/harness/coverage.py +18 -3
- package/harness/harness/gate.py +57 -17
- package/harness/harness/shard.py +55 -18
- package/harness/test_work.py +147 -4
- package/package.json +3 -3
package/harness/harness/align.py
CHANGED
|
@@ -566,7 +566,7 @@ def _align_acceptance(root: Path) -> list:
|
|
|
566
566
|
# only test never executed, which is the false green the whole join exists to
|
|
567
567
|
# catch. Only statuses the run explicitly reports as not-passed are subtracted,
|
|
568
568
|
# so `align` still works before anyone has produced a run.
|
|
569
|
-
run
|
|
569
|
+
run = _load_run(repo).proven
|
|
570
570
|
unproven = {cid for cid, status in run.items() if status in ("todo", "failed")}
|
|
571
571
|
|
|
572
572
|
for name, md in sorted(features.items()):
|
|
@@ -70,9 +70,10 @@ def cmd_coverage(args) -> int:
|
|
|
70
70
|
nobody reads. The flip is its own task."""
|
|
71
71
|
root = find_work_root()
|
|
72
72
|
repo = root.parent
|
|
73
|
-
|
|
73
|
+
read = _load_run(repo)
|
|
74
|
+
run, runners, shards = read.proven, read.runners, read.shards
|
|
74
75
|
|
|
75
|
-
if not shards:
|
|
76
|
+
if not shards and not read.stale:
|
|
76
77
|
# The commands come from `verify.*` in the repo's config. They used to be
|
|
77
78
|
# one repo's own package-manager invocations, hardcoded — which told every
|
|
78
79
|
# other consumer to run a command it does not have. A name-only literal
|
|
@@ -142,7 +143,21 @@ def cmd_coverage(args) -> int:
|
|
|
142
143
|
len(built & todo), len(declared - built),
|
|
143
144
|
sorted(failed), sorted(gap), sorted(ahead)))
|
|
144
145
|
|
|
145
|
-
print(f"\n read {len(shards)} shard(s): {', '.join(shards)}
|
|
146
|
+
print(f"\n read {len(shards)} shard(s): {', '.join(shards)}")
|
|
147
|
+
# WHAT WAS SET ASIDE, on the same screen as what was counted. A result whose
|
|
148
|
+
# test file is gone and a result that never existed look identical in the
|
|
149
|
+
# totals, and telling them apart is the difference between "re-run that suite"
|
|
150
|
+
# and "nobody has ever proved this".
|
|
151
|
+
if read.stale:
|
|
152
|
+
folders = sorted({s.rsplit("/", 1)[0] for s in read.stale if "/" in s})
|
|
153
|
+
print(f" ignored {len(read.stale)} shard(s) whose test file is gone"
|
|
154
|
+
f"{' (' + ', '.join(folders) + ')' if folders else ''}"
|
|
155
|
+
" — re-run those suites to replace them")
|
|
156
|
+
if read.unattributed:
|
|
157
|
+
print(f" {len(read.unattributed)} shard(s) do not say which file they ran"
|
|
158
|
+
" — counted, but nothing can check them: "
|
|
159
|
+
+ ", ".join(read.unattributed))
|
|
160
|
+
print()
|
|
146
161
|
print(f" {'feature':<18} {'built':>6} {'PROVEN':>7} {'eyes-on':>8} {'todo':>5} "
|
|
147
162
|
f"{'gap':>4} {'unbuilt':>8} status")
|
|
148
163
|
print(f" {'-' * 18} {'-' * 6} {'-' * 7} {'-' * 8} {'-' * 5} {'-' * 4} "
|
package/harness/harness/gate.py
CHANGED
|
@@ -384,12 +384,19 @@ def _results_out(data) -> list:
|
|
|
384
384
|
for r in (data.get("results") or [])]
|
|
385
385
|
|
|
386
386
|
|
|
387
|
-
def
|
|
388
|
-
"""
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
387
|
+
def _verify_report(root, name) -> int:
|
|
388
|
+
"""Say where the run stands. **Never starts one.**
|
|
389
|
+
|
|
390
|
+
ASKING AND STARTING ARE TWO OPERATIONS, and they used to be one call whose
|
|
391
|
+
meaning depended on timing: it attached when a run was in flight and started
|
|
392
|
+
one when none was. So a session checking whether the gates had finished set a
|
|
393
|
+
four-minute build going by asking — which is how a repo with a shared build
|
|
394
|
+
directory raced itself, and the resulting corruption was read as a flaky gate
|
|
395
|
+
for three sessions.
|
|
396
|
+
|
|
397
|
+
The split is on this side rather than the other because THIS is the call that
|
|
398
|
+
gets repeated. Polling is the loop; starting happens once and is said out
|
|
399
|
+
loud. A caller that repeats itself must be unable to cause anything.
|
|
393
400
|
"""
|
|
394
401
|
running = live_run(root)
|
|
395
402
|
if running:
|
|
@@ -424,6 +431,31 @@ def _verify_async(root, name) -> int:
|
|
|
424
431
|
"done": done.get("done") or [], "passed": bool(done.get("passed")),
|
|
425
432
|
"commit": done.get("sha") or "", "results": _results_out(done)})
|
|
426
433
|
|
|
434
|
+
return _answer(
|
|
435
|
+
"idle",
|
|
436
|
+
f"No gates have run for '{name or 'no task'}' at this commit. Start them "
|
|
437
|
+
f"explicitly — asking never starts a run.",
|
|
438
|
+
{"gates": sorted(VERIFY)})
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _verify_start(root, name) -> int:
|
|
442
|
+
"""Start the gates in the background. **Never reports on somebody else's run.**
|
|
443
|
+
|
|
444
|
+
Refuses when a run is in flight rather than quietly attaching to it. Attaching
|
|
445
|
+
is what let one call report a run started for a DIFFERENT task, whose verdict
|
|
446
|
+
would then have been read against this one's commit — measured 2026-09-09.
|
|
447
|
+
A refusal that names the task holding the checkout is the honest answer, and
|
|
448
|
+
the report door is one call away.
|
|
449
|
+
"""
|
|
450
|
+
running = live_run(root)
|
|
451
|
+
if running:
|
|
452
|
+
return _answer(
|
|
453
|
+
"running", _busy(running),
|
|
454
|
+
{"gate": running.get("current") or "starting",
|
|
455
|
+
"elapsedSeconds": _elapsed(running),
|
|
456
|
+
"gates": running.get("gates") or [], "done": running.get("done") or [],
|
|
457
|
+
"results": _results_out(running)})
|
|
458
|
+
|
|
427
459
|
child = subprocess.Popen(
|
|
428
460
|
[sys.executable, os.path.abspath(sys.argv[0]), "verify", "--task", name,
|
|
429
461
|
"--detached"],
|
|
@@ -432,17 +464,24 @@ def _verify_async(root, name) -> int:
|
|
|
432
464
|
return _answer(
|
|
433
465
|
"started",
|
|
434
466
|
f"Gates started ({len(VERIFY)}): {', '.join(sorted(VERIFY))}. They take "
|
|
435
|
-
f"minutes, not seconds — ask
|
|
467
|
+
f"minutes, not seconds — ask for progress, which never starts another.",
|
|
436
468
|
{"gates": sorted(VERIFY)})
|
|
437
469
|
|
|
438
470
|
|
|
439
471
|
def cmd_verify(args) -> int:
|
|
440
472
|
"""Execute the verify commands and RECORD the result against the task.
|
|
441
473
|
|
|
442
|
-
|
|
443
|
-
is the
|
|
444
|
-
|
|
445
|
-
|
|
474
|
+
Four doors, and **asking is not one of the ones that starts anything.**
|
|
475
|
+
Blocking is the person's: typing the command is the explicit act, so it runs
|
|
476
|
+
the gates and prints the table. `--async` starts a background run for the tool
|
|
477
|
+
surface, because a call that takes 174 seconds cannot be a relayed round trip.
|
|
478
|
+
`--status` only reports, and is what a caller waiting for a result uses.
|
|
479
|
+
`--detached` is the child `--async` spawns and is nobody's to type.
|
|
480
|
+
|
|
481
|
+
The two used to be one call that started or attached depending on timing, and
|
|
482
|
+
the ambiguity cost more than it saved: a session polling for a result started
|
|
483
|
+
the run it was waiting for, and one call attached to a run belonging to
|
|
484
|
+
another task and would have reported that verdict here.
|
|
446
485
|
"""
|
|
447
486
|
root = find_work_root()
|
|
448
487
|
name = (args.get("task") or "").strip()
|
|
@@ -456,16 +495,17 @@ def cmd_verify(args) -> int:
|
|
|
456
495
|
_execute(root, name)
|
|
457
496
|
return 0
|
|
458
497
|
|
|
498
|
+
if args.get("status"):
|
|
499
|
+
return _verify_report(root, name)
|
|
500
|
+
|
|
501
|
+
if args.get("async"):
|
|
502
|
+
return _verify_start(root, name)
|
|
503
|
+
|
|
459
504
|
running = live_run(root)
|
|
460
505
|
if running:
|
|
461
|
-
if args.get("async"):
|
|
462
|
-
return _verify_async(root, name)
|
|
463
506
|
print(f"error: {_busy(running)}", file=sys.stderr)
|
|
464
507
|
return 1
|
|
465
508
|
|
|
466
|
-
if args.get("async"):
|
|
467
|
-
return _verify_async(root, name)
|
|
468
|
-
|
|
469
509
|
results, ok, sha = _execute(root, name)
|
|
470
510
|
_print_report(results, sha)
|
|
471
511
|
if name:
|
|
@@ -577,7 +617,7 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
|
|
|
577
617
|
# a recorded sentence saying what a human saw. A `covers:` with neither is the
|
|
578
618
|
# exact shape of "done on the model's say-so".
|
|
579
619
|
if task.covers:
|
|
580
|
-
proven
|
|
620
|
+
proven = _load_run(root.parent).proven
|
|
581
621
|
seen = _observed_acs(task)
|
|
582
622
|
feature = (task.owner or "").split("/")[-1]
|
|
583
623
|
for ac in task.covers:
|
package/harness/harness/shard.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Reading the coverage shards — the ONE reader, as
|
|
1
|
+
"""Reading the coverage shards — the ONE reader, as the covers package is the one writer.
|
|
2
2
|
|
|
3
|
-
Every runner (vitest, Playwright,
|
|
4
|
-
`.work/coverage/<runner>-<workspace>.json` carrying what that run actually proved.
|
|
5
|
-
checks need to read them — `coverage` reports them, `align` refuses to call a skipped
|
|
3
|
+
Every runner (vitest, Playwright, doctest, a hand-rolled script) drops a
|
|
4
|
+
`.work/coverage/<runner>-<workspace>-<source>.json` carrying what that run actually proved.
|
|
5
|
+
Three checks need to read them — `coverage` reports them, `align` refuses to call a skipped
|
|
6
6
|
binding evidence, and `lint`'s rollup asks whether a shipped feature has any. Each growing
|
|
7
7
|
its own reader is the drift the shard format exists to prevent, and it also produced a real
|
|
8
8
|
import cycle the moment the second one wanted it.
|
|
@@ -11,6 +11,7 @@ import cycle the moment the second one wanted it.
|
|
|
11
11
|
"""
|
|
12
12
|
import json
|
|
13
13
|
from pathlib import Path
|
|
14
|
+
from typing import NamedTuple
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
#: Precedence when several sites claim one criterion — the SAME rank both
|
|
@@ -20,9 +21,30 @@ from pathlib import Path
|
|
|
20
21
|
_RANK = {"failed": 3, "todo": 2, "passed": 1}
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
"""
|
|
25
|
-
|
|
24
|
+
class Run(NamedTuple):
|
|
25
|
+
"""What the shards on disk add up to, and what had to be set aside to get there.
|
|
26
|
+
|
|
27
|
+
`stale` and `unattributed` are as much a part of the answer as `proven`: a
|
|
28
|
+
reader who cannot see that evidence was discarded cannot tell it from evidence
|
|
29
|
+
that never existed, and that is the failure this whole module guards.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
#: `{'feature/AC-nn': 'passed' | 'failed' | 'todo'}`.
|
|
33
|
+
proven: dict
|
|
34
|
+
#: `{'feature/AC-nn': {runner, …}}` — what KIND of test claimed each one.
|
|
35
|
+
runners: dict
|
|
36
|
+
#: The shard filenames actually read.
|
|
37
|
+
shards: list
|
|
38
|
+
#: Source paths named by a shard that is no longer in the tree. Their claims
|
|
39
|
+
#: are not in `proven`.
|
|
40
|
+
stale: list
|
|
41
|
+
#: Shards that never say what they ran. Their claims ARE in `proven` — there
|
|
42
|
+
#: is nothing to check them against — and they are named so a reader knows it.
|
|
43
|
+
unattributed: list
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_run(repo: Path) -> Run:
|
|
47
|
+
"""Every shard's verdict on every criterion, merged.
|
|
26
48
|
|
|
27
49
|
The merge applies `failed > todo > passed` ACROSS shards, because a promise
|
|
28
50
|
is one behaviour however many runners touch it: proven here and failing
|
|
@@ -34,30 +56,45 @@ def _load_run(repo: Path) -> tuple[dict, dict, list]:
|
|
|
34
56
|
`playwright.json`. A criterion bound at both levels reported whichever
|
|
35
57
|
runner sorted last, which is the one thing this file exists not to do.
|
|
36
58
|
|
|
59
|
+
A SHARD OUTLIVES THE TEST THAT WROTE IT, so one whose `source` is no longer
|
|
60
|
+
in the tree is set aside rather than read. It is a result about code this
|
|
61
|
+
checkout does not have, and nothing rewrites it: a renamed or deleted spec
|
|
62
|
+
would otherwise go on claiming `passed` forever, which is the same fault a
|
|
63
|
+
`// Covers:` comment is banned for. The merge above saves only the
|
|
64
|
+
contradicted case — a criterion with a stale pass AND a live failure reads
|
|
65
|
+
as failing — and does nothing at all for one whose only record is stale.
|
|
66
|
+
|
|
37
67
|
The runners are kept because they say what KIND of test claimed a criterion:
|
|
38
68
|
a browser suite proves what is on screen, a unit runner proves a value. Which
|
|
39
69
|
vitest suite is `unit` and which is `integration` is not derivable here, and is
|
|
40
70
|
not guessed at — see `AC_LEVELS`. `_wrong_level` is the only caller that needs
|
|
41
|
-
them, and it is why this returns
|
|
42
|
-
|
|
71
|
+
them, and it is why this returns them at all: a second reader that tracked
|
|
72
|
+
runners separately is exactly the drift this module ended.
|
|
43
73
|
"""
|
|
44
|
-
|
|
45
|
-
runners: dict = {}
|
|
46
|
-
shards: list = []
|
|
74
|
+
read = Run({}, {}, [], [], [])
|
|
47
75
|
d = repo / ".work" / "coverage"
|
|
48
76
|
if not d.is_dir():
|
|
49
|
-
return
|
|
77
|
+
return read
|
|
50
78
|
for p in sorted(d.glob("*.json")):
|
|
51
79
|
try:
|
|
52
80
|
data = json.loads(p.read_text())
|
|
53
81
|
except Exception:
|
|
54
82
|
continue
|
|
55
|
-
|
|
83
|
+
source = data.get("source")
|
|
84
|
+
if not isinstance(source, str) or source == "":
|
|
85
|
+
# COUNTED, AND NAMED. A runner that does not write a source cannot be
|
|
86
|
+
# checked, and dropping it would throw away real evidence to punish a
|
|
87
|
+
# writer. Saying so is what lets somebody fix the writer.
|
|
88
|
+
read.unattributed.append(p.name)
|
|
89
|
+
elif not (repo / source).exists():
|
|
90
|
+
read.stale.append(source)
|
|
91
|
+
continue
|
|
92
|
+
read.shards.append(p.name)
|
|
56
93
|
runner = data.get("runner") or "unknown"
|
|
57
94
|
for cid, rec in (data.get("covered") or {}).items():
|
|
58
95
|
status = rec.get("status") if isinstance(rec, dict) else str(rec)
|
|
59
|
-
runners.setdefault(cid, set()).add(runner)
|
|
60
|
-
prior =
|
|
96
|
+
read.runners.setdefault(cid, set()).add(runner)
|
|
97
|
+
prior = read.proven.get(cid)
|
|
61
98
|
if prior is None or _RANK.get(status, 0) > _RANK.get(prior, 0):
|
|
62
|
-
|
|
63
|
-
return
|
|
99
|
+
read.proven[cid] = status
|
|
100
|
+
return read
|
package/harness/test_work.py
CHANGED
|
@@ -1008,7 +1008,10 @@ def test_epic_covers_must_be_feature_qualified():
|
|
|
1008
1008
|
|
|
1009
1009
|
def _shards(tmp: str, **byname: dict) -> Path:
|
|
1010
1010
|
"""A repo with `.work/coverage/` holding one shard per keyword — the file
|
|
1011
|
-
NAME matters, since the merge used to depend on the order they sort in.
|
|
1011
|
+
NAME matters, since the merge used to depend on the order they sort in.
|
|
1012
|
+
|
|
1013
|
+
A `source` key is written only where a test asks for one, so the merge tests
|
|
1014
|
+
above stay about the merge."""
|
|
1012
1015
|
d = Path(tmp) / ".work" / "coverage"
|
|
1013
1016
|
d.mkdir(parents=True)
|
|
1014
1017
|
for name, covered in byname.items():
|
|
@@ -1016,6 +1019,16 @@ def _shards(tmp: str, **byname: dict) -> Path:
|
|
|
1016
1019
|
return Path(tmp)
|
|
1017
1020
|
|
|
1018
1021
|
|
|
1022
|
+
def _ran(repo: Path, name: str, source: str, covered: dict) -> Path:
|
|
1023
|
+
"""One shard that names the file it ran. Returns that file's path so a test
|
|
1024
|
+
can create it, or rename it away."""
|
|
1025
|
+
d = repo / ".work" / "coverage"
|
|
1026
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
1027
|
+
(d / f"{name}.json").write_text(
|
|
1028
|
+
json.dumps({"runner": "vitest", "source": source, "covered": covered}))
|
|
1029
|
+
return repo / source
|
|
1030
|
+
|
|
1031
|
+
|
|
1019
1032
|
def test_a_todo_in_one_shard_beats_a_pass_in_another():
|
|
1020
1033
|
# The masking bug: a criterion driven at both levels — a passing vitest
|
|
1021
1034
|
# suite AND a Playwright `test.fixme` — is HALF covered, and half is not
|
|
@@ -1029,9 +1042,9 @@ def test_a_todo_in_one_shard_beats_a_pass_in_another():
|
|
|
1029
1042
|
# Three values since the level model landed: the merged statuses, the
|
|
1030
1043
|
# RUNNERS that claimed each id, and the shards read. This call site was
|
|
1031
1044
|
# left unpacking two and nothing caught it — `scripts/` is in no gate.
|
|
1032
|
-
|
|
1033
|
-
assert
|
|
1034
|
-
assert len(shards) == 2
|
|
1045
|
+
read = coverage._load_run(repo)
|
|
1046
|
+
assert read.proven["courses/AC-01"] == "todo", read.proven
|
|
1047
|
+
assert len(read.shards) == 2
|
|
1035
1048
|
|
|
1036
1049
|
|
|
1037
1050
|
def test_a_failure_anywhere_outranks_everything():
|
|
@@ -1066,6 +1079,59 @@ def test_a_covers_with_no_tests_never_promotes_a_criterion():
|
|
|
1066
1079
|
assert coverage._load_run(repo)[0]["x/AC-01"] == "passed"
|
|
1067
1080
|
|
|
1068
1081
|
|
|
1082
|
+
def test_a_shard_whose_test_file_is_gone_proves_nothing():
|
|
1083
|
+
# A RENAME, made deliberately, on a fixture — never on this tree's history.
|
|
1084
|
+
# The shard is untouched by the rename and goes on saying `passed`; nothing
|
|
1085
|
+
# will ever rewrite it, because the run that would is gone with the file.
|
|
1086
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1087
|
+
repo = Path(tmp)
|
|
1088
|
+
was = _ran(repo, "vitest-picker", "tests/picker.test.ts",
|
|
1089
|
+
{"projects/AC-01": {"status": "passed"}})
|
|
1090
|
+
was.parent.mkdir(parents=True, exist_ok=True)
|
|
1091
|
+
was.write_text("")
|
|
1092
|
+
assert coverage._load_run(repo).proven["projects/AC-01"] == "passed"
|
|
1093
|
+
|
|
1094
|
+
was.rename(was.parent / "chooser.test.ts")
|
|
1095
|
+
read = coverage._load_run(repo)
|
|
1096
|
+
assert "projects/AC-01" not in read.proven, read.proven
|
|
1097
|
+
assert read.shards == []
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def test_what_was_set_aside_is_reported_rather_than_dropped():
|
|
1101
|
+
# A reader who cannot see that evidence was discarded cannot tell it from
|
|
1102
|
+
# evidence that never existed, and would go looking for a test to write
|
|
1103
|
+
# rather than a suite to re-run.
|
|
1104
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1105
|
+
repo = _shards(tmp, doctest={"engine/AC-02": {"status": "passed"}})
|
|
1106
|
+
_ran(repo, "vitest-picker", "tests/picker.test.ts",
|
|
1107
|
+
{"projects/AC-01": {"status": "passed"}})
|
|
1108
|
+
read = coverage._load_run(repo)
|
|
1109
|
+
|
|
1110
|
+
assert read.stale == ["tests/picker.test.ts"]
|
|
1111
|
+
# The one with no source at all is COUNTED — there is nothing to check it
|
|
1112
|
+
# against, and dropping it would throw away real evidence to punish a
|
|
1113
|
+
# writer — but it is named, so the writer can be fixed.
|
|
1114
|
+
assert read.unattributed == ["doctest.json"]
|
|
1115
|
+
assert read.proven["engine/AC-02"] == "passed"
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def test_a_live_failure_is_not_argued_away_by_a_stale_pass():
|
|
1119
|
+
# The merge already handled this one, and it is the ONLY case it handled: a
|
|
1120
|
+
# criterion whose sole record is a stale pass had nothing to contradict it.
|
|
1121
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1122
|
+
repo = Path(tmp)
|
|
1123
|
+
live = _ran(repo, "playwright-bar", "e2e/bar.spec.ts",
|
|
1124
|
+
{"daw/AC-16": {"status": "failed"}})
|
|
1125
|
+
live.parent.mkdir(parents=True, exist_ok=True)
|
|
1126
|
+
live.write_text("")
|
|
1127
|
+
_ran(repo, "playwright-old-bar", "old/bar.spec.ts",
|
|
1128
|
+
{"daw/AC-16": {"status": "passed"}})
|
|
1129
|
+
|
|
1130
|
+
read = coverage._load_run(repo)
|
|
1131
|
+
assert read.proven["daw/AC-16"] == "failed"
|
|
1132
|
+
assert read.stale == ["old/bar.spec.ts"]
|
|
1133
|
+
|
|
1134
|
+
|
|
1069
1135
|
# ---------------------------------------------------------------------------
|
|
1070
1136
|
# Ported from the second fork. Everything below covers a capability that fork
|
|
1071
1137
|
# had and this base did not: domain hosting, judgement tallying, run-aware
|
|
@@ -2733,6 +2799,83 @@ def test_a_verify_run_is_visible_to_the_next_caller():
|
|
|
2733
2799
|
gate.VERIFY = old
|
|
2734
2800
|
|
|
2735
2801
|
|
|
2802
|
+
def _said(capsys_free_call) -> dict:
|
|
2803
|
+
"""The JSON one of the async doors printed, as a dict."""
|
|
2804
|
+
import io
|
|
2805
|
+
from contextlib import redirect_stdout
|
|
2806
|
+
|
|
2807
|
+
buf = io.StringIO()
|
|
2808
|
+
with redirect_stdout(buf):
|
|
2809
|
+
capsys_free_call()
|
|
2810
|
+
for line in reversed(buf.getvalue().splitlines()):
|
|
2811
|
+
if line.strip().startswith("{"):
|
|
2812
|
+
return json.loads(line)
|
|
2813
|
+
raise AssertionError(f"no JSON on stdout: {buf.getvalue()!r}")
|
|
2814
|
+
|
|
2815
|
+
|
|
2816
|
+
def test_asking_where_the_gates_stand_never_starts_them():
|
|
2817
|
+
# Asking and starting used to be ONE call whose meaning depended on timing, so a
|
|
2818
|
+
# session polling for a result set off the four-minute run it was waiting for.
|
|
2819
|
+
# Polling is the loop and starting happens once, so the repeatable call is the
|
|
2820
|
+
# one that has to be unable to cause anything.
|
|
2821
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2822
|
+
v = _tree(tmp)
|
|
2823
|
+
e = _epic(v, "an-epic")
|
|
2824
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
2825
|
+
with _work_dir(tmp) as root:
|
|
2826
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
2827
|
+
try:
|
|
2828
|
+
out = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
2829
|
+
assert out["state"] == "idle", out
|
|
2830
|
+
# The real assertion: nothing ran, and nothing is running.
|
|
2831
|
+
assert gate.read_run(root) is None, "asking wrote a run file"
|
|
2832
|
+
assert gate.live_run(root) is None
|
|
2833
|
+
finally:
|
|
2834
|
+
gate.VERIFY = old
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
def test_starting_while_a_run_is_live_names_whose_it_is_rather_than_attaching():
|
|
2838
|
+
# Attaching is what let one call report a run started for a DIFFERENT task,
|
|
2839
|
+
# whose verdict would then have been read against this one's commit.
|
|
2840
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2841
|
+
v = _tree(tmp)
|
|
2842
|
+
e = _epic(v, "an-epic")
|
|
2843
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
2844
|
+
_task(e / "in-progress", "beta", body="# T\n")
|
|
2845
|
+
with _work_dir(tmp) as root:
|
|
2846
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
2847
|
+
try:
|
|
2848
|
+
gate._write_run(root, {"task": "alpha", "pid": os.getpid(),
|
|
2849
|
+
"machine": "here", "started": gate._now().isoformat(),
|
|
2850
|
+
"gates": ["tests"], "current": "tests", "done": [],
|
|
2851
|
+
"finished": None, "read": False})
|
|
2852
|
+
out = _said(lambda: gate.cmd_verify({"task": "beta", "async": True}))
|
|
2853
|
+
assert out["state"] == "running", out
|
|
2854
|
+
assert "alpha" in out["message"], out["message"]
|
|
2855
|
+
finally:
|
|
2856
|
+
gate.VERIFY = old
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def test_a_finished_run_for_another_task_is_not_handed_over_as_this_ones():
|
|
2860
|
+
# The wrong-verdict half. A result proven for one item says nothing about
|
|
2861
|
+
# another, and reporting it would put a green gate on work nobody ran.
|
|
2862
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2863
|
+
v = _tree(tmp)
|
|
2864
|
+
e = _epic(v, "an-epic")
|
|
2865
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
2866
|
+
_task(e / "in-progress", "beta", body="# T\n")
|
|
2867
|
+
with _work_dir(tmp) as root:
|
|
2868
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
2869
|
+
try:
|
|
2870
|
+
gate.cmd_verify({"task": "alpha"})
|
|
2871
|
+
assert gate.read_run(root)["passed"] is True
|
|
2872
|
+
out = _said(lambda: gate.cmd_verify({"task": "beta", "status": True}))
|
|
2873
|
+
assert out["state"] == "idle", out
|
|
2874
|
+
assert out["passed"] is False, out
|
|
2875
|
+
finally:
|
|
2876
|
+
gate.VERIFY = old
|
|
2877
|
+
|
|
2878
|
+
|
|
2736
2879
|
def test_a_second_verify_is_refused_while_one_is_running():
|
|
2737
2880
|
with tempfile.TemporaryDirectory() as tmp:
|
|
2738
2881
|
v = _tree(tmp)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"@jarvis/rpc": "1.0.0",
|
|
66
66
|
"@jarvis/types": "1.0.0",
|
|
67
67
|
"@jarvis/typescript-config": "1.0.0",
|
|
68
|
-
"@jarvis/
|
|
69
|
-
"@jarvis/
|
|
68
|
+
"@jarvis/vitest-config": "1.0.0",
|
|
69
|
+
"@jarvis/ui": "0.1.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|