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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +12 -11
  5. package/bin/riskrails.js +109 -18
  6. package/hooks/ai-path-guard-defaults.json +3 -2
  7. package/hooks/context-guard.py +14 -11
  8. package/hooks/dialog-gate.py +1 -1
  9. package/hooks/guard/dialog.py +107 -27
  10. package/hooks/guard/git.py +12 -3
  11. package/hooks/guard/paths.py +109 -7
  12. package/hooks/project-scaffold.py +2 -1
  13. package/hooks/runtime-gate.py +128 -27
  14. package/hooks/update_notice.py +3 -3
  15. package/package.json +1 -1
  16. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  17. package/skills/ai-task/rr_state/__init__.py +1 -0
  18. package/skills/ai-task/rr_state/cli.py +210 -0
  19. package/skills/ai-task/rr_state/close.py +171 -0
  20. package/skills/ai-task/rr_state/dialog.py +541 -0
  21. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  22. package/skills/ai-task/rr_state/fs.py +220 -0
  23. package/skills/ai-task/rr_state/gate.py +125 -0
  24. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  25. package/skills/ai-task/rr_state/handoff.py +226 -0
  26. package/skills/ai-task/rr_state/journal.py +126 -0
  27. package/skills/ai-task/rr_state/labels.py +99 -0
  28. package/skills/ai-task/rr_state/ledger.py +145 -0
  29. package/skills/ai-task/rr_state/locks.py +84 -0
  30. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  31. package/skills/ai-task/rr_state/policy.py +378 -0
  32. package/skills/ai-task/rr_state/profile.py +373 -0
  33. package/skills/ai-task/rr_state/questions.py +336 -0
  34. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  35. package/skills/ai-task/rr_state/report.py +271 -0
  36. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  37. package/skills/ai-task/rr_state/review.py +165 -0
  38. package/skills/ai-task/rr_state/runtime.py +80 -0
  39. package/skills/ai-task/rr_state/start.py +257 -0
  40. package/skills/ai-task/rr_state/steps.py +131 -0
  41. package/skills/ai-task/rr_state/task.py +396 -0
  42. package/skills/ai-task/rr_state/verify.py +231 -0
  43. package/skills/ai-task/sensors.py +16 -3
  44. package/skills/ai-task/state.py +24 -5161
  45. package/skills/project-update/adopt.py +38 -24
  46. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  47. package/skills/project-update/history/index.json +2 -1
  48. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  49. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  50. package/skills/project-update/migrations/README.md +16 -1
  51. package/skills/project-update/update.py +202 -26
  52. package/skills/rr-setup/SKILL.md +9 -2
  53. package/skills/rr-status/status.py +36 -9
@@ -32,6 +32,12 @@ def test_mode():
32
32
  return os.environ.get("RISKRAILS_TEST", os.environ.get("CLAUDE_AGENTIC_TEST")) == "1"
33
33
 
34
34
 
35
+ def ai_unattended():
36
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
37
+ and blanks ignored): one rule in every reader (risk 0587)."""
38
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
39
+
40
+
35
41
  def human_gate():
36
42
  """(passed, via, why) for --confirm-delete. A terminal outside any agent
37
43
  session is a human: via "terminal". Otherwise a launcher that declared the
@@ -44,7 +50,7 @@ def human_gate():
44
50
  markers = [name for name in AGENT_MARKERS if name in os.environ]
45
51
  if os.isatty(0) and not markers:
46
52
  return True, "terminal", ""
47
- if os.environ.get("AI_UNATTENDED"):
53
+ if ai_unattended():
48
54
  return True, "unattended", ""
49
55
  if markers:
50
56
  return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
@@ -175,7 +181,7 @@ def load_table(path=MAP_FILE):
175
181
  try:
176
182
  with open(path, encoding="utf-8") as fh:
177
183
  table = json.load(fh)
178
- except (OSError, ValueError) as exc:
184
+ except (OSError, ValueError, RecursionError) as exc:
179
185
  raise TableError("%s: %s" % (path, exc)) from exc
180
186
  tools = table.get("tools")
181
187
  rows = table.get("rows")
@@ -455,7 +461,7 @@ def load_decisions(root):
455
461
  try:
456
462
  with open(os.path.join(root, where, "decisions.json"), encoding="utf-8") as fh:
457
463
  return json.load(fh)
458
- except (OSError, ValueError) as exc:
464
+ except (OSError, ValueError, RecursionError) as exc:
459
465
  raise TableError("%s/decisions.json: %s" % (where, exc)) from exc
460
466
 
461
467
 
@@ -925,7 +931,7 @@ def state_line(root, shipped_block, skeleton_cap, table=None, instruction_files=
925
931
  try:
926
932
  return _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files)
927
933
  except (TableError, OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
928
- return 1, "adopt state unreadable (%s) — run /project-update --adopt" % exc
934
+ return 1, "adopt state unreadable (%s) — run /rr-setup --adopt" % exc
929
935
 
930
936
 
931
937
  def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files):
@@ -943,11 +949,11 @@ def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shi
943
949
  return 0, "no foreign structure detected"
944
950
  bits = ["%s (%d file(s))" % (t, sum(g.values())) for t, g in adoption.detected.items()]
945
951
  bits += ["%s (split?)" % c.path for c, _ in adoption.candidates]
946
- return 1, "foreign structure detected: %s — run /project-update --adopt" % ", ".join(bits)
952
+ return 1, "foreign structure detected: %s — run /rr-setup --adopt" % ", ".join(bits)
947
953
  try:
948
954
  with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
949
955
  record_data = json.load(fh)
950
- except (OSError, ValueError):
956
+ except (OSError, ValueError, RecursionError):
951
957
  return 0, "no foreign structure detected"
952
958
  date = where.rsplit("adopt-", 1)[-1]
953
959
  regenerated = []
@@ -968,13 +974,13 @@ def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shi
968
974
  if current != src.get("sha"):
969
975
  regenerated.append(src["path"])
970
976
  if regenerated:
971
- return 1, ("foreign files regenerated since the adopt of %s: %s — run /project-update --adopt"
977
+ return 1, ("foreign files regenerated since the adopt of %s: %s — run /rr-setup --adopt"
972
978
  % (date, ", ".join(sorted(regenerated))))
973
979
  checks = record_data.get("checks", {})
974
980
  failed = [name for name, c in checks.items() if c.get("status") not in ("pass", "not_applicable")]
975
981
  if failed or record_data.get("status") == "partial":
976
982
  what = failed[0].replace("_", "-") + " FAIL" if failed else "the apply was interrupted"
977
- return 1, "adoption of %s incomplete: %s — run /project-update --adopt" % (date, what)
983
+ return 1, "adoption of %s incomplete: %s — run /rr-setup --adopt" % (date, what)
978
984
  tools = ", ".join(sorted(record_data.get("tools", {})))
979
985
  pending = sum(1 for s in record_data.get("sources", []) if s.get("cleanup") and safe_rel(s.get("path"))
980
986
  and os.path.isfile(os.path.join(root, s["path"])))
@@ -1306,7 +1312,7 @@ def read_record(root, where):
1306
1312
  try:
1307
1313
  with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
1308
1314
  return json.load(fh)
1309
- except (OSError, ValueError):
1315
+ except (OSError, ValueError, RecursionError):
1310
1316
  return None
1311
1317
 
1312
1318
 
@@ -1331,7 +1337,7 @@ def task_in_flight(root):
1331
1337
  try:
1332
1338
  with open(path, encoding="utf-8") as fh:
1333
1339
  state = json.load(fh)
1334
- except (OSError, ValueError):
1340
+ except (OSError, ValueError, RecursionError):
1335
1341
  return None
1336
1342
  stage = state.get("current_stage")
1337
1343
  return None if stage in (None, "done") else "%s at stage %s" % (state.get("task_id", "a task"), stage)
@@ -1340,13 +1346,13 @@ def task_in_flight(root):
1340
1346
  def gates(root, u, planned_writes):
1341
1347
  """R5, in the order a human fixes them. Returns a refusal (reason, how) or None."""
1342
1348
  if not os.path.isdir(os.path.join(root, ".ai")):
1343
- return "the project has no .ai/", "run /ai-init first, then /project-update --adopt"
1349
+ return "the project has no .ai/", "run /rr-setup first, then /rr-setup --adopt"
1344
1350
  dirty = clean_tree(root)
1345
1351
  if dirty is None:
1346
1352
  return "the project is not a git work tree", "run `git init` and commit, so the adopt can be undone"
1347
1353
  running = task_in_flight(root)
1348
1354
  if running:
1349
- return "a task is in flight (%s)" % running, "finish or abandon it with /ai-task first"
1355
+ return "a task is in flight (%s)" % running, "finish or abandon it with /rr-build first"
1350
1356
  outside = [p for p in dirty if not p.startswith(ALLOWED_DIRT) and p not in planned_writes]
1351
1357
  if outside:
1352
1358
  more = " and %d more" % (len(outside) - 3) if len(outside) > 3 else ""
@@ -1356,7 +1362,7 @@ def gates(root, u, planned_writes):
1356
1362
  "commit it or remove it")
1357
1363
  return ("the tree has uncommitted changes: %s%s" % (", ".join(outside[:3]), more), how)
1358
1364
  if u.plain_pending(root, ignore=planned_writes):
1359
- return "the project is behind the installed plugin", "run /project-update --apply first, then adopt"
1365
+ return "the project is behind the installed plugin", "run /rr-setup --update --apply first, then adopt"
1360
1366
  return None
1361
1367
 
1362
1368
 
@@ -1403,7 +1409,8 @@ def write_json(u, root, rel, data):
1403
1409
 
1404
1410
  def apply_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
1405
1411
  """`update.py --adopt --apply`: gates, originals, phased writes, the checks
1406
- on disk, the record. Exit 0, 3 (aborted), 4 (incomplete) or 5 (refused)."""
1412
+ on disk, the record. Exit 0, 2 (the routing table or a migration could not be
1413
+ read), 3 (aborted), 4 (incomplete) or 5 (refused)."""
1407
1414
  root = plan.root
1408
1415
  try:
1409
1416
  table = load_table()
@@ -1413,7 +1420,11 @@ def apply_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shippe
1413
1420
  tools = [t.strip() for t in args.tool.split(",")] if args.tool else None
1414
1421
  resume_dir, resume = partial_record(root)
1415
1422
  planned_writes = set(resume.get("planned_writes", [])) if resume else set()
1416
- refusal = gates(root, u, planned_writes)
1423
+ try:
1424
+ refusal = gates(root, u, planned_writes)
1425
+ except u.SchemaError as exc: # plain_pending builds the plan: a migration's error is exit 2, as in update.py
1426
+ print("project-update: %s" % exc, file=sys.stderr)
1427
+ return 2
1417
1428
  if refusal:
1418
1429
  return refuse(*refusal)
1419
1430
  plan.report_dir = plan.adopt_report_dir = resume_dir or ".ai/reports/adopt-%s" % today()
@@ -1577,7 +1588,9 @@ def report_md(plan, adoption, record):
1577
1588
  # ---------------------------------------------------------------------------
1578
1589
 
1579
1590
  CLEANUP_HEADING = "## Cleanup"
1580
- REVIEW_LINE = "Run `/project-update --adopt --cleanup` to review; a human confirms the deletion."
1591
+ REVIEW_LINE = "Run `/rr-setup --adopt --cleanup` to review; a human confirms the deletion."
1592
+ # Reports written by v2.0.2-v3.1.0 carry this text; cleanup still replaces it.
1593
+ LEGACY_REVIEW_LINE = "Run `/project-update --adopt --cleanup` to review; a human confirms the deletion."
1581
1594
 
1582
1595
 
1583
1596
  def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_files):
@@ -1589,21 +1602,21 @@ def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_
1589
1602
  def no(reason, how, adoption=None):
1590
1603
  return (reason, how), None, None, None, adoption
1591
1604
  if not os.path.isdir(os.path.join(root, ".ai")):
1592
- return no("the project has no .ai/", "run /ai-init first")
1605
+ return no("the project has no .ai/", "run /rr-setup first")
1593
1606
  where = latest_with(root, "adopt.json")
1594
1607
  record = read_record(root, where) if where else None
1595
1608
  if record is None:
1596
- return no("no adopt record to clean up", "run /project-update --adopt --apply first")
1609
+ return no("no adopt record to clean up", "run /rr-setup --adopt --apply first")
1597
1610
  if args.mode == "coexist" or record.get("mode") == "coexist":
1598
1611
  return no("coexist keeps the foreign files",
1599
- "to move them, run /project-update --adopt --apply (mode migrate), then --cleanup")
1612
+ "to move them, run /rr-setup --adopt --apply (mode migrate), then --cleanup")
1600
1613
  checks = record.get("checks", {})
1601
1614
  if record.get("status") != "applied" or any(c.get("status") != "pass" for c in checks.values()):
1602
1615
  return no("the adopt of %s is not complete (R12)" % where.rsplit("adopt-", 1)[-1],
1603
- "run /project-update --adopt --apply until both checks pass")
1616
+ "run /rr-setup --adopt --apply until both checks pass")
1604
1617
  running = task_in_flight(root)
1605
1618
  if running:
1606
- return no("a task is in flight (%s)" % running, "finish or abandon it with /ai-task first")
1619
+ return no("a task is in flight (%s)" % running, "finish or abandon it with /rr-build first")
1607
1620
  dirty = clean_tree(root)
1608
1621
  if dirty is None:
1609
1622
  return no("the project is not a git work tree", "cleanup deletes only what git can restore")
@@ -1642,7 +1655,7 @@ def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_
1642
1655
  what.append("%d unmapped, %d split(s) waiting" % (len(adoption.unmapped), len(adoption.split)))
1643
1656
  if what:
1644
1657
  return no("the adopt no longer holds on the current tree: %s" % "; ".join(what),
1645
- "run /project-update --adopt to see why, settle it, then --cleanup", adoption)
1658
+ "run /rr-setup --adopt to see why, settle it, then --cleanup", adoption)
1646
1659
  # Only what the adopt finds again as a cleanup source, and only what git
1647
1660
  # can restore: an ignored file skipped from original/ would be gone for good.
1648
1661
  found = {path for path, _t, _tr, _n, cleanup in adoption.sources if cleanup}
@@ -1773,7 +1786,8 @@ def cleanup_report_md(u, root, where, record):
1773
1786
  except OSError:
1774
1787
  text = ""
1775
1788
  text = text.split("\n" + CLEANUP_HEADING + "\n", 1)[0].rstrip("\n") + "\n"
1776
- text = text.replace(REVIEW_LINE, "The deletion is recorded under Cleanup below.")
1789
+ for line in (REVIEW_LINE, LEGACY_REVIEW_LINE):
1790
+ text = text.replace(line, "The deletion is recorded under Cleanup below.")
1777
1791
  c = record["cleanup"]
1778
1792
  runs = list(c.get("history") or []) + [c]
1779
1793
 
@@ -1900,7 +1914,7 @@ def find_proposal(root, cand, stale=False):
1900
1914
  try:
1901
1915
  with open(os.path.join(base, d, name), encoding="utf-8") as fh:
1902
1916
  data = json.load(fh)
1903
- except (OSError, ValueError):
1917
+ except (OSError, ValueError, RecursionError):
1904
1918
  continue
1905
1919
  if isinstance(data, dict) and data.get("source") == cand.path and (stale or data.get("source_sha") == cand.sha):
1906
1920
  return ".ai/reports/%s/%s" % (d, name), data
@@ -0,0 +1,67 @@
1
+ # Task state
2
+
3
+ `state.json` is the machine-readable record of the task in flight. It is
4
+ written by `skills/ai-task/state.py` (atomically), read by `/rr-status`, and
5
+ enforced by `ai-scope-guard`, which derives the files the current step may touch
6
+ from it.
7
+
8
+ Two other writers exist, and only these two. A schema migration in
9
+ `/rr-setup --update` may change the state through `ctx.patch_state()`, with the
10
+ same atomic write and a `schema_migrated` history entry — that is how a task in
11
+ flight survives a change to the shape of this file. And `hooks/context-guard.py`
12
+ writes `session.json`, the sidecar beside this file, and nothing else: it never
13
+ touches `state.json`. Nothing else writes any of it — an agent never edits
14
+ them by hand, and the path guard refuses when one tries.
15
+
16
+ It is **not committed**: it describes a session, not the repository. The
17
+ `.gitignore` entry added by `/rr-setup` keeps it out of git.
18
+
19
+ When a task closes, `state.py close` writes the final state to
20
+ `.ai/state/logs/<task-id>/state.json`, beside the task's verify logs — local,
21
+ **not** committed. What is committed is `.ai/reports/<task-id>/events.jsonl`
22
+ and `task.md`, and in packages mode the task's compact record under
23
+ `.ai/memory/` — that is the audit trail: which stages ran, in what order, who
24
+ approved what, and when (the plugin's `docs/local-memory.md`).
25
+
26
+ It holds facts, never transcripts:
27
+
28
+ ```
29
+ task_id, goal, workflow, risk_tier, current_stage, affected_modules,
30
+ context_summary_ref, approved_plan { ref, current_step_id, steps[] },
31
+ completed_steps, test_status, e2e_status, review_status, security_status, open_risks,
32
+ next_action, owner_runtime, resume_point { stage, step_id, next_action, at, runtime },
33
+ questions { file, pending[] }, handoff { file, written_at, reason },
34
+ human_approval { required, granted, granted_by, granted_at,
35
+ requested_at, gate_id, requested_session, rejected_at, via, unattended },
36
+ created_at, updated_at, history[]
37
+ ```
38
+
39
+ `state.py` applies the schema-2 keys in memory as it loads, so a project that has
40
+ not run `/rr-setup --update` yet still answers every command; `save()` then writes
41
+ them. A reader that opens this file directly sees what is on disk, not those
42
+ defaults.
43
+
44
+ ## The other files beside it
45
+
46
+ | File | Written by | Committed |
47
+ |---|---|---|
48
+ | `state.json` (legacy `current.json`, still read) | `state.py`, and `update.py` during a migration | no |
49
+ | `session.json` | `context-guard.py`, on SessionStart and each prompt | no |
50
+ | `handoff.md` | `state.py handoff` — never a model | no |
51
+ | `../reports/<task-id>/task.md` §Questions | `state.py ask/answer/questions` | yes |
52
+ | `../reports/<task-id>/events.jsonl` | `state.py` (`emit`), append-only | yes |
53
+
54
+ `handoff.md` is the ≤ 30-line frame a session that lost its context reads first:
55
+ the goal, the next action and its resume point, the pending questions, the latest
56
+ decisions, rejected options and failed attempts, and the user's last instruction
57
+ verbatim. It is rendered from the four sources above, so deleting it loses
58
+ nothing — the next stage-moving command writes it again.
59
+
60
+ `events.jsonl` is the journal: one line per state change, `history[]`'s facts
61
+ with types beside them. It is best effort by contract — a line that cannot be
62
+ written never fails the command that emitted it — so nothing depends on it being
63
+ complete, and readers skip a line they cannot parse.
64
+
65
+ If this file is corrupt, `state.py` refuses to read it rather than replacing it.
66
+ Inspect it by hand: the scope guard's boundaries come from here, and a silently
67
+ regenerated state file means an unbounded implementer.
@@ -244,7 +244,8 @@
244
244
  "ad4c4f81855725f7fe6e0e3d8f557df0de5fc063465d428546f69c54020c69d9",
245
245
  "19b1a6390e96136619d23bc2ef9afed8f15e62107de7c1052bc5ea23e5bb5692",
246
246
  "23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5",
247
- "7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a"
247
+ "7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a",
248
+ "a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9"
248
249
  ],
249
250
  "ai-init/.ai/templates/architecture-decision.md": [
250
251
  "78619b522826ba8f5f0422e9deb7359c8002553d1aeb29a3719da5ab11ff13a1"
@@ -61,5 +61,5 @@ def plan(ctx):
61
61
  "the managed block below it now says the same thing" % path)
62
62
  if ctx.block_status(path) == "edited":
63
63
  ctx.hint("%s: the managed block was edited here, so the plugin's stub cannot "
64
- "replace it — move your lines below the block and run /project-update "
64
+ "replace it — move your lines below the block and run /rr-setup --update "
65
65
  "again" % path)
@@ -54,7 +54,7 @@ TEST_KEYS = ("timeout_seconds", "env_retries", "log_max_bytes")
54
54
  def _loads(raw):
55
55
  try:
56
56
  doc = json.loads(raw, object_pairs_hook=OrderedDict)
57
- except (TypeError, ValueError):
57
+ except (TypeError, ValueError, RecursionError): # nested past the parser's limit: edit_json holds the schema
58
58
  return None
59
59
  return doc if isinstance(doc, dict) else None
60
60
 
@@ -111,21 +111,17 @@ def _hint(doc, axis, paths, note):
111
111
  _add(hint.setdefault("paths", []), paths)
112
112
 
113
113
 
114
- def convert(old, base, shipped):
115
- """The weight table `base` plus what the old table `old` added on top of the
116
- `shipped` (examples, scopes, scalars, weight template) it was installed
117
- with. A scalar is carried only when the old table changed it and `base`
118
- still holds the shipped weight value — a project's own weight.json edit
119
- wins. `base` is not changed, and converting twice adds nothing new."""
114
+ def _writes(old, base, shipped):
115
+ """What converting `old` into `base` writes, in order: ("list", path, values) appends to a list,
116
+ ("set", path, value) sets a scalar, ("hint", axis, (paths, note)) adds to a hint."""
120
117
  shipped_examples, shipped_scopes, shipped_scalars, template = shipped
121
- new = json.loads(json.dumps(base), object_pairs_hook=OrderedDict)
122
- axes = new.setdefault("axes", OrderedDict())
118
+ out = []
123
119
  for tier, entry in (old.get("tiers") or {}).items():
124
120
  if tier in EXAMPLE_MODE and isinstance(entry, dict):
125
121
  extra = [e for e in entry.get("examples") or []
126
122
  if isinstance(e, str) and e not in shipped_examples.get(tier, set())]
127
123
  if extra:
128
- _add(new.setdefault("examples", OrderedDict()).setdefault(EXAMPLE_MODE[tier], []), extra)
124
+ out.append(("list", ("examples", EXAMPLE_MODE[tier]), extra))
129
125
  for scope in old.get("path_scopes") or []:
130
126
  if not isinstance(scope, dict):
131
127
  continue
@@ -136,23 +132,41 @@ def convert(old, base, shipped):
136
132
  continue
137
133
  if name in DOMAIN or (name not in HINTS and name not in INFRA | NEEDS_STEP
138
134
  and scope.get("min_tier") in ("T5", "T6")):
139
- _add(axes.setdefault("domain", OrderedDict()).setdefault("modules", []), paths)
135
+ out.append(("list", ("axes", "domain", "modules"), paths))
140
136
  elif name in HINTS:
141
- _hint(new, HINTS[name], paths, "%s (from risk-tiers.json)" % name)
137
+ out.append(("hint", HINTS[name], (paths, "%s (from risk-tiers.json)" % name)))
142
138
  elif name in INFRA | NEEDS_STEP:
143
- rev = axes.setdefault("reversibility", OrderedDict())
144
139
  two = [p for p in paths if name in INFRA and "prod" in p]
145
- _add(rev.setdefault("irreversible", []), two)
146
- _add(rev.setdefault("needs_step", []), [p for p in paths if p not in two])
140
+ out.append(("list", ("axes", "reversibility", "irreversible"), two))
141
+ out.append(("list", ("axes", "reversibility", "needs_step"), [p for p in paths if p not in two]))
147
142
  for key, value in _scalars(old).items():
148
143
  if value in shipped_scalars.get(key, []) or _get(base, key) != _get(template, key):
149
144
  continue
150
145
  if key == "tests.max_suite_runs" and isinstance(value, int):
151
146
  value = min(value, 2)
152
- if key.startswith("tests."):
153
- new.setdefault("tests", OrderedDict())[key[len("tests."):]] = value
147
+ out.append(("set", tuple(key.split(".")), value))
148
+ return out
149
+
150
+
151
+ def convert(old, base, shipped):
152
+ """The weight table `base` plus what the old table `old` added on top of the
153
+ `shipped` (examples, scopes, scalars, weight template) it was installed
154
+ with. A scalar is carried only when the old table changed it and `base`
155
+ still holds the shipped weight value — a project's own weight.json edit
156
+ wins. `base` is not changed, and converting twice adds nothing new."""
157
+ new = json.loads(json.dumps(base), object_pairs_hook=OrderedDict)
158
+ new.setdefault("axes", OrderedDict())
159
+ for kind, path, value in _writes(old, base, shipped):
160
+ if kind == "hint":
161
+ _hint(new, path, *value)
162
+ continue
163
+ node = new
164
+ for part in path[:-1]:
165
+ node = node.setdefault(part, OrderedDict())
166
+ if kind == "list":
167
+ _add(node.setdefault(path[-1], []), value)
154
168
  else:
155
- new[key] = value
169
+ node[path[-1]] = value
156
170
  return new
157
171
 
158
172
 
@@ -168,19 +182,112 @@ def _mode(state):
168
182
  return state
169
183
 
170
184
 
185
+ def _kind(value):
186
+ """the JSON name of a value's type"""
187
+ if value is None:
188
+ return "null"
189
+ if isinstance(value, bool): # before int: True is an int
190
+ return "boolean"
191
+ for types, name in (((int, float), "number"), (str, "string"), (list, "list"), (dict, "object")):
192
+ if isinstance(value, types):
193
+ return name
194
+ return type(value).__name__
195
+
196
+
197
+ def _an(word):
198
+ return word if word == "null" else ("an " if word[0] in "aeiou" else "a ") + word
199
+
200
+
201
+ def _bad(doc, key, want, what):
202
+ """A reason when `doc[key]`, present and not null, is not a `want`; else None."""
203
+ value = doc.get(key)
204
+ if value is not None and not isinstance(value, want):
205
+ return "%s is %s, not %s" % (what, _an(_kind(value)), _an("object" if want is dict else "list"))
206
+ return None
207
+
208
+
209
+ def _old_problem(old):
210
+ """Why the old table cannot be converted (a wrong type where convert reads), or None."""
211
+ if not isinstance(old, dict):
212
+ return "risk-tiers.json is %s, not an object" % _an(_kind(old))
213
+ why = _bad(old, "tiers", dict, "tiers") if old.get("tiers") else None # falsy: convert reads no tiers
214
+ for tier, entry in (old.get("tiers") or {}).items() if why is None else ():
215
+ if tier in EXAMPLE_MODE and isinstance(entry, dict) and entry.get("examples"): # convert reads only these
216
+ why = why or _bad(entry, "examples", (list, dict), "tiers.%s.examples" % tier) # an object iterates by key
217
+ scopes = old.get("path_scopes")
218
+ if scopes and not isinstance(scopes, (list, dict, str)): # an object or a string iterates, its members skipped
219
+ why = why or "path_scopes is %s, not a list" % _an(_kind(scopes))
220
+ for scope in scopes if isinstance(scopes, list) and not why else ():
221
+ if isinstance(scope, dict):
222
+ name = scope.get("scope")
223
+ if scope.get("paths"):
224
+ why = _bad(scope, "paths", (list, dict), "a path scope's paths") # an object iterates by key
225
+ # the name is hashed only against a str path
226
+ if not why and isinstance(name, (list, dict)) and any(isinstance(p, str) for p in scope.get("paths") or []):
227
+ why = "a path scope's name is %s, not a string" % _an(_kind(name))
228
+ if why:
229
+ break
230
+ return why
231
+
232
+
233
+ def _new_problem(doc, writes):
234
+ """Why the weight table `doc` cannot take the `writes` of convert (a wrong type where convert writes), or None."""
235
+ if not isinstance(doc, dict):
236
+ return "weight.json is %s, not an object" % _an(_kind(doc))
237
+ for kind, path, value in writes:
238
+ if kind == "hint":
239
+ if "hints" not in doc:
240
+ continue
241
+ if not isinstance(doc["hints"], list):
242
+ return "hints is %s, not a list" % _an(_kind(doc["hints"]))
243
+ for i, hint in enumerate(doc["hints"]):
244
+ if isinstance(hint, dict) and hint.get("axis") == path:
245
+ if "paths" in hint and not isinstance(hint["paths"], list):
246
+ return "hints[%d].paths is %s, not a list" % (i, _an(_kind(hint["paths"])))
247
+ break
248
+ continue
249
+ node = doc
250
+ for i, part in enumerate(path):
251
+ if part not in node:
252
+ break
253
+ node = node[part]
254
+ what = ".".join(path[:i + 1])
255
+ if i < len(path) - 1:
256
+ if not isinstance(node, dict):
257
+ return "%s is %s, not an object" % (what, _an(_kind(node)))
258
+ elif kind == "list" and value and not isinstance(node, list):
259
+ return "%s is %s, not a list" % (what, _an(_kind(node)))
260
+ return None
261
+
262
+
171
263
  def plan(ctx):
172
264
  old = ctx.read_json(OLD)
265
+ why = _old_problem(old) if old is not None else None
266
+ if why:
267
+ ctx.conflict(OLD, "%s, so this migration could not run" % why)
268
+ old = None
173
269
  shipped = _shipped(ctx)
174
270
  before = ctx.read(NEW)
175
271
  if before is None:
176
272
  template = ctx.template(NEW)
177
273
  doc = convert(old, _loads(template), shipped) if old is not None else None
178
- if doc is None or doc == _loads(template):
179
- ctx.create(NEW, template)
180
- else:
181
- ctx.create(NEW, (json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode())
274
+ try:
275
+ body = None if doc is None or doc == _loads(template) else \
276
+ (json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode()
277
+ except RecursionError: # a deep value carried from the old table: the default beside it holds the schema
278
+ ctx.conflict(OLD, "nested too deeply, so this migration could not run")
279
+ body = None
280
+ ctx.create(NEW, template if body is None else body)
182
281
  elif old is not None:
183
- ctx.edit_json(NEW, lambda new: convert(old, new, shipped))
282
+ try:
283
+ current = json.loads(before)
284
+ except (ValueError, RecursionError): # invalid or too deep: edit_json holds the schema
285
+ current = {}
286
+ why = _new_problem(current, _writes(old, current, shipped))
287
+ if why:
288
+ ctx.conflict(NEW, "%s, so this migration could not run" % why)
289
+ else:
290
+ ctx.edit_json(NEW, lambda new: convert(old, new, shipped))
184
291
  after = ctx.read(NEW)
185
292
  if old is not None and _loads(after) != _loads(before or ctx.template(NEW)):
186
293
  ctx.hint("%s now carries this project's risk-tier edits: run `state.py weight --mirror` to "
@@ -21,7 +21,17 @@ this directory, named `NNNN_slug.py`, numbered contiguously from `0001`, with
21
21
  moves a path a task's step may touch must patch it, or the scope guard will
22
22
  stop that task on a file that no longer exists.
23
23
  - Operations are planned, never written: the dry run must list everything.
24
- Paths are project-relative and normalised, and content is `bytes`.
24
+ Paths are project-relative and normalised, and content is `bytes`; the
25
+ `note` of `ctx.conflict` / `ctx.edit_text` and the `reason` of `ctx.delete` are
26
+ `str`. The type is checked when the operation is planned: any other type stops
27
+ the update with a SchemaError (exit 2). A call that changes nothing (an
28
+ `edit_text` whose `fn` returns its input, a `delete` of a path that is not
29
+ there) plans nothing and is not checked.
30
+ - `ctx.edit_json(path, fn)` hands `fn` a deep copy of the document and takes the
31
+ new one back. `fn` must not recurse in Python over the document (a file that
32
+ passed the deep copy can still be ~470-490 levels deep), or it guards
33
+ `RecursionError` itself; an unguarded `fn`'s `RecursionError` fails the run as
34
+ the migration's own bug, naming no file.
25
35
  - `plan(ctx)` runs again on every run until the schema version advances, and a
26
36
  conflict or an unconfirmed deletion holds it back. Every `fn` must therefore be
27
37
  idempotent: append a line only when it is not already there, or the next run
@@ -45,6 +55,11 @@ own text instead of matching a pattern against the human's prose:
45
55
  else.
46
56
  - `ctx.hint(text)` — say something to the human that is not an operation on a
47
57
  file. The right answer whenever the text is no longer the plugin's.
58
+ - `ctx.conflict(path, note)` — a file the migration cannot handle (wrong JSON
59
+ types, say) is a conflict: it holds the schema version, so the next run tries
60
+ again. `note` says why, and must be `str`: any other type stops the update
61
+ with a SchemaError (`%04d: <path> note must be str, not <type>`, exit 2), as
62
+ content that is not `bytes` does.
48
63
  - `ctx.template(path)` — the bytes the plugin ships today at that path, from the
49
64
  scaffold or the shared tree. `ctx.is_shipped(path, data)` — whether these bytes
50
65
  are a version ever shipped there (CRLF counts; `index.json` alone answers).