riskrails 3.1.0 → 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 +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- 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 +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- 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/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""policy: the weight table's reads, the modes and the budgets they set, the documents rule and the weighing, the suite facts, and the next action each state asks for.
|
|
2
|
+
|
|
3
|
+
core: MODES, MODE_TIER, VERDICT_STATUS, mode_for, higher_mode, mode_setting, BATCH_DEFAULTS, BATCH_TIERS, batch_on, next_action_for, full_suite, suite_green, NEVER_DOCUMENTS, is_document, DOCUMENTS_ZEROED, weigh, review_next_action
|
|
4
|
+
shell: weight_policy, batch_policy, budget, added_lines, rescore_weight, rescore_v7, documents_cover
|
|
5
|
+
"""
|
|
6
|
+
import copy
|
|
7
|
+
import os
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from rr_state import fs, journal
|
|
12
|
+
from rr_state import runtime as rr_runtime
|
|
13
|
+
|
|
14
|
+
# weight.py and sensors.py sit beside state.py; the entry puts that directory on sys.path.
|
|
15
|
+
try:
|
|
16
|
+
import sensors # measurement only; it never writes this file
|
|
17
|
+
except ImportError: # an old install that shipped state.py alone
|
|
18
|
+
sensors = None
|
|
19
|
+
try:
|
|
20
|
+
import weight # pure computation; it writes nothing
|
|
21
|
+
except ImportError:
|
|
22
|
+
weight = None
|
|
23
|
+
|
|
24
|
+
MODES = ["light", "guarded", "full", "full_irreversible"]
|
|
25
|
+
# tier_set compat (R18): the old budget tables price a task by its T-label.
|
|
26
|
+
MODE_TIER = {"light": "T2", "guarded": "T4", "full": "T5", "full_irreversible": "T6"}
|
|
27
|
+
|
|
28
|
+
VERDICT_STATUS = {"pass": "passed", "pass_with_findings": "passed_with_findings",
|
|
29
|
+
"blockers_open": "blockers_open"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def weight_policy(root):
|
|
33
|
+
if weight is None:
|
|
34
|
+
fs.die("weight.py is missing beside state.py: reinstall the plugin")
|
|
35
|
+
try:
|
|
36
|
+
return weight.load_policy(root)[0]
|
|
37
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
38
|
+
fs.die("cannot read the weight table: %s" % exc)
|
|
39
|
+
return None # unreachable: die() exits
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mode_for(axes, policy, irreversible=False):
|
|
43
|
+
"""The mode six axis values earn under `policy`'s thresholds — weight.compute's
|
|
44
|
+
rule, applied to re-scored axes; an irreversible_content hit is
|
|
45
|
+
full_irreversible whatever the sum."""
|
|
46
|
+
modes = policy.get("modes") or {}
|
|
47
|
+
full = modes.get("full") or {}
|
|
48
|
+
hard = full.get("hard_axes") or ["reversibility", "domain"]
|
|
49
|
+
light_max = int((modes.get("light") or {}).get("max_w", 2))
|
|
50
|
+
guarded_max = int((modes.get("guarded") or {}).get("max_w", 7))
|
|
51
|
+
w = sum(int(axes.get(a) or 0) for a in weight.AXES)
|
|
52
|
+
if irreversible or all(int(axes.get(a) or 0) >= 2 for a in hard):
|
|
53
|
+
return "full_irreversible"
|
|
54
|
+
if w >= int(full.get("min_w", guarded_max + 1)) or any(int(axes.get(a) or 0) >= 2 for a in hard):
|
|
55
|
+
return "full"
|
|
56
|
+
return "light" if w <= light_max else "guarded"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def higher_mode(a, b):
|
|
60
|
+
return max(a, b, key=lambda m: MODES.index(m) if m in MODES else -1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mode_setting(policy, mode, key, default=None):
|
|
64
|
+
"""modes.<mode>.<key>; full_irreversible inherits what it does not say from full."""
|
|
65
|
+
modes = policy.get("modes") or {}
|
|
66
|
+
for name in ([mode, "full"] if mode == "full_irreversible" else [mode]):
|
|
67
|
+
if key in (modes.get(name) or {}):
|
|
68
|
+
return modes[name][key]
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# The batch loop of /rr-build-all: one task for a whole plan, its own budgets.
|
|
73
|
+
BATCH_DEFAULTS = {"default": False, "review_rounds_max": 3, "max_suite_runs": 8, "parallel": 3, "tier": "BALANCED"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
BATCH_TIERS = ["FAST", "BALANCED", "STRONG", "EXPERT"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def batch_policy(policy, strict=False):
|
|
80
|
+
"""weight.json's batch section with every key filled in and checked. A table
|
|
81
|
+
from before the section runs with the shipped numbers (R13). A bad value is
|
|
82
|
+
refused where the task starts or the loop is switched (strict); a reader —
|
|
83
|
+
status, the handoff — falls back to the shipped value and never crashes."""
|
|
84
|
+
section = policy.get("batch") if isinstance(policy.get("batch"), dict) else {}
|
|
85
|
+
out, problems = dict(BATCH_DEFAULTS), []
|
|
86
|
+
for key, value in section.items():
|
|
87
|
+
if key not in BATCH_DEFAULTS:
|
|
88
|
+
continue
|
|
89
|
+
if key == "default":
|
|
90
|
+
if isinstance(value, bool):
|
|
91
|
+
out[key] = value
|
|
92
|
+
else:
|
|
93
|
+
problems.append("batch.default must be true or false, not %r" % (value,))
|
|
94
|
+
elif key == "tier":
|
|
95
|
+
if isinstance(value, str) and value.upper() in BATCH_TIERS:
|
|
96
|
+
out[key] = value.upper()
|
|
97
|
+
else:
|
|
98
|
+
problems.append("batch.tier must be one of %s, not %r" % ("/".join(BATCH_TIERS), value))
|
|
99
|
+
elif isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
100
|
+
problems.append("batch.%s must be a whole number of at least 1, not %r" % (key, value))
|
|
101
|
+
else:
|
|
102
|
+
out[key] = value
|
|
103
|
+
if out["parallel"] >= 3 and out["tier"] not in ("FAST", "BALANCED"):
|
|
104
|
+
problems.append("batch.tier %s with batch.parallel %d: three or more parallel agents run at FAST or "
|
|
105
|
+
"BALANCED (model-routing.md, Fan-out)" % (out["tier"], out["parallel"]))
|
|
106
|
+
if problems and strict:
|
|
107
|
+
fs.die("weight.json: " + "; ".join(problems))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def batch_on(state):
|
|
112
|
+
return bool((state.get("batch") or {}).get("on"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def budget(policy, state, key):
|
|
116
|
+
"""(limit, the table key the refusal names) for review_rounds_max or
|
|
117
|
+
max_suite_runs: the batch section's while the batch loop is on, else the
|
|
118
|
+
standard one — a task switched off mid-way keeps what it already spent."""
|
|
119
|
+
if batch_on(state):
|
|
120
|
+
return int(batch_policy(policy)[key]), "batch.%s" % key
|
|
121
|
+
if key == "max_suite_runs":
|
|
122
|
+
return int((policy.get("tests") or {}).get(key, 2)), "tests.max_suite_runs"
|
|
123
|
+
return int(policy.get(key, 2)), key
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def added_lines(root, base_tree, rel):
|
|
127
|
+
"""The lines `rel` gained since `base_tree` — all of them for a new file — so
|
|
128
|
+
content that was already there never raises a re-score."""
|
|
129
|
+
before = ""
|
|
130
|
+
if base_tree and sensors is not None:
|
|
131
|
+
code, text = sensors.git(root, ["cat-file", "-p", "%s:%s" % (base_tree, rel)])
|
|
132
|
+
before = text if code == 0 and "\0" not in text[:8192] else ""
|
|
133
|
+
seen = {}
|
|
134
|
+
for line in before.splitlines():
|
|
135
|
+
seen[line] = seen.get(line, 0) + 1
|
|
136
|
+
out = []
|
|
137
|
+
for line in fs.read_text(root, rel).splitlines():
|
|
138
|
+
if seen.get(line):
|
|
139
|
+
seen[line] -= 1
|
|
140
|
+
else:
|
|
141
|
+
out.append(line)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rescore_weight(root, base_tree, files, current, policy):
|
|
146
|
+
"""Reversibility and domain from the diff's `files`, only upward (R26).
|
|
147
|
+
|
|
148
|
+
It lives here, not in weight.py, because weight.py is at the spec's 300-line
|
|
149
|
+
cap; the rules it applies are weight.json's, through weight.match. Returns
|
|
150
|
+
{"axes", "raised": [{axis, from, to, evidence}], "operations", "irreversible"}.
|
|
151
|
+
A pattern of `irreversible_content` in the lines a `needs_step` file gained is
|
|
152
|
+
reversibility 2 and full_irreversible. The patterns are case-sensitive, as
|
|
153
|
+
the SQL keywords they name are written: `rename()` in Python is not RENAME."""
|
|
154
|
+
spec = policy.get("axes") or {}
|
|
155
|
+
rules = policy.get("rules") or {}
|
|
156
|
+
rev, dom = spec.get("reversibility") or {}, spec.get("domain") or {}
|
|
157
|
+
axes = dict(current or {})
|
|
158
|
+
found, operations = {}, []
|
|
159
|
+
files = [f for f in dict.fromkeys(files or []) if f]
|
|
160
|
+
for rel in files:
|
|
161
|
+
hit = weight.match(rel, rev.get("irreversible"))
|
|
162
|
+
if hit:
|
|
163
|
+
found["reversibility"] = (2, "%s matches irreversible %s" % (rel, hit))
|
|
164
|
+
elif weight.match(rel, rev.get("needs_step")):
|
|
165
|
+
gained = added_lines(root, base_tree, rel)
|
|
166
|
+
for pattern in rev.get("irreversible_content") or []:
|
|
167
|
+
try:
|
|
168
|
+
rx = re.compile(pattern)
|
|
169
|
+
except re.error:
|
|
170
|
+
continue
|
|
171
|
+
line = next((ln for ln in gained if rx.search(ln)), None)
|
|
172
|
+
if line is not None:
|
|
173
|
+
# The operation's keyword (DROP, ALTER …), not the pattern's
|
|
174
|
+
# source: it is what a human names in approve --confirm.
|
|
175
|
+
# The first word of the match; a match of only blanks or punctuation
|
|
176
|
+
# is named IRREVERSIBLE, never an IndexError.
|
|
177
|
+
word = re.search(r"[A-Za-z]+", rx.search(line).group(0))
|
|
178
|
+
operations.append(word.group(0).upper() if word else "IRREVERSIBLE")
|
|
179
|
+
if found.get("reversibility", (0,))[0] < 2:
|
|
180
|
+
found["reversibility"] = (2, "%s adds %r (irreversible_content %r)"
|
|
181
|
+
% (rel, line.strip()[:60], pattern))
|
|
182
|
+
found.setdefault("reversibility", (1, "%s matches needs_step %s"
|
|
183
|
+
% (rel, weight.match(rel, rev.get("needs_step")))))
|
|
184
|
+
for value, key, said in ((2, "modules", "matches modules"), (1, "customer_visible", "is customer-visible:")):
|
|
185
|
+
hit = weight.match(rel, dom.get(key))
|
|
186
|
+
if hit and value > found.get("domain", (0,))[0]:
|
|
187
|
+
found["domain"] = (value, "%s %s %s" % (rel, said, hit))
|
|
188
|
+
break
|
|
189
|
+
for hint in policy.get("hints") or []:
|
|
190
|
+
hit = next((f for f in files if weight.match(f, hint.get("paths"))), None)
|
|
191
|
+
if hit and hint.get("axis") == "domain":
|
|
192
|
+
value, why = found.get("domain", (0, ""))
|
|
193
|
+
found["domain"] = (min(2, value + int(rules.get("hint_raises_by", 1))),
|
|
194
|
+
"%shint %s (%s)" % (why + "; " if why else "", hint.get("note", ""), hit))
|
|
195
|
+
break
|
|
196
|
+
raised = []
|
|
197
|
+
for axis in rules.get("rescore_axes") or ["reversibility", "domain"]:
|
|
198
|
+
if axis not in found:
|
|
199
|
+
continue
|
|
200
|
+
value, why = found[axis]
|
|
201
|
+
if value > int(axes.get(axis) or 0):
|
|
202
|
+
raised.append({"axis": axis, "from": int(axes.get(axis) or 0), "to": value, "evidence": why})
|
|
203
|
+
axes[axis] = value
|
|
204
|
+
return {"axes": axes, "raised": raised, "operations": sorted(set(operations)),
|
|
205
|
+
"irreversible": bool(operations)}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def next_action_for(state):
|
|
209
|
+
if batch_on(state):
|
|
210
|
+
return ("batch loop: 'steps --ready' → implementers, step-done each; 'verify --scope all', red fixed "
|
|
211
|
+
"as one batch; review, findings fixed as one batch; again until it passes; the pass past the "
|
|
212
|
+
"batch budget is the human's")
|
|
213
|
+
return {
|
|
214
|
+
"light": "edit, then 'state.py verify --scope all', then 'state.py close'",
|
|
215
|
+
"guarded": "edit, 'state.py verify --scope all', one review ('state.py review'), then 'state.py close'",
|
|
216
|
+
"full": "edit, 'state.py verify --scope all', one STRONG review, then 'state.py close' "
|
|
217
|
+
"requests the human's approval",
|
|
218
|
+
"full_irreversible": "edit, 'state.py verify --scope all', one STRONG review, then 'state.py close'; "
|
|
219
|
+
"the human approves with --confirm \"<operation>\"",
|
|
220
|
+
}.get(state.get("mode"), "edit, verify, close")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def rescore_v7(root, state, policy, at, rt=rr_runtime.Runtime()):
|
|
224
|
+
"""R26 at verify and close: raises are applied, journaled and printed; the
|
|
225
|
+
task never restarts and the other four axes are not recomputed."""
|
|
226
|
+
if sensors is None:
|
|
227
|
+
return
|
|
228
|
+
diff = fs.safe_sensor(sensors.diff_files, root, state["diff"].get("base_tree"), fs.current_tree(root))
|
|
229
|
+
if not diff:
|
|
230
|
+
return
|
|
231
|
+
current = state["weight"].get("axes") or {}
|
|
232
|
+
got = rescore_weight(root, state["diff"].get("base_tree"), [d["path"] for d in diff], current, policy)
|
|
233
|
+
# The documents rule (faster-task-cycle (c)): the axes it zeroed come back, upward, once the
|
|
234
|
+
# diff holds a path that is neither a document nor a test — `start --amend` does not re-weigh.
|
|
235
|
+
zeroed = (state["weight"].get("documents_only") or {}).get("zeroed") or {}
|
|
236
|
+
tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
|
|
237
|
+
outside = next((d["path"] for d in diff if not is_document(d["path"], policy)
|
|
238
|
+
and not weight.match(d["path"], tests)), None)
|
|
239
|
+
if outside:
|
|
240
|
+
for axis, value in zeroed.items():
|
|
241
|
+
if int(got["axes"].get(axis) or 0) < int(value):
|
|
242
|
+
got["raised"].append({"axis": axis, "from": int(got["axes"].get(axis) or 0), "to": int(value),
|
|
243
|
+
"evidence": "a non-document changed (%s)" % outside})
|
|
244
|
+
got["axes"][axis] = int(value)
|
|
245
|
+
before = state["mode"]
|
|
246
|
+
ops = sorted(set(state["weight"].get("operations") or []) | set(got["operations"]))
|
|
247
|
+
# Only what the diff moved can move the mode: an unchanged measurement must
|
|
248
|
+
# not undo a mode a human lowered (R27).
|
|
249
|
+
was = mode_for(current, policy, bool(state["weight"].get("operations")))
|
|
250
|
+
now_measured = mode_for(got["axes"], policy, bool(ops))
|
|
251
|
+
mode = higher_mode(before, now_measured) if now_measured != was else before
|
|
252
|
+
if not got["raised"] and mode == before and ops == (state["weight"].get("operations") or []):
|
|
253
|
+
return
|
|
254
|
+
state["weight"].update(axes=got["axes"], w=sum(got["axes"].values()), operations=ops)
|
|
255
|
+
state["mode"] = mode
|
|
256
|
+
for r in got["raised"]:
|
|
257
|
+
journal.emit(root, state, "weight_raised", "%s %d -> %d at %s: %s" % (r["axis"], r["from"], r["to"], at,
|
|
258
|
+
fs.one_line(r["evidence"], 200)),
|
|
259
|
+
dict(r, mode_from=before, mode_to=mode), rt=rt)
|
|
260
|
+
state["weight"]["rescores"].append("%s at %s: %s %d → %d — %s"
|
|
261
|
+
% (fs.now(), at, r["axis"], r["from"], r["to"], r["evidence"]))
|
|
262
|
+
print("re-score: %s %d → %d — %s" % (r["axis"], r["from"], r["to"], fs.one_line(r["evidence"], 120)))
|
|
263
|
+
if mode != before:
|
|
264
|
+
state["weight"]["rescores"].append("%s at %s: mode %s → %s%s" % (
|
|
265
|
+
fs.now(), at, before, mode, " (irreversible: %s)" % ", ".join(ops) if got["irreversible"] else ""))
|
|
266
|
+
journal.emit(root, state, "tier_set", "%s (mode %s, compat)" % (MODE_TIER[mode], mode),
|
|
267
|
+
{"tier": MODE_TIER[mode], "mode": mode, "w": state["weight"]["w"]}, rt=rt)
|
|
268
|
+
state["next_action"] = next_action_for(state)
|
|
269
|
+
print("mode: %s → %s — %s" % (before, mode, state["next_action"]))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def full_suite(run):
|
|
273
|
+
"""A run of the whole suite: one narrowed with --files proves only those files."""
|
|
274
|
+
return run.get("scope") == "suite" and not run.get("expected_fail") and not run.get("files")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def suite_green(state):
|
|
278
|
+
return any(full_suite(r) and r.get("exit") == 0 for r in state["verify"]["runs"])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# Never a document, whatever weight.json's `documents` says: the policies and the per-directory rules bind the agents.
|
|
282
|
+
NEVER_DOCUMENTS = (".ai/policies/**", ".ai/rules/**")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def is_document(path, policy):
|
|
286
|
+
"""The one definition of a document (faster-task-cycle D4): `documents.globs`, minus
|
|
287
|
+
`documents.except`, NEVER_DOCUMENTS, code and tests; no `documents` key, no document."""
|
|
288
|
+
docs = policy.get("documents") or {}
|
|
289
|
+
cov = (policy.get("axes") or {}).get("coverage") or {}
|
|
290
|
+
return bool(weight.match(path, docs.get("globs"))
|
|
291
|
+
and not any(weight.match(path, pats) for pats in (
|
|
292
|
+
docs.get("except"), NEVER_DOCUMENTS, cov.get("code_globs"), cov.get("test_globs"))))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
DOCUMENTS_ZEROED = ("behaviour", "domain", "novelty")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def weigh(root, files, declared, goal, policy, globs=()):
|
|
299
|
+
"""weight.compute, plus the documents rule (faster-task-cycle (c), in state.py because
|
|
300
|
+
weight.py is at its line cap). Predicate P: at least one concrete file, every file a
|
|
301
|
+
document (is_document) or a test, at least one a document. When P holds, file text no
|
|
302
|
+
longer scores behaviour (the goal's keyword still does, exactly as in compute), and
|
|
303
|
+
behaviour, domain and novelty still UNKNOWN become 0 with the evidence "documents only"; hints and
|
|
304
|
+
declarations were applied inside compute. `documents_only` is {"zeroed": {axis: what it
|
|
305
|
+
would have scored}}, None when P does not hold or the policy has no `documents` key.
|
|
306
|
+
`globs` are the task's glob entries, which score nothing themselves: P fails when one is
|
|
307
|
+
not itself a document or test glob."""
|
|
308
|
+
concrete = [posixpath.normpath(f.strip().replace(os.sep, "/")) for f in files if f.strip()]
|
|
309
|
+
tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
|
|
310
|
+
inside = all(not f.startswith(("/", "../")) and f != ".." for f in concrete)
|
|
311
|
+
if not (inside and all(is_document(g, policy) or weight.match(g, tests) for g in globs)
|
|
312
|
+
and any(is_document(f, policy) for f in concrete)
|
|
313
|
+
and all(is_document(f, policy) or weight.match(f, tests) for f in concrete)):
|
|
314
|
+
result = weight.compute(root, files, declared, goal, policy=policy)
|
|
315
|
+
result.documents_only = None
|
|
316
|
+
return result
|
|
317
|
+
scored = copy.deepcopy(policy)
|
|
318
|
+
keywords = list(((policy.get("axes") or {}).get("behaviour") or {}).get("keywords") or [])
|
|
319
|
+
if not (goal and any(re.search(r"\b%s\b" % re.escape(k), goal, re.I) for k in keywords)):
|
|
320
|
+
# No goal keyword: file text must not score. With one, compute reads the goal first
|
|
321
|
+
# (value 1 whatever the files hold), so keeping the list scores exactly as plain compute does.
|
|
322
|
+
scored["axes"]["behaviour"] = dict(scored["axes"].get("behaviour") or {}, keywords=[])
|
|
323
|
+
result = weight.compute(root, files, declared, goal, policy=scored)
|
|
324
|
+
zeroed = {}
|
|
325
|
+
for axis in DOCUMENTS_ZEROED:
|
|
326
|
+
if axis not in result.unknown:
|
|
327
|
+
continue
|
|
328
|
+
if result.axes[axis] != 0:
|
|
329
|
+
zeroed[axis] = result.axes[axis]
|
|
330
|
+
result.axes[axis], result.evidence[axis] = 0, "documents only"
|
|
331
|
+
result.unknown.remove(axis)
|
|
332
|
+
result.w = sum(result.axes.values())
|
|
333
|
+
result.mode = mode_for(result.axes, policy)
|
|
334
|
+
result.documents_only = {"zeroed": zeroed}
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def documents_cover(root, state, policy, tree):
|
|
339
|
+
"""Fact (a) through the documents rule (faster-task-cycle D1-D5): (ok, why, facts).
|
|
340
|
+
why is "" when the rule is off or does not apply: today's refusal text stands."""
|
|
341
|
+
off = (False, "", None)
|
|
342
|
+
tests = (fs.safe_sensor(sensors.read_commands, root) or {}).get("doc_tests", "").split()
|
|
343
|
+
if not (policy.get("documents") or {}).get("globs") or not tests or tests == ["none"]:
|
|
344
|
+
return off # D1
|
|
345
|
+
runs = [r for r in state["verify"]["runs"] if full_suite(r) and r.get("exit") is not None and r.get("tree")]
|
|
346
|
+
g = runs[-1] if runs else None
|
|
347
|
+
if not g or g["exit"] != 0 or any(r["tree"] == g["tree"] and r["exit"] != 0 for r in runs):
|
|
348
|
+
return off # D2
|
|
349
|
+
# A full-suite run after G that is not green stops the rule, whatever its tree (it may be None).
|
|
350
|
+
if any(full_suite(r) and r["n"] > g["n"] and r.get("exit") != 0 for r in state["verify"]["runs"]):
|
|
351
|
+
return off
|
|
352
|
+
diff = fs.safe_sensor(sensors.diff_files, root, g["tree"], tree)
|
|
353
|
+
if not diff or not all(is_document(d["path"], policy) for d in diff):
|
|
354
|
+
return off # D3, D4
|
|
355
|
+
paths = [d["path"] for d in diff]
|
|
356
|
+
steps = [r for r in state["verify"]["runs"] if r.get("scope") == "step" and not r.get("expected_fail")
|
|
357
|
+
and r.get("tree") == tree]
|
|
358
|
+
s = steps[-1] if steps else None # D5
|
|
359
|
+
if not (s and s["n"] > g["n"] and s.get("exit") == 0 and all(t in (s.get("files") or []) for t in tests)):
|
|
360
|
+
return (False, "only documents changed since run %d (%d files): 'verify --scope step --files \"%s\"'"
|
|
361
|
+
% (g["n"], len(paths), ",".join(tests)), None)
|
|
362
|
+
return (True, "documents only since run %d (%d files: %s); doc_tests green in run %d on %s"
|
|
363
|
+
% (g["n"], len(paths), ", ".join(paths[:5]) + (", …" if len(paths) > 5 else ""), s["n"], tree[:9]),
|
|
364
|
+
{"since_run": g["n"], "since_tree": g["tree"], "run": s["n"], "paths": paths[:20], "tests": tests})
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def review_next_action(state):
|
|
368
|
+
"""What follows the last verdict — also after `batch off`, which changes what
|
|
369
|
+
a blockers_open verdict asks for: a full pass in batch, a scoped re-review outside."""
|
|
370
|
+
status = state["review"].get("status")
|
|
371
|
+
if status == "blockers_open":
|
|
372
|
+
if batch_on(state):
|
|
373
|
+
return ("fix every BLOCKER and HIGH as one parallel batch, 'state.py verify --scope all "
|
|
374
|
+
"--after-fix \"…\"', then a full review pass again")
|
|
375
|
+
return "fix the blockers, 'state.py verify --scope all --after-fix \"…\"', one scoped re-review"
|
|
376
|
+
if status in VERDICT_STATUS.values():
|
|
377
|
+
return "'state.py close'"
|
|
378
|
+
return next_action_for(state)
|