riskrails 3.0.1 → 3.2.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +29 -15
- package/ai/policies/model-routing.md +7 -7
- package/ai/policies/production.md +3 -3
- package/ai/policies/release.md +2 -2
- package/ai/policies/review-economy.md +27 -30
- package/bin/riskrails.js +114 -23
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +15 -12
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/common.py +2 -0
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +150 -37
- package/hooks/project-scaffold.py +3 -6
- package/hooks/runtime-gate.py +134 -33
- package/hooks/update_notice.py +22 -16
- package/instructions/routing.md +1 -1
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/skills/AGENTS.md +1 -1
- package/skills/CLAUDE.md +1 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/security.md +7 -1
- package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-init/templates/AGENTS.block.md +2 -2
- package/skills/ai-init/templates/CLAUDE.block.md +2 -2
- package/skills/ai-init/templates/GEMINI.block.md +2 -2
- package/skills/ai-init/templates/gitignore.snippet +1 -0
- package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +37 -4
- package/skills/ai-task/state.py +24 -5138
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
- package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
- package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
- package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
- package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
- package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
- package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
- package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
- package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
- package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
- package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
- package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
- package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
- package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
- package/skills/project-update/history/index.json +38 -16
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/migrations/__init__.py +6 -1
- package/skills/project-update/render_instructions.py +1 -1
- package/skills/project-update/update.py +569 -106
- package/skills/rr-build-all/SKILL.md +2 -2
- package/skills/rr-plan/SKILL.md +3 -1
- package/skills/rr-review/SKILL.md +13 -8
- package/skills/rr-setup/SKILL.md +24 -4
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/status.py +36 -9
- package/skills/usage-report/usage-report.py +4 -2
- package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
- package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""task: the task record — its schema, defaults, load and save, the task id, the steps and their order, and the old-surface routing.
|
|
2
|
+
|
|
3
|
+
core: TASK_COMMANDS, apply_defaults, TASK_ID_RE, open_gate, SCHEMA, is_v7, apply_defaults_v7, task_files, _overlap, order_shared_files, ready_steps, old_surface_refusal
|
|
4
|
+
shell: load, claim_runtime, save, sealed_task_ids, next_task_id, valid_task_id, load_v7, parse_steps, check_steps, abandon_old, route_surface, in_flight
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=protected-access # parse_steps calls fs._split_files, which partition.json assigns to fs
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from rr_state import fs
|
|
14
|
+
from rr_state import journal
|
|
15
|
+
from rr_state import runtime as rr_runtime
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import weight # pure computation; it writes nothing
|
|
19
|
+
except ImportError:
|
|
20
|
+
weight = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The commands that work on the task in flight; a task the old surface started
|
|
24
|
+
# refuses them all but close --abandon (route_surface).
|
|
25
|
+
TASK_COMMANDS = {"step-done", "ask", "answer", "questions", "approve", "reject", "close", "verify",
|
|
26
|
+
"review", "steps", "batch"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def apply_defaults(state):
|
|
30
|
+
"""Schema 2 keys (I6) in memory, so a v1 state answers every command before
|
|
31
|
+
migration 0002 has run; save() then persists them. A reader that opens
|
|
32
|
+
the state file directly — update.py does — sees the file, not these."""
|
|
33
|
+
if is_v7(state):
|
|
34
|
+
return apply_defaults_v7(state)
|
|
35
|
+
task_id = state.get("task_id") or ""
|
|
36
|
+
state.setdefault("owner_runtime", None)
|
|
37
|
+
state.setdefault("resume_point", None)
|
|
38
|
+
# A hand-edited or half-migrated file may hold null where v1 held an object.
|
|
39
|
+
# load() promises to be loud about a file it cannot read and silent about one
|
|
40
|
+
# it can, so the defaults are total rather than assuming the v1 shape.
|
|
41
|
+
if not isinstance(state.get("questions"), dict):
|
|
42
|
+
state["questions"] = {"file": ".ai/reports/%s/task.md" % task_id, "pending": []}
|
|
43
|
+
if not isinstance(state.get("handoff"), dict):
|
|
44
|
+
state["handoff"] = {"file": ".ai/state/handoff.md", "written_at": None, "reason": None}
|
|
45
|
+
if not isinstance(state.get("human_approval"), dict):
|
|
46
|
+
state["human_approval"] = {"required": True, "granted": False,
|
|
47
|
+
"granted_by": None, "granted_at": None}
|
|
48
|
+
for key, default in (("requested_at", None), ("gate_id", None), ("requested_session", None),
|
|
49
|
+
("rejected_at", None), ("via", None), ("unattended", False)):
|
|
50
|
+
state["human_approval"].setdefault(key, default)
|
|
51
|
+
# Schema 4 (WP4 I2): a task that started before the gates existed has no
|
|
52
|
+
# tree to measure from, and one cannot be invented afterwards.
|
|
53
|
+
if not isinstance(state.get("diff"), dict):
|
|
54
|
+
state["diff"] = {}
|
|
55
|
+
state["diff"].setdefault("base_commit", None)
|
|
56
|
+
state["diff"].setdefault("base_tree", None)
|
|
57
|
+
# Schema 5 (WP5 I4): a task handed to the other runtime, and a review asked of it.
|
|
58
|
+
state["handoff"].setdefault("pending_to", None)
|
|
59
|
+
state["handoff"].setdefault("pending_since", None)
|
|
60
|
+
state.setdefault("cross_vendor_review", None)
|
|
61
|
+
return state
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load(root, required=True, claim=None, rt=rr_runtime.Runtime()):
|
|
65
|
+
path = fs.state_path(root)
|
|
66
|
+
if not os.path.exists(path):
|
|
67
|
+
if required:
|
|
68
|
+
fs.die("no task in flight (%s does not exist). Start one with state.py start." % path)
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
with open(path, encoding="utf-8") as fh:
|
|
72
|
+
data = json.load(fh)
|
|
73
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
74
|
+
# Loud on purpose: a corrupt state file must never be silently replaced,
|
|
75
|
+
# because ai-scope-guard's boundaries are derived from it.
|
|
76
|
+
fs.die("%s is unreadable (%s). Inspect it by hand; do not delete it blindly." % (path, exc))
|
|
77
|
+
state = apply_defaults(data)
|
|
78
|
+
# Ownership is taken before the command's own events, so the journal and
|
|
79
|
+
# history both show the handoff ahead of the work it covers.
|
|
80
|
+
if rt.mutating if claim is None else claim:
|
|
81
|
+
claim_runtime(root, state, rt=rt)
|
|
82
|
+
return state
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def claim_runtime(root, state, rt=rr_runtime.Runtime()):
|
|
86
|
+
"""Called from load() for the commands in MUTATING_COMMANDS, so ownership is
|
|
87
|
+
taken once rather than in fifteen call sites. A task that changes runtime says
|
|
88
|
+
so in the journal; WP5 is what will move a task on purpose."""
|
|
89
|
+
pending = (state.get("handoff") or {}).get("pending_to")
|
|
90
|
+
if pending in rr_runtime.RUNTIMES:
|
|
91
|
+
if rt.name != pending:
|
|
92
|
+
back = ("state.py handoff --to %s" % rt.name if rt.name in rr_runtime.RUNTIMES else
|
|
93
|
+
"state.py --runtime %s handoff --to %s (this shell is neither runtime; "
|
|
94
|
+
"name the one taking it)" % ((rr_runtime.other_runtime(pending, rt=rt),) * 2))
|
|
95
|
+
fs.die("RUNTIME_HANDOFF_PENDING: task %s was handed to %s at %s. Resume it there "
|
|
96
|
+
"(/rr-build), or take it back here with: %s"
|
|
97
|
+
% (state.get("task_id"), pending, state["handoff"].get("pending_since"), back), 7)
|
|
98
|
+
# The receiving runtime's first change: the handoff is complete. The
|
|
99
|
+
# move itself was journaled when it was made, so there is no second event.
|
|
100
|
+
state["handoff"]["pending_to"] = None
|
|
101
|
+
state["handoff"]["pending_since"] = None
|
|
102
|
+
previous = state.get("owner_runtime")
|
|
103
|
+
if rt.name not in rr_runtime.RUNTIMES or previous == rt.name:
|
|
104
|
+
return
|
|
105
|
+
if previous in rr_runtime.RUNTIMES:
|
|
106
|
+
journal.emit(root, state, "runtime_handoff", "%s -> %s" % (previous, rt.name),
|
|
107
|
+
{"from": previous, "to": rt.name, "via": "resume"}, rt=rt)
|
|
108
|
+
state["owner_runtime"] = rt.name
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def save(root, state):
|
|
112
|
+
state["updated_at"] = fs.now()
|
|
113
|
+
path = fs.state_path(root)
|
|
114
|
+
try:
|
|
115
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
116
|
+
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
117
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
118
|
+
json.dump(state, fh, indent=2, ensure_ascii=False)
|
|
119
|
+
fh.write("\n")
|
|
120
|
+
os.replace(tmp, path)
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
fs.die("cannot write %s (%s) — the state is the contract, so nothing was recorded" % (path, exc))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sealed_task_ids(root):
|
|
126
|
+
"""The task ids a package under .ai/memory/ seals; empty in files mode."""
|
|
127
|
+
found = set()
|
|
128
|
+
base = os.path.join(root, ".ai", "memory")
|
|
129
|
+
for name in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
|
130
|
+
try:
|
|
131
|
+
with open(os.path.join(base, name), "rb") as fh:
|
|
132
|
+
for raw in fh:
|
|
133
|
+
if b'"kind":"task"' not in raw:
|
|
134
|
+
continue
|
|
135
|
+
with contextlib.suppress(ValueError, UnicodeDecodeError, AttributeError, RecursionError):
|
|
136
|
+
found.add(json.loads(raw.decode("utf-8")).get("task"))
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
return found
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def next_task_id(root, goal="", slug=None, at=None):
|
|
143
|
+
"""R1: T-YYYY-MM-DD-HHMM-<slug>, from the clock and the goal alone. A count
|
|
144
|
+
of the day's report directories gave two clones the same id and handed a
|
|
145
|
+
deleted directory's id to the next task. A local hit — a report directory,
|
|
146
|
+
the raw store under .ai/state/logs/, a sealed key — gets -2 … -9."""
|
|
147
|
+
import memory_format # pylint: disable=import-outside-toplevel
|
|
148
|
+
stamp = at or datetime.now(timezone.utc)
|
|
149
|
+
base = slug or memory_format.slug_of(goal)
|
|
150
|
+
sealed = sealed_task_ids(root)
|
|
151
|
+
for n in range(1, 10):
|
|
152
|
+
task_id = memory_format.new_task_id(stamp, memory_format.with_suffix(base, n))
|
|
153
|
+
if not (os.path.exists(os.path.join(root, ".ai", "reports", task_id))
|
|
154
|
+
or os.path.exists(os.path.join(root, ".ai", "state", "logs", task_id)) or task_id in sealed):
|
|
155
|
+
return task_id
|
|
156
|
+
fs.die("%s and -2 … -9 are all taken this minute: start again with another --slug \"<3-24 characters>\""
|
|
157
|
+
% memory_format.new_task_id(stamp, base), 2)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def valid_task_id(task_id, flag="--task-id"):
|
|
165
|
+
"""A task id is a directory name under .ai/reports/, and everything the
|
|
166
|
+
journal and the questions file are worth rests on it staying one. A '/' or
|
|
167
|
+
a '..' in it puts both outside the project — and outside the path guard's
|
|
168
|
+
`.ai/reports/<id>/` patterns, which is how a writable task.md, and
|
|
169
|
+
with it a self-granted gate, would come back."""
|
|
170
|
+
if not isinstance(task_id, str) or not TASK_ID_RE.match(task_id) or ".." in task_id:
|
|
171
|
+
fs.die("%s takes a directory name — letters, digits, '.', '_' and '-', "
|
|
172
|
+
"no path separators — not %r" % (flag, task_id))
|
|
173
|
+
return task_id
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def open_gate(state):
|
|
177
|
+
"""The one signal that a gate is open, and the one thing that names it.
|
|
178
|
+
|
|
179
|
+
R11 words this as "a gate_requested event exists", and the first draft read
|
|
180
|
+
the journal for one. It must not: events.jsonl is append-only, unprotected,
|
|
181
|
+
and keyed on a caller-supplied task id, so a single hand-written line — or
|
|
182
|
+
an `init --task-id` that reuses an old id — satisfied the precondition. The
|
|
183
|
+
state is the contract and the path guard protects it; `stage human_approval`
|
|
184
|
+
is the only writer, and approve/reject consume what it wrote.
|
|
185
|
+
"""
|
|
186
|
+
approval = state.get("human_approval") or {}
|
|
187
|
+
if approval.get("requested_at") and approval.get("gate_id"):
|
|
188
|
+
return approval["gate_id"]
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
SCHEMA = 7
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_v7(state):
|
|
196
|
+
return isinstance(state, dict) and state.get("schema") == SCHEMA
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def apply_defaults_v7(state):
|
|
200
|
+
"""The keys every shared helper reads (questions, handoff, the gate) and the
|
|
201
|
+
schema-7 ones; none of the old surface's own keys, so a schema-7 file holds
|
|
202
|
+
only what its commands use."""
|
|
203
|
+
task_id = state.get("task_id") or ""
|
|
204
|
+
state.setdefault("owner_runtime", None)
|
|
205
|
+
state.setdefault("resume_point", None)
|
|
206
|
+
state.setdefault("cross_vendor_review", None)
|
|
207
|
+
if not isinstance(state.get("questions"), dict):
|
|
208
|
+
state["questions"] = {"file": ".ai/reports/%s/task.md" % task_id, "pending": []}
|
|
209
|
+
if not isinstance(state.get("handoff"), dict):
|
|
210
|
+
state["handoff"] = {"file": ".ai/state/handoff.md", "written_at": None, "reason": None}
|
|
211
|
+
state["handoff"].setdefault("pending_to", None)
|
|
212
|
+
state["handoff"].setdefault("pending_since", None)
|
|
213
|
+
if not isinstance(state.get("human_approval"), dict):
|
|
214
|
+
state["human_approval"] = {"required": False, "granted": False,
|
|
215
|
+
"granted_by": None, "granted_at": None}
|
|
216
|
+
for key, default in (("requested_at", None), ("gate_id", None), ("requested_session", None),
|
|
217
|
+
("rejected_at", None), ("via", None), ("unattended", False)):
|
|
218
|
+
state["human_approval"].setdefault(key, default)
|
|
219
|
+
for key, default in (("files", []), ("tests", []), ("steps", []), ("amendments", []),
|
|
220
|
+
("findings", []), ("diff", {}), ("gate", None), ("mode_override", None)):
|
|
221
|
+
if not isinstance(state.get(key), type(default)) and not (default is None and key in state):
|
|
222
|
+
state[key] = default
|
|
223
|
+
for step in state["steps"]:
|
|
224
|
+
if not isinstance(step.get("after"), list):
|
|
225
|
+
step["after"] = [] # a step from before the batch loop depends on nothing
|
|
226
|
+
if not isinstance(state.get("batch"), dict):
|
|
227
|
+
state["batch"] = {"on": False, "by": None, "why": None, "since": None}
|
|
228
|
+
if not isinstance(state.get("verify"), dict):
|
|
229
|
+
state["verify"] = {}
|
|
230
|
+
for key, default in (("status", "not_run"), ("e2e", "not_run"), ("suite_runs", 0), ("runs", [])):
|
|
231
|
+
state["verify"].setdefault(key, default)
|
|
232
|
+
if not isinstance(state.get("review"), dict):
|
|
233
|
+
state["review"] = {}
|
|
234
|
+
for key, default in (("status", "not_started"), ("passes", 0), ("tier", None),
|
|
235
|
+
("findings", []), ("tree", None), ("log", []), ("closed", [])):
|
|
236
|
+
state["review"].setdefault(key, default)
|
|
237
|
+
return state
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load_v7(root, rt=rr_runtime.Runtime()):
|
|
241
|
+
state = load(root, rt=rt)
|
|
242
|
+
if not is_v7(state):
|
|
243
|
+
fs.die(old_surface_refusal(state))
|
|
244
|
+
return state
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def task_files(state):
|
|
248
|
+
"""Every file and glob the task may touch: its files, its tests, every step's."""
|
|
249
|
+
out = list(state.get("files") or []) + list(state.get("tests") or [])
|
|
250
|
+
for step in state.get("steps") or []:
|
|
251
|
+
out += list(step.get("files") or []) + list(step.get("tests") or [])
|
|
252
|
+
return list(dict.fromkeys(out))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def parse_steps(text, plan_file):
|
|
256
|
+
"""--steps "id:a,b:t;id2:c" or --plan-file F.json ([{id, files, tests, after}]);
|
|
257
|
+
`after` (plan-file only) names the steps this one waits for (R2)."""
|
|
258
|
+
raw = []
|
|
259
|
+
if plan_file:
|
|
260
|
+
try:
|
|
261
|
+
with open(plan_file, encoding="utf-8") as fh:
|
|
262
|
+
raw = json.load(fh)
|
|
263
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
264
|
+
fs.die("--plan-file %s: %s" % (plan_file, exc))
|
|
265
|
+
if not isinstance(raw, list) or not all(isinstance(s, dict) for s in raw):
|
|
266
|
+
fs.die("--plan-file holds a JSON list of {\"id\", \"files\", \"tests\", \"after\"}")
|
|
267
|
+
for s in raw:
|
|
268
|
+
after = s.get("after") or []
|
|
269
|
+
if not isinstance(after, list) or not all(isinstance(a, str) and a.strip() for a in after):
|
|
270
|
+
fs.die("step %s: \"after\" is a JSON list of step ids" % (s.get("id") or "?"))
|
|
271
|
+
raw = [(str(s.get("id") or ""), ",".join(s.get("files") or []), ",".join(s.get("tests") or []),
|
|
272
|
+
[fs.one_line(a, 40) for a in s.get("after") or []]) for s in raw]
|
|
273
|
+
for chunk in [c for c in (text or "").split(";") if c.strip()]:
|
|
274
|
+
parts = chunk.split(":")
|
|
275
|
+
raw.append((parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "", []))
|
|
276
|
+
steps = []
|
|
277
|
+
for step_id, files, tests, after in raw:
|
|
278
|
+
step_id = fs.one_line(step_id, 40)
|
|
279
|
+
if not step_id or not fs._split_files(files):
|
|
280
|
+
fs.die("a step is id:files[:tests], with at least one file: %r" % ":".join((step_id, files, tests)))
|
|
281
|
+
if step_id in [s["id"] for s in steps]:
|
|
282
|
+
fs.die("step id %s appears twice" % step_id)
|
|
283
|
+
steps.append({"id": step_id, "files": fs._split_files(files), "tests": fs._split_files(tests),
|
|
284
|
+
"after": list(dict.fromkeys(after)), "status": "pending", "tree_before": None,
|
|
285
|
+
"tree_after": None})
|
|
286
|
+
return steps
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _overlap(a, b):
|
|
290
|
+
"""One path or pattern covers the other: equal once normalised, or one
|
|
291
|
+
matches the other as a glob — so `src/*.py` and `./src/a.py` count as shared."""
|
|
292
|
+
a, b = os.path.normpath(a), os.path.normpath(b)
|
|
293
|
+
return a == b or bool(weight and (weight.match(a, [b]) or weight.match(b, [a])))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def order_shared_files(steps):
|
|
297
|
+
"""Two steps that name one file or pattern — among their files or their
|
|
298
|
+
tests — never run side by side: the later one is put after the earlier (R2).
|
|
299
|
+
Returns the implied edges as (later, earlier, what they share)."""
|
|
300
|
+
implied = []
|
|
301
|
+
for n, later in enumerate(steps):
|
|
302
|
+
for earlier in steps[:n]:
|
|
303
|
+
shared = [f for f in later["files"] + later["tests"]
|
|
304
|
+
if any(_overlap(f, g) for g in earlier["files"] + earlier["tests"])]
|
|
305
|
+
if shared and earlier["id"] not in later["after"]:
|
|
306
|
+
later["after"].append(earlier["id"])
|
|
307
|
+
implied.append((later["id"], earlier["id"], ", ".join(dict.fromkeys(shared))))
|
|
308
|
+
return implied
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_steps(steps, implied=()):
|
|
312
|
+
"""Every `after` names another, existing step, and the steps form no cycle (R2).
|
|
313
|
+
A cycle closed by an implied edge (order_shared_files) names that edge."""
|
|
314
|
+
ids = [s["id"] for s in steps]
|
|
315
|
+
for step in steps:
|
|
316
|
+
for dep in step["after"]:
|
|
317
|
+
if dep == step["id"]:
|
|
318
|
+
fs.die("step %s lists itself in after" % step["id"])
|
|
319
|
+
if dep not in ids:
|
|
320
|
+
fs.die("step %s is after %s, which is not a step (have: %s)" % (step["id"], dep, ", ".join(ids)))
|
|
321
|
+
by_id = {s["id"]: s for s in steps}
|
|
322
|
+
seen = {} # id -> "visiting" | "done"
|
|
323
|
+
|
|
324
|
+
def visit(step_id, trail):
|
|
325
|
+
if seen.get(step_id) == "done":
|
|
326
|
+
return
|
|
327
|
+
if seen.get(step_id) == "visiting":
|
|
328
|
+
loop = trail + [step_id]
|
|
329
|
+
hints = ["%s after %s is implied: they share %s" % edge for edge in implied
|
|
330
|
+
if edge[0] in loop and edge[1] in loop]
|
|
331
|
+
fs.die("the steps' after form a cycle: %s%s" % (
|
|
332
|
+
" -> ".join(loop), " (%s — list the dependent row after the one it needs)" % "; ".join(hints)
|
|
333
|
+
if hints else ""))
|
|
334
|
+
seen[step_id] = "visiting"
|
|
335
|
+
for dep in by_id[step_id]["after"]:
|
|
336
|
+
visit(dep, trail + [step_id])
|
|
337
|
+
seen[step_id] = "done"
|
|
338
|
+
|
|
339
|
+
for step in steps:
|
|
340
|
+
visit(step["id"], [])
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def ready_steps(state):
|
|
344
|
+
"""The steps not yet done whose `after` are all done, in plan order: the next
|
|
345
|
+
wave of /rr-build-all, and the step step-done starts next."""
|
|
346
|
+
steps = state.get("steps") or []
|
|
347
|
+
done = {s["id"] for s in steps if s.get("status") == "done"}
|
|
348
|
+
return [s for s in steps if s.get("status") != "done" and all(d in done for d in s.get("after") or [])]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def old_surface_refusal(state):
|
|
352
|
+
return ("task %s runs on the old surface (schema %s), whose commands were removed in redesign 2.6: "
|
|
353
|
+
"finish it with the plugin version that started it, or drop it with 'state.py close --abandon'"
|
|
354
|
+
% (state.get("task_id"), state.get("schema") or "<7"))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def abandon_old(_args, root, rt=rr_runtime.Runtime()):
|
|
358
|
+
"""close --abandon for a task the old surface started: the record moves to
|
|
359
|
+
.ai/state/logs/<task>/state.json as it is, and nothing else is measured."""
|
|
360
|
+
state = load(root, rt=rt)
|
|
361
|
+
state["result"] = "abandoned"
|
|
362
|
+
state["closed_at"] = fs.now()
|
|
363
|
+
journal.emit(root, state, "task_closed", "abandoned", {"abandoned": True, "surface": "old"}, rt=rt)
|
|
364
|
+
fs.ensure_logs(root, state["task_id"])
|
|
365
|
+
target = os.path.join(fs.logs_dir(root, state["task_id"]), "state.json")
|
|
366
|
+
state["updated_at"] = fs.now()
|
|
367
|
+
tmp = "%s.%d.tmp" % (target, os.getpid())
|
|
368
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
369
|
+
json.dump(state, fh, indent=2, ensure_ascii=False)
|
|
370
|
+
fh.write("\n")
|
|
371
|
+
os.replace(tmp, target)
|
|
372
|
+
with contextlib.suppress(OSError):
|
|
373
|
+
os.remove(fs.handoff_path(root))
|
|
374
|
+
fs.remove_state(root)
|
|
375
|
+
print("%s abandoned (old surface)" % state["task_id"])
|
|
376
|
+
print("state: %s" % os.path.relpath(target, root))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def route_surface(root, args, rt=rr_runtime.Runtime()):
|
|
380
|
+
"""A task the old surface started (schema < 7) is read (status, handoff,
|
|
381
|
+
questions without --sync) or dropped (close --abandon); every command that
|
|
382
|
+
works on it refuses."""
|
|
383
|
+
if args.command not in TASK_COMMANDS:
|
|
384
|
+
return
|
|
385
|
+
state = load(root, required=False, claim=False, rt=rt)
|
|
386
|
+
if state is None or is_v7(state) or (args.command == "questions" and not args.sync):
|
|
387
|
+
return
|
|
388
|
+
if args.command == "close" and args.abandon:
|
|
389
|
+
args.func = abandon_old
|
|
390
|
+
return
|
|
391
|
+
fs.die(old_surface_refusal(state))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def in_flight(root, rt=rr_runtime.Runtime()):
|
|
395
|
+
state = load(root, required=False, claim=False, rt=rt)
|
|
396
|
+
return state if state and state.get("current_stage") != "done" and state.get("task_id") else None
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""verify: the `verify` command -- run one scope's command to the end, record it, re-score; the e2e declarations and the moved-tree detail.
|
|
2
|
+
|
|
3
|
+
shell: moved_detail, run_v7, e2e_na_refusal, e2e_na, e2e_none, cmd_verify
|
|
4
|
+
core: E2E_NONE
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=protected-access # run_v7 calls fs._split_files, which partition.json assigns to fs
|
|
7
|
+
# pylint: disable=unused-argument # uniform cli dispatch: cmd_verify takes rt like every command
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from rr_state import dialog, fs, handoff, journal, labels, report, task
|
|
13
|
+
from rr_state import policy as rr_policy
|
|
14
|
+
from rr_state import runtime as rr_runtime
|
|
15
|
+
|
|
16
|
+
# sensors.py sits beside state.py; the entry puts that directory on sys.path.
|
|
17
|
+
try:
|
|
18
|
+
import sensors # measurement only; it never writes this file
|
|
19
|
+
except ImportError: # an old install that shipped state.py alone
|
|
20
|
+
sensors = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def moved_detail(root, before, after, limit=5):
|
|
24
|
+
"""Lines naming what changed between the two snapshots of a run that moved the tree,
|
|
25
|
+
and saying so when the new paths are untracked (a suite that writes a cache or a
|
|
26
|
+
coverage file moves the tree on every run). [] when the paths cannot be measured."""
|
|
27
|
+
diff = fs.safe_sensor(sensors.diff_files, root, before, after)
|
|
28
|
+
if not diff:
|
|
29
|
+
return []
|
|
30
|
+
paths = [d["path"] for d in diff]
|
|
31
|
+
tracked = set()
|
|
32
|
+
code, out = fs.safe_sensor(sensors.git, root, ["ls-files", "-z", "--"] + paths) or (1, "")
|
|
33
|
+
if code == 0:
|
|
34
|
+
tracked = set(f for f in out.split("\0") if f)
|
|
35
|
+
new = [d["path"] for d in diff if d["status"] == "A" and d["path"] not in tracked]
|
|
36
|
+
def esc(path):
|
|
37
|
+
# A name from `git diff -z` is data: a newline or ESC in it would forge a verify line.
|
|
38
|
+
return "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
|
|
39
|
+
shown = ", ".join("%s (%s)" % (esc(d["path"]), "untracked" if d["path"] in new else
|
|
40
|
+
{"A": "added", "D": "deleted", "T": "type changed"}.get(d["status"], "changed"))
|
|
41
|
+
for d in diff[:limit])
|
|
42
|
+
more = " and %d more" % (len(diff) - limit) if len(diff) > limit else ""
|
|
43
|
+
lines = ["changed during the run: %s%s" % (shown, more)]
|
|
44
|
+
if new:
|
|
45
|
+
lines.append("untracked files written by the suite move the tree on every run: add them to "
|
|
46
|
+
".gitignore or stop the suite writing them")
|
|
47
|
+
return lines
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_v7(root, state, scope, args, tcfg, rt=rr_runtime.Runtime()):
|
|
51
|
+
"""One scope's command, to the end, logged under .ai/state/logs/; returns the run."""
|
|
52
|
+
command = fs.safe_sensor(sensors.command_for, root, scope) or ""
|
|
53
|
+
if not command:
|
|
54
|
+
fs.die("no command for scope '%s' in .ai/policies/testing.md — write it down first" % scope)
|
|
55
|
+
files = fs._split_files(args.files)
|
|
56
|
+
if files:
|
|
57
|
+
command = fs.safe_sensor(sensors.expand_files, command, files) or command
|
|
58
|
+
runs = state["verify"]["runs"]
|
|
59
|
+
n = len(runs) + 1
|
|
60
|
+
fs.ensure_logs(root, state["task_id"])
|
|
61
|
+
log = os.path.join(fs.logs_dir(root, state["task_id"]), "%s-%d.log" % (scope, n))
|
|
62
|
+
before = fs.current_tree(root)
|
|
63
|
+
result = fs.safe_sensor(sensors.run_command, root, command, log, tcfg.get("timeout_seconds", 1800),
|
|
64
|
+
tcfg.get("log_max_bytes", 2097152))
|
|
65
|
+
if result is None:
|
|
66
|
+
fs.die("sensors.py is not installed beside state.py — run the project's command yourself")
|
|
67
|
+
after = fs.current_tree(root)
|
|
68
|
+
# A tree that moved during the run was not the tree the run tested: the run
|
|
69
|
+
# is kept in the record, but stands for no tree at all.
|
|
70
|
+
run = {"n": n, "scope": scope, "exit": result["exit"], "tree": after if after == before else None,
|
|
71
|
+
"tree_moved": after != before,
|
|
72
|
+
"log": os.path.relpath(log, root), "ts": fs.now(), "expected_fail": args.expect == "fail",
|
|
73
|
+
"runtime": rt.name, "command": command, "duration_s": result["duration_s"],
|
|
74
|
+
"after_fix": args.after_fix or None, "files": files}
|
|
75
|
+
runs.append(run)
|
|
76
|
+
if scope == "suite":
|
|
77
|
+
state["verify"]["suite_runs"] += 1
|
|
78
|
+
state["verify"]["status"] = ("passing" if run["exit"] == 0 else
|
|
79
|
+
"env_failure" if run["exit"] is None else "failing")
|
|
80
|
+
elif scope == "e2e":
|
|
81
|
+
state["verify"]["e2e"] = "passing" if run["exit"] == 0 else "failing"
|
|
82
|
+
state["verify"]["e2e_tree"] = run["tree"]
|
|
83
|
+
if run["tree_moved"]:
|
|
84
|
+
print("tree moved during the run: run %d stands for no tree — run it again on a still tree" % n)
|
|
85
|
+
for line in moved_detail(root, before, after):
|
|
86
|
+
print(line)
|
|
87
|
+
journal.emit(root, state, "verify_run", "%s run %d: exit %s" % (scope, n, run["exit"]),
|
|
88
|
+
{k: run[k] for k in ("n", "scope", "exit", "tree", "log", "expected_fail")}, rt=rt)
|
|
89
|
+
if rr_policy.full_suite(run) and run["exit"] is not None and run["tree"]:
|
|
90
|
+
other = [r for r in runs[:-1] if rr_policy.full_suite(r) and r.get("tree") == run["tree"]
|
|
91
|
+
and r.get("runtime") != rt.name and (r.get("exit") == 0) != (run["exit"] == 0)
|
|
92
|
+
and r.get("exit") is not None]
|
|
93
|
+
red, green = (run, other[-1]) if other and run["exit"] != 0 else \
|
|
94
|
+
(other[-1], run) if other else (None, None)
|
|
95
|
+
if red:
|
|
96
|
+
# R19: the same tree green under one runtime and red under the other, in
|
|
97
|
+
# either order, goes into task.md. A red under Codex is best effort and
|
|
98
|
+
# never a gate; a red under any other runtime is the change's, and the
|
|
99
|
+
# green elsewhere does not answer it (two_facts).
|
|
100
|
+
state["findings"].append("%s: suite red under %s (run %d) and green under %s (run %d) on %s"
|
|
101
|
+
% (fs.now(), red.get("runtime"), red["n"], green.get("runtime"),
|
|
102
|
+
green["n"], run["tree"][:9]))
|
|
103
|
+
if red.get("runtime") == "codex":
|
|
104
|
+
print("finding: red under %s on a tree green under %s — recorded in task.md, close is not "
|
|
105
|
+
"blocked" % (red.get("runtime"), green.get("runtime")))
|
|
106
|
+
else:
|
|
107
|
+
print("red under %s on a tree green under %s — recorded in task.md; the red is the change's "
|
|
108
|
+
"and close is blocked until it is green under %s"
|
|
109
|
+
% (red.get("runtime"), green.get("runtime"), red.get("runtime")))
|
|
110
|
+
print("%s run %d: exit %s in %ds" % (scope, n, run["exit"], run["duration_s"]))
|
|
111
|
+
print("command: %s" % command)
|
|
112
|
+
print("log: %s" % run["log"])
|
|
113
|
+
if result.get("detail"):
|
|
114
|
+
print(result["detail"])
|
|
115
|
+
return run
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def e2e_na_refusal(root, state):
|
|
119
|
+
"""Why e2e cannot be declared not applicable now, or None. The last e2e run
|
|
120
|
+
decides, on whatever tree it ran: a red flow is fixed and run again, never
|
|
121
|
+
declared away by an edit and a declaration; a run that could not finish
|
|
122
|
+
(exit None) is run again."""
|
|
123
|
+
runs = [r for r in state["verify"]["runs"] if r.get("scope") == "e2e"]
|
|
124
|
+
if not runs or runs[-1].get("exit") == 0:
|
|
125
|
+
return None
|
|
126
|
+
last, tree = runs[-1], fs.current_tree(root)
|
|
127
|
+
if last.get("exit") is None:
|
|
128
|
+
return ("e2e run %d did not finish: run it again with 'verify --scope e2e' — an environment "
|
|
129
|
+
"failure is not a reason to declare e2e not applicable" % last["n"])
|
|
130
|
+
where = "on this tree" if tree and last.get("tree") == tree else \
|
|
131
|
+
"on %s, and no e2e run since has passed" % (last.get("tree") or "a moving tree")[:9]
|
|
132
|
+
return ("e2e run %d is red %s: a failing flow is fixed and 'verify --scope e2e' run again, not "
|
|
133
|
+
"declared not applicable" % (last["n"], where))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def e2e_na(root, state, why, rt=rr_runtime.Runtime()):
|
|
137
|
+
refusal = e2e_na_refusal(root, state)
|
|
138
|
+
if refusal:
|
|
139
|
+
fs.die(refusal)
|
|
140
|
+
tree = fs.current_tree(root)
|
|
141
|
+
state["verify"]["e2e_tree"] = tree
|
|
142
|
+
state["verify"]["e2e"] = "not_applicable"
|
|
143
|
+
state["verify"]["e2e_why"] = why
|
|
144
|
+
journal.emit(root, state, "verify_run", "e2e not applicable: %s" % fs.one_line(why, 200),
|
|
145
|
+
{"scope": "e2e", "exit": None, "not_applicable": True, "why": why}, rt=rt)
|
|
146
|
+
print("e2e: not applicable — %s" % fs.one_line(why, 200))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
E2E_NONE = "e2e_command: none in .ai/policies/testing.md"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def e2e_none(root):
|
|
153
|
+
return (fs.safe_sensor(sensors.command_for, root, "e2e") or "").strip() == "none"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_verify(args, root, rt=rr_runtime.Runtime()):
|
|
157
|
+
"""Run, record, re-score up; exit 0 green, 1 red, 6 SUITE_BUDGET (R20)."""
|
|
158
|
+
state = task.load_v7(root, rt=rt)
|
|
159
|
+
policy = rr_policy.weight_policy(root)
|
|
160
|
+
tcfg = policy.get("tests") or {}
|
|
161
|
+
scope = args.scope
|
|
162
|
+
if args.expect and (scope != "step" or not args.files):
|
|
163
|
+
fs.die("--expect fail is for one test shown failing before the fix: --scope step --files <test>")
|
|
164
|
+
if args.e2e and (scope not in ("all", "e2e") or args.e2e[0] != "not-applicable"
|
|
165
|
+
or not args.e2e[1].strip()):
|
|
166
|
+
fs.die('--e2e takes: not-applicable "<why the change cannot reach an e2e flow>", with --scope all or e2e')
|
|
167
|
+
if scope == "all" and args.files:
|
|
168
|
+
fs.die("--files narrows one scope; --scope all runs the whole suite and e2e")
|
|
169
|
+
if args.e2e and e2e_na_refusal(root, state):
|
|
170
|
+
fs.die(e2e_na_refusal(root, state)) # before the suite runs, not after (N3)
|
|
171
|
+
max_runs, budget_name = rr_policy.budget(policy, state, "max_suite_runs")
|
|
172
|
+
if scope in ("suite", "all") and state["verify"]["suite_runs"] >= max_runs:
|
|
173
|
+
if not args.by:
|
|
174
|
+
dialog.offer_decision(root, state, "human_set", "Budget",
|
|
175
|
+
"%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d%s. Run it once more?"
|
|
176
|
+
% (dialog.task_facts(root, state), state["verify"]["suite_runs"], max_runs,
|
|
177
|
+
" (%s)" % budget_name if rr_policy.batch_on(state) else ""),
|
|
178
|
+
[{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
|
|
179
|
+
{"kind": "command",
|
|
180
|
+
"label": labels.state_label(root, sys.argv[1:] + ["--by", labels.human_name(root)])}], "verify", rt=rt)
|
|
181
|
+
fs.die("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it "
|
|
182
|
+
"again is not the next move: the human decides. In their own terminal:\n python3 %s "
|
|
183
|
+
"--root %s verify --scope %s --by \"<name>\""
|
|
184
|
+
% (state["verify"]["suite_runs"], max_runs, budget_name, shlex.quote(fs.ENTRY),
|
|
185
|
+
shlex.quote(root), scope), 6)
|
|
186
|
+
via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
|
|
187
|
+
if via is None:
|
|
188
|
+
fs.die("APPROVAL_REFUSED — a run past the suite budget is the human's, from their own terminal."
|
|
189
|
+
"\nRefused because %s." % why, 5)
|
|
190
|
+
journal.emit(root, state, "human_set", "suite run %d past the budget by %s via %s"
|
|
191
|
+
% (state["verify"]["suite_runs"] + 1, args.by, via),
|
|
192
|
+
dict({"field": "suite_runs", "value": state["verify"]["suite_runs"] + 1, "by": args.by,
|
|
193
|
+
"via": via, "unattended": via == "unattended"}, **dialog.pick_data(dialog.DIALOG_PICK[0])),
|
|
194
|
+
actor="human" if via == "terminal" else "agent", rt=rt)
|
|
195
|
+
green_before = rr_policy.suite_green(state)
|
|
196
|
+
state["stage"] = "verify"
|
|
197
|
+
code = 0
|
|
198
|
+
if scope == "e2e" and args.e2e:
|
|
199
|
+
e2e_na(root, state, args.e2e[1], rt=rt)
|
|
200
|
+
elif scope == "e2e" and e2e_none(root):
|
|
201
|
+
e2e_na(root, state, E2E_NONE, rt=rt) # `none` is a declaration, not a command (N1)
|
|
202
|
+
else:
|
|
203
|
+
run = run_v7(root, state, "suite" if scope == "all" else scope, args, tcfg, rt=rt)
|
|
204
|
+
code = run["exit"]
|
|
205
|
+
if args.expect == "fail":
|
|
206
|
+
# exit 1 is a failing test; the runner's own usage error (2) is not, unless
|
|
207
|
+
# testing.md's bite_codes names it (pytest 1,2 — 2 is a collection error)
|
|
208
|
+
run["bites"] = code in (fs.safe_sensor(sensors.bite_codes, root) or (1,))
|
|
209
|
+
print("expected fail: %s" % ("the test fails without the fix — it bites" if run["bites"]
|
|
210
|
+
else "the test passes without the fix — it does not bite yet"))
|
|
211
|
+
code = 0 if run["bites"] else 1
|
|
212
|
+
elif scope == "all" and code == 0:
|
|
213
|
+
if args.e2e:
|
|
214
|
+
e2e_na(root, state, args.e2e[1], rt=rt)
|
|
215
|
+
elif not rr_policy.mode_setting(policy, state["mode"], "e2e", False):
|
|
216
|
+
state["verify"]["e2e"] = "not_required"
|
|
217
|
+
print("e2e: not required in %s mode" % state["mode"])
|
|
218
|
+
elif e2e_none(root):
|
|
219
|
+
e2e_na(root, state, E2E_NONE, rt=rt)
|
|
220
|
+
else:
|
|
221
|
+
code = run_v7(root, state, "e2e", args, tcfg, rt=rt)["exit"]
|
|
222
|
+
elif scope == "all":
|
|
223
|
+
print("e2e: not run — the suite is red")
|
|
224
|
+
rr_policy.rescore_v7(root, state, policy, "verify", rt=rt)
|
|
225
|
+
if not green_before and rr_policy.suite_green(state):
|
|
226
|
+
handoff.write_handoff(root, state, "verify", rt=rt)
|
|
227
|
+
task.save(root, state)
|
|
228
|
+
report.write_task_md(root, state)
|
|
229
|
+
if scope in ("suite", "all"):
|
|
230
|
+
print("suite runs used: %d of %d" % (state["verify"]["suite_runs"], max_runs))
|
|
231
|
+
sys.exit(0 if code == 0 else 1)
|