riskrails 3.2.2 → 4.1.0
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 +50 -84
- package/agents/ai-reviewer.md.tmpl +1 -1
- package/ai/using-riskrails.md +0 -3
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +132 -79
- package/hooks/ai-path-guard-defaults.json +1 -1
- package/hooks/context-guard.py +2 -4
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +3 -6
- package/hooks/guard/scope.py +1 -3
- package/hooks/runtime-gate.py +18 -58
- package/instructions/routing.md +2 -2
- package/package.json +1 -1
- package/scripts/ai-path +1 -1
- package/scripts/resolve-profile.py +0 -3
- package/skills/ai-audit/templates/ai-sdlc-adoption-plan.md +1 -1
- package/skills/ai-audit/templates/play-scoring-rubric.md +1 -1
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +6 -6
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/.ai/policies/security.md +1 -2
- package/skills/ai-init/templates/.ai/policies/testing.md +5 -12
- package/skills/ai-init/templates/.ai/policies/verification.md +18 -0
- package/skills/ai-init/templates/.ai/policies/weight.json +0 -1
- package/skills/ai-init/templates/.ai/policies/weight.md +1 -2
- package/skills/ai-init/templates/.ai/policies/weight.project.json +4 -0
- package/skills/ai-init/templates/.ai/project/architecture.md +1 -1
- package/skills/ai-init/templates/.ai/project/initial-assessment.md +2 -2
- package/skills/ai-init/templates/.ai/project/overview.md +1 -1
- package/skills/ai-init/templates/.ai/rules/README.md +2 -2
- package/skills/ai-init/templates/.ai/state/README.md +1 -1
- package/skills/ai-task/memory_store.py +8 -9
- package/skills/ai-task/rr_state/cli.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +2 -4
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/fs.py +5 -13
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +7 -4
- package/skills/ai-task/rr_state/profile.py +2 -4
- package/skills/ai-task/rr_state/questions_cmd.py +1 -1
- package/skills/ai-task/rr_state/report.py +3 -4
- package/skills/ai-task/rr_state/runtime.py +1 -1
- package/skills/ai-task/rr_state/start.py +7 -7
- package/skills/ai-task/sensors.py +10 -11
- package/skills/ai-task/state.py +4 -24
- package/skills/ai-task/weight.py +17 -17
- package/skills/ai-task/weight_overlay.py +38 -0
- package/skills/project-init/templates/constitution.md +2 -2
- package/skills/project-update/history/blobs/00f5e27ce2d66e3a1d8b222b013ae8fa1fed32612d91a551e0c661cee3ef9c2d +34 -0
- package/skills/project-update/history/blobs/0a6b1b6dd06a35582b2e9989a1f41c40abc29254b8e482714fcc6f0a7b16c406 +32 -0
- package/skills/project-update/history/blobs/0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6 +34 -0
- package/skills/project-update/history/blobs/0ce913a28996f546f72d570b0e5a376362e993ce4d805bc4aae57d38caa44212 +41 -0
- package/skills/project-update/history/blobs/0ea9e36446b624b73b2a821231b2ee66a357b43d584a5b23da48b3c800855e65 +41 -0
- package/skills/project-update/history/blobs/0fb1d68678a1349195b2e79433bb20174d25874e3bfd0b70410bb4e428b4e4b8 +35 -0
- package/skills/project-update/history/blobs/119b2b9d0f9c647a243540724edac6b29afa020c69aa205797bcfa9eb06bc021 +35 -0
- package/skills/project-update/history/blobs/1d0fe3ee5fb8260ba610942d4bbdfff7410fbe80ed826ac07f32985031dc2b3f +34 -0
- package/skills/project-update/history/blobs/1de9a37ed6402893dcab6e61ddbf15ffd35f15a6d84b18e675a0c5bf4e981f85 +42 -0
- package/skills/project-update/history/blobs/1f44e1a236233dcbab58064f39b69f798b35d1d9a29f8c615b5b12854ce633cb +15 -0
- package/skills/project-update/history/blobs/1f824645299579cbdd8141642ddbb4bca9fd5fe9d3112f036d8221f0811e0abf +40 -0
- package/skills/project-update/history/blobs/235fb88393cfe20dd58b35fe9785cc45baf5ffe21a13d926652d76ec68cea8b7 +35 -0
- package/skills/project-update/history/blobs/3918b4ee011d9477b9d2779e2893ab599749c406d4b09439a9748b64fbff58f3 +18 -0
- package/skills/project-update/history/blobs/449273200dfec2a56885c881ff34d40e8ecc00da53c24c2d09b7fbeb1004d255 +40 -0
- package/skills/project-update/history/blobs/4c45aee9b9fc5f6b4e1d9739e434c4094975a5a8dec6b9703fb25f3080680c77 +124 -0
- package/skills/project-update/history/blobs/4dd3fa12bc45acc8c836ab65d790248ca7504a021bca6677499d17bb3d798ed9 +64 -0
- package/skills/project-update/history/blobs/51e3f15095d7123ea8566b512479ee10a6cb2f336b7352a259a083a887bd7116 +33 -0
- package/skills/project-update/history/blobs/5497a204967bea7dab29079a6e1dfa3d8713b2d2cb294c2779378039f03a55fc +36 -0
- package/skills/project-update/history/blobs/60c4d7e7fde7049d92d84295fc13ca9a91eb2de423be08f093b3ee5d5e817ff8 +64 -0
- package/skills/project-update/history/blobs/6221b7c14cc04634e485e562d464c6860d45666a7f63e293c06bb2944ff556cc +27 -0
- package/skills/project-update/history/blobs/637971cfb844ef4285e2886a4b1c834fb2ed5c46e773adb88c2c147fea626ad3 +33 -0
- package/skills/project-update/history/blobs/65af2282eea2a51fcc27a4acf2c26c727b0961c30e1be13176369200c460c219 +34 -0
- package/skills/project-update/history/blobs/66ecd73e303e7bbc1aa8ee72435d0d10812ff7b2eff0adac59bbf0a303e2cc01 +41 -0
- package/skills/project-update/history/blobs/6a43bf32461b22b87fafe2865529dc04fa66cd00182a2a7598aa071667ca255a +35 -0
- package/skills/project-update/history/blobs/6d6dee8634abf9ac057bb206828b83a893a2afcf4f86bbe69e839a3b47d0adde +48 -0
- package/skills/project-update/history/blobs/7224ede803ea81e65a456e21b6028a0e848e8a880456c6503eebe7534ca4f6aa +74 -0
- package/skills/project-update/history/blobs/765a39a202617096c232017822ae738d37e4405430c71700dc7bc14bf1ba794e +33 -0
- package/skills/project-update/history/blobs/77577440a933d1cabd8c6f4a6401cf361a784aa09a25500a6f7b44ead8ab5e55 +41 -0
- package/skills/project-update/history/blobs/7bae90789625d6fb9071ecb412d14a8d1eb0bc5659d8d284c4eaf213e1b5de56 +67 -0
- package/skills/project-update/history/blobs/7bcdf9a0e6b85277b9b60669150224b9414d2e32821ff06f4d148eb1d18785bb +42 -0
- package/skills/project-update/history/blobs/7f834103e9d97aea44e76717bcdcbbe349c560e5b81a42ce85a1cec341041281 +4 -0
- package/skills/project-update/history/blobs/7fb3acb0211818efebc2234863db0b8c846022a7280a6d2d69f267dbf63db48c +34 -0
- package/skills/project-update/history/blobs/87f0855859ff68cc6c988cc0b631ee7f56af7fbba062172bf80e01f4982cc054 +35 -0
- package/skills/project-update/history/blobs/8abab74759766d02b5cb7b0c3e59a3d440b25a454216cd8fea325c23fd822d36 +33 -0
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/a1fb50e6c86fae1679ef3351296fd6713411a08cf8dd1790a4fd05fae8688164 +1 -0
- package/skills/project-update/history/blobs/a3df7268feb0780be0d2cf9acced6ed81780a7790f36fe024b6ea0b1bd6eb7a6 +35 -0
- package/skills/project-update/history/blobs/b28721136482dafb9fd2801af340364cb97e9f6eaa987908606c786045b8d334 +28 -0
- package/skills/project-update/history/blobs/b3726422d767f9219fea633bea77f0c99ad9af843c591e6e928e1ccd2a47f374 +27 -0
- package/skills/project-update/history/blobs/b3839425f9d0ab2efa42a6f5e9488ec2d26ca5fb8dcd694b8ab098e10743ca46 +32 -0
- package/skills/project-update/history/blobs/ba14a457e83f9dde65e4190fdfab83b912f7398c1442a2a1cb3d89bfac741cf3 +8 -0
- package/skills/project-update/history/blobs/bceb7189444bb6291b262ca08e34db01010dda2f6e64d696231ec3c343c95c66 +34 -0
- package/skills/project-update/history/blobs/bf82a2bb4fce1bd349ff66118930a01d61a7af8e2bd8d200c721d43afc58c713 +34 -0
- package/skills/project-update/history/blobs/c4dbeb180292a6199ad73a3be861ece07c1f8bf6bf0cc7ca204412bc8e1bb1d8 +31 -0
- package/skills/project-update/history/blobs/c644838866df2001a42501a988dd7b77aacf862bf09d22b465f3cd8a22c6b6eb +33 -0
- package/skills/project-update/history/blobs/c980d74f76a4bdfb38c94b573ac10a54eb50b850b052e15fa6f2442889b36ccb +32 -0
- package/skills/project-update/history/blobs/ceed2f3bd937f862d0e6ca59ce1b1e3603e19a0d7f85d90d6dfeb7bf690153be +29 -0
- package/skills/project-update/history/blobs/d51ea2e893482a20c5882056cf8043327ca1dc1c957927f6746929a47ee8368e +45 -0
- package/skills/project-update/history/blobs/d78f5e0bb1380259fce2cce009785a9a11f6de8b87a160080b9872c059f95060 +34 -0
- package/skills/project-update/history/blobs/d97f46a2cca020406252b1f7baa4184617fa71faca3885e2379c44ae4e1d9327 +67 -0
- package/skills/project-update/history/blobs/da3ecd4c4c414c93d04488685931df39a56bf959bfeb9c45cb1aea2c13bd991b +38 -0
- package/skills/project-update/history/blobs/e129bf71a5a922e24b2c1bc8daa8a5d2b5589b0069770e17ce17e7db5ed4e7a4 +27 -0
- package/skills/project-update/history/blobs/e504529519a371892838725f8b89b5d32e3300e345bcbf2643a3a1a48434b3dd +24 -0
- package/skills/project-update/history/blobs/e9e62d03ca70b3dffccef8dceeb214173352cfef005145e519f0e037993f1c03 +36 -0
- package/skills/project-update/history/blobs/effefbaaf4beb8f1ba55081d6b7e74bf25ebb91045dd10613418f17b95fa7182 +33 -0
- package/skills/project-update/history/blobs/f6af4581bafbf62b257d57e06c3ae2c5998aeca608ee7df037fcc3c3e4b563a9 +34 -0
- package/skills/project-update/history/blobs/fc35045777d29630303430752a651b3e553580a581036a02cc103eb96a46f4b0 +34 -0
- package/skills/project-update/history/blobs/fdc21345c77f92a42d244aa104f393f439a0ff9981a9fd462bd837e8a47931bb +48 -0
- package/skills/project-update/history/index.json +34 -14
- package/skills/project-update/render_instructions.py +12 -45
- package/skills/project-update/update.py +757 -1172
- package/skills/rr-setup/SKILL.md +114 -97
- package/skills/rr-setup/scaffold_ai.py +15 -9
- package/skills/rr-setup/setup.py +462 -164
- package/skills/rr-status/status.py +7 -10
- package/skills/usage-report/usage-report.py +4 -5
- package/skills/ai-audit/SKILL.md +0 -9
- package/skills/ai-init/SKILL.md +0 -9
- package/skills/ai-status/SKILL.md +0 -9
- package/skills/ai-task/SKILL.md +0 -9
- package/skills/project-init/SKILL.md +0 -9
- package/skills/project-update/SKILL.md +0 -9
- package/skills/project-update/adopt-map.json +0 -91
- package/skills/project-update/adopt.py +0 -2225
- package/skills/project-update/migrations/0001_schema_version.py +0 -13
- package/skills/project-update/migrations/0002_task_journal.py +0 -123
- package/skills/project-update/migrations/0003_context_diet.py +0 -65
- package/skills/project-update/migrations/0004_deterministic_gates.py +0 -57
- package/skills/project-update/migrations/0005_runtimes_and_plans.py +0 -35
- package/skills/project-update/migrations/0006_shared_files.py +0 -154
- package/skills/project-update/migrations/0007_weight_model.py +0 -295
- package/skills/project-update/migrations/0008_renames.py +0 -43
- package/skills/project-update/migrations/0009_state_file.py +0 -25
- package/skills/project-update/migrations/0010_dialog_switch.py +0 -30
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +0 -37
- package/skills/project-update/migrations/README.md +0 -76
- package/skills/project-update/migrations/__init__.py +0 -186
- package/skills/sdlc-intent/SKILL.md +0 -9
- package/skills/sdlc-plan/SKILL.md +0 -9
- package/skills/sdlc-spec/SKILL.md +0 -9
- package/skills/usage-report/SKILL.md +0 -10
- /package/{ai/workflows/refactoring.md → skills/project-update/history/blobs/36cb593293496fbcf38df828a9b9b2bc0d5a53333f584a5319ebc3d799016e24} +0 -0
- /package/{ai/workflows/feature.md → skills/project-update/history/blobs/56e16072c2f3edc6c8897c690d09a11d12fc414c3e3f361f359f7dc609a21282} +0 -0
- /package/{ai/workflows/hotfix.md → skills/project-update/history/blobs/8cbefbf1aa412bae3e5019acd40b8fe0598fceba6c781906b3bc6dbd014f6b23} +0 -0
- /package/{ai/workflows/bugfix.md → skills/project-update/history/blobs/af56dc16e44ae1d8c3dac4c3ca82e2163b719553c02ea455e77430bab9a14089} +0 -0
- /package/{ai/workflows/investigation.md → skills/project-update/history/blobs/b9e09cb24958cd6831589997558974bd6356305306e8f9685e226bcdc30d60ac} +0 -0
- /package/{ai/agents/manager.md → skills/project-update/history/blobs/d0cf6388f35a92a0cf607d33909278382f6d7b6a4c32e281b9f9e0ea652c7b91} +0 -0
|
@@ -1,2225 +0,0 @@
|
|
|
1
|
-
"""Adopt a foreign AI-tool structure into RiskRails' layout (`update.py --adopt`).
|
|
2
|
-
|
|
3
|
-
This module grows over WP6's plan steps; update.py keeps only flag parsing and the
|
|
4
|
-
calls into it, so new behaviour lives here.
|
|
5
|
-
|
|
6
|
-
Exit codes used by the adopt family (update.py returns them):
|
|
7
|
-
4 unmapped, a failed check or an invalid proposal: a decision is needed
|
|
8
|
-
5 refused: a gate that needs a human, a clean tree or a finished task did not hold
|
|
9
|
-
"""
|
|
10
|
-
import hashlib
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import re
|
|
14
|
-
import subprocess
|
|
15
|
-
import sys
|
|
16
|
-
from collections import OrderedDict
|
|
17
|
-
from datetime import datetime, timezone
|
|
18
|
-
|
|
19
|
-
import render_instructions
|
|
20
|
-
|
|
21
|
-
REFUSED = "ADOPT_REFUSED"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
|
|
25
|
-
# and the names Codex gives its tool calls. Present counts, an empty value too.
|
|
26
|
-
AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
|
|
27
|
-
"CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_mode():
|
|
31
|
-
"""RISKRAILS_TEST=1, or CLAUDE_AGENTIC_TEST=1 before 3.4b (R35, one version)."""
|
|
32
|
-
return os.environ.get("RISKRAILS_TEST", os.environ.get("CLAUDE_AGENTIC_TEST")) == "1"
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
def human_gate():
|
|
42
|
-
"""(passed, via, why) for --confirm-delete. A terminal outside any agent
|
|
43
|
-
session is a human: via "terminal". Otherwise a launcher that declared the
|
|
44
|
-
run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
|
|
45
|
-
approve records it. Anything else is refused, and `why` says what was seen —
|
|
46
|
-
a terminal alone is not enough, since an agent can open a pty (F3).
|
|
47
|
-
|
|
48
|
-
Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
|
|
49
|
-
the two skills are installed independently and neither imports the other."""
|
|
50
|
-
markers = [name for name in AGENT_MARKERS if name in os.environ]
|
|
51
|
-
if os.isatty(0) and not markers:
|
|
52
|
-
return True, "terminal", ""
|
|
53
|
-
if ai_unattended():
|
|
54
|
-
return True, "unattended", ""
|
|
55
|
-
if markers:
|
|
56
|
-
return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
|
|
57
|
-
"outside Claude Code or Codex, not through the `!` prefix"
|
|
58
|
-
% (", ".join(markers), "is" if len(markers) == 1 else "are"))
|
|
59
|
-
return False, None, "this run has no terminal"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# The name typed into --confirm-delete is the audit trail; the template's own
|
|
63
|
-
# "<your name>" copied verbatim names no one. The whole value, not a substring.
|
|
64
|
-
PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def placeholder_name(name):
|
|
68
|
-
return bool(PLACEHOLDER_NAME_RE.match(name or ""))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def clean_tree(root):
|
|
72
|
-
"""The paths `git status` reports as changed or untracked under root, or
|
|
73
|
-
None when root is not inside a git work tree. `-z` keeps paths with spaces
|
|
74
|
-
or quotes intact; a rename entry carries its source as a second record,
|
|
75
|
-
which is skipped."""
|
|
76
|
-
try:
|
|
77
|
-
proc = subprocess.run(["git", "-C", root, "status", "--porcelain", "-z", "--untracked-files=all"],
|
|
78
|
-
capture_output=True, check=False)
|
|
79
|
-
except OSError:
|
|
80
|
-
return None
|
|
81
|
-
if proc.returncode != 0:
|
|
82
|
-
return None
|
|
83
|
-
records = proc.stdout.decode("utf-8", "surrogateescape").split("\0")
|
|
84
|
-
paths, skip = [], False
|
|
85
|
-
for rec in records:
|
|
86
|
-
if skip:
|
|
87
|
-
skip = False
|
|
88
|
-
continue
|
|
89
|
-
if len(rec) < 4:
|
|
90
|
-
continue
|
|
91
|
-
status, path = rec[:2], rec[3:]
|
|
92
|
-
paths.append(path)
|
|
93
|
-
if "R" in status or "C" in status:
|
|
94
|
-
skip = True
|
|
95
|
-
return paths
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def refuse(reason, how):
|
|
99
|
-
"""Print the refusal the way every adopt gate does — the marker on the first
|
|
100
|
-
stdout line, then what to do — and return exit code 5."""
|
|
101
|
-
print("%s: %s" % (REFUSED, reason))
|
|
102
|
-
print(" %s" % how)
|
|
103
|
-
return 5
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def safe_rel(path):
|
|
107
|
-
"""A path adopt may read, write or delete: relative, normalised, inside the
|
|
108
|
-
project and outside .git/. adopt.json, decisions.json and proposals are
|
|
109
|
-
project files anyone can edit, so every path taken from them passes this."""
|
|
110
|
-
if not isinstance(path, str) or not path or path.startswith("/") or "\\" in path or "\0" in path:
|
|
111
|
-
return False
|
|
112
|
-
parts = path.split("/")
|
|
113
|
-
return all(p not in ("", ".", "..") for p in parts) and parts[0] != ".git"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def budget_offenders(root, files, caps, block_of):
|
|
117
|
-
"""R21: one line per root instruction file over budget — its managed block
|
|
118
|
-
over `caps["project"]`, or the whole file over `caps["skeleton"]`. Files that
|
|
119
|
-
do not exist are skipped; a file under both budgets prints nothing."""
|
|
120
|
-
out = []
|
|
121
|
-
for name in files:
|
|
122
|
-
path = os.path.join(root, name)
|
|
123
|
-
if not os.path.isfile(path):
|
|
124
|
-
continue
|
|
125
|
-
with open(path, encoding="utf-8") as fh:
|
|
126
|
-
text = fh.read()
|
|
127
|
-
block = block_of(text)
|
|
128
|
-
size = len(text.encode("utf-8"))
|
|
129
|
-
if block is not None and caps.get("project") and len(block.encode("utf-8")) > caps["project"]:
|
|
130
|
-
out.append("%s block %d B over the project budget of %d B"
|
|
131
|
-
% (name, len(block.encode("utf-8")), caps["project"]))
|
|
132
|
-
if caps.get("skeleton") and size > caps["skeleton"]:
|
|
133
|
-
out.append("%s file %d B over the skeleton budget of %d B" % (name, size, caps["skeleton"]))
|
|
134
|
-
return out
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# ---------------------------------------------------------------------------
|
|
138
|
-
# The mapping table (spec I2) and its matcher
|
|
139
|
-
# ---------------------------------------------------------------------------
|
|
140
|
-
|
|
141
|
-
TRANSFORMS = ("copy", "append-section", "rule", "instruction-file", "drop", "ignore")
|
|
142
|
-
MAP_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "adopt-map.json")
|
|
143
|
-
INCOMPLETE = "ADOPT_INCOMPLETE"
|
|
144
|
-
# Never walked for foreign files: not ours to read, or not text.
|
|
145
|
-
# A child holding a .git entry (worktree, nested repository, submodule) is pruned too.
|
|
146
|
-
WALK_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components", "third_party",
|
|
147
|
-
"Pods", "site-packages", ".venv", ".pnpm", ".yarn", "__pycache__"}
|
|
148
|
-
SECRET_RE = re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*\S{8,}"
|
|
149
|
-
r"|-----BEGIN [A-Z ]*PRIVATE KEY")
|
|
150
|
-
PLACEHOLDER_RE = re.compile(r"\{(dir|stem|rel)\}")
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
class TableError(Exception):
|
|
154
|
-
"""adopt-map.json is not a table this code can run: exit 2, like a bad
|
|
155
|
-
migration registry."""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def glob_re(pattern):
|
|
159
|
-
"""`*` stops at `/`, `**` crosses it, `**/` also matches no directory at all."""
|
|
160
|
-
out, i = [], 0
|
|
161
|
-
while i < len(pattern):
|
|
162
|
-
if pattern.startswith("**/", i):
|
|
163
|
-
out.append("(?:.*/)?")
|
|
164
|
-
i += 3
|
|
165
|
-
elif pattern.startswith("**", i):
|
|
166
|
-
out.append(".*")
|
|
167
|
-
i += 2
|
|
168
|
-
elif pattern[i] == "*":
|
|
169
|
-
out.append("[^/]*")
|
|
170
|
-
i += 1
|
|
171
|
-
elif pattern[i] == "?":
|
|
172
|
-
out.append("[^/]")
|
|
173
|
-
i += 1
|
|
174
|
-
else:
|
|
175
|
-
out.append(re.escape(pattern[i]))
|
|
176
|
-
i += 1
|
|
177
|
-
return re.compile("".join(out) + r"\Z")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def load_table(path=MAP_FILE):
|
|
181
|
-
try:
|
|
182
|
-
with open(path, encoding="utf-8") as fh:
|
|
183
|
-
table = json.load(fh)
|
|
184
|
-
except (OSError, ValueError, RecursionError) as exc:
|
|
185
|
-
raise TableError("%s: %s" % (path, exc)) from exc
|
|
186
|
-
tools = table.get("tools")
|
|
187
|
-
rows = table.get("rows")
|
|
188
|
-
if table.get("version") != 1 or not isinstance(tools, dict) or not isinstance(rows, list):
|
|
189
|
-
raise TableError("%s: needs version 1, a tools object and a rows list" % path)
|
|
190
|
-
for n, row in enumerate(rows, 1):
|
|
191
|
-
where = "%s row %d" % (path, n)
|
|
192
|
-
if row.get("tool") not in tools:
|
|
193
|
-
raise TableError("%s: unknown tool %r" % (where, row.get("tool")))
|
|
194
|
-
if row.get("transform") not in TRANSFORMS:
|
|
195
|
-
raise TableError("%s: unknown transform %r" % (where, row.get("transform")))
|
|
196
|
-
source = row.get("source")
|
|
197
|
-
if not isinstance(source, str) or not source or source.startswith("/") or ".." in source.split("/"):
|
|
198
|
-
raise TableError("%s: bad source glob %r" % (where, source))
|
|
199
|
-
if row["transform"] in ("copy", "append-section", "rule") and not row.get("dest"):
|
|
200
|
-
raise TableError("%s: a %s row needs a dest" % (where, row["transform"]))
|
|
201
|
-
if row["transform"] in ("drop", "ignore") and not row.get("why"):
|
|
202
|
-
raise TableError("%s: a %s row needs a why" % (where, row["transform"]))
|
|
203
|
-
row["_re"] = glob_re(source)
|
|
204
|
-
row["_n"] = n
|
|
205
|
-
return table
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def under_root(path, root):
|
|
209
|
-
"""A root is a directory (`x/`), an exact file, or a glob naming either."""
|
|
210
|
-
if root.endswith("/"):
|
|
211
|
-
return path.startswith(root)
|
|
212
|
-
if any(c in root for c in "*?"):
|
|
213
|
-
rx = glob_re(root)
|
|
214
|
-
parts = path.split("/")
|
|
215
|
-
return any(rx.match("/".join(parts[:k])) for k in range(1, len(parts) + 1))
|
|
216
|
-
return path == root or path.startswith(root + "/")
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def walk(root):
|
|
220
|
-
"""Every file under root, relative with `/`, skipping WALK_SKIP directories
|
|
221
|
-
and nested git trees."""
|
|
222
|
-
out = []
|
|
223
|
-
for here, dirs, files in os.walk(root):
|
|
224
|
-
dirs[:] = sorted(d for d in dirs if d not in WALK_SKIP
|
|
225
|
-
and not os.path.lexists(os.path.join(here, d, ".git")))
|
|
226
|
-
rel = os.path.relpath(here, root)
|
|
227
|
-
for name in sorted(files):
|
|
228
|
-
out.append(name if rel == "." else "%s/%s" % (rel.replace(os.sep, "/"), name))
|
|
229
|
-
return out
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def has_signature(root, sig):
|
|
233
|
-
path = os.path.join(root, sig.rstrip("/"))
|
|
234
|
-
return os.path.isdir(path) if sig.endswith("/") else os.path.isfile(path)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
# ---------------------------------------------------------------------------
|
|
238
|
-
# Planning
|
|
239
|
-
# ---------------------------------------------------------------------------
|
|
240
|
-
|
|
241
|
-
class Adoption:
|
|
242
|
-
"""What an adopt run found, beside the shared Plan: the lines that are not
|
|
243
|
-
writes (spec I3) and the facts later steps record."""
|
|
244
|
-
|
|
245
|
-
def __init__(self, mode):
|
|
246
|
-
self.mode = mode
|
|
247
|
-
self.detected = OrderedDict() # tool -> OrderedDict(group -> count)
|
|
248
|
-
self.notes = [] # (action, subject, note), printed in I3 order
|
|
249
|
-
self.unmapped = [] # (path, tool)
|
|
250
|
-
self.split = [] # split candidates awaiting a proposal or the fallback
|
|
251
|
-
self.candidates = [] # (Candidate, row number): every split candidate
|
|
252
|
-
self.split_errors = [] # (file, reason): a split that cannot be applied, exit 4
|
|
253
|
-
self.split_done = OrderedDict() # file -> what plan_split recorded (adopt.json.split)
|
|
254
|
-
self.split_before = {} # target -> bytes before the split wrote it (R9)
|
|
255
|
-
self.split_dests = [] # every target a split writes, the instruction file included
|
|
256
|
-
self.dropped = [] # dropped.jsonl records (spec I7)
|
|
257
|
-
self.sources = [] # (path, tool, transform, row number, cleanup)
|
|
258
|
-
self.destinations = [] # (dest-or-path, tool, router, coexist bool); router is the
|
|
259
|
-
# row's text or None, or a dict (A5): {"text", "file"} for a
|
|
260
|
-
# row of its own, {"text", "globs"} in coexist
|
|
261
|
-
self.rows_used = set()
|
|
262
|
-
self.checks = {} # "no-line-lost"/"no-dangling" -> (status, detail text)
|
|
263
|
-
self.moves = {} # A3: source path -> its one destination, for the rewrite
|
|
264
|
-
self.adopted_lines = {} # A3: destination -> normalised lines that came from a source
|
|
265
|
-
self.instruction_targets = [] # A4: the root instruction files that point at ALWAYS_FILE
|
|
266
|
-
self.wants_pointer = False # A4: an always rule was planned
|
|
267
|
-
|
|
268
|
-
def note(self, action, subject, text):
|
|
269
|
-
self.notes.append((action, subject, text))
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def slugify(text):
|
|
273
|
-
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
274
|
-
return slug or "rule"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def expand(dest, path, roots):
|
|
278
|
-
"""Fill {dir}, {stem} and {rel} (the path under the tool's longest matching
|
|
279
|
-
directory root, without its extension, `/` -> `-`)."""
|
|
280
|
-
parent, name = os.path.split(path)
|
|
281
|
-
stem = os.path.splitext(name)[0]
|
|
282
|
-
under = [r for r in roots if r.endswith("/") and path.startswith(r)]
|
|
283
|
-
rel = path[len(max(under, key=len)):] if under else path
|
|
284
|
-
rel = os.path.splitext(rel)[0].replace("/", "-")
|
|
285
|
-
values = {"dir": os.path.basename(parent), "stem": stem, "rel": rel}
|
|
286
|
-
return PLACEHOLDER_RE.sub(lambda m: values[m.group(1)], dest)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def frontmatter(text):
|
|
290
|
-
"""(dict of key -> [values], body, the frontmatter lines) or ({}, text, [])."""
|
|
291
|
-
match = render_instructions.FRONTMATTER_RE.match(text)
|
|
292
|
-
if not match:
|
|
293
|
-
return {}, text, []
|
|
294
|
-
front = render_instructions._frontmatter(match.group("body"), "frontmatter") # pylint: disable=protected-access
|
|
295
|
-
return front, text[match.end():], match.group("body").split("\n")
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def literal_dir(pattern):
|
|
299
|
-
parts = pattern.split("/")
|
|
300
|
-
lead = []
|
|
301
|
-
for part in parts[:-1]:
|
|
302
|
-
if any(c in part for c in "*?[{"):
|
|
303
|
-
break
|
|
304
|
-
lead.append(part)
|
|
305
|
-
return "/".join(lead)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def rule_content(plan, path, text, row, tool, adoption):
|
|
309
|
-
"""The `rule` transform: frontmatter rewritten, body verbatim. Returns
|
|
310
|
-
(dest, content, note). Every frontmatter line is logged as rewritten."""
|
|
311
|
-
fmap = row.get("frontmatter", {})
|
|
312
|
-
try:
|
|
313
|
-
front, body, raw = frontmatter(text)
|
|
314
|
-
except render_instructions.RenderError as exc:
|
|
315
|
-
return None, None, "frontmatter not readable: %s" % exc
|
|
316
|
-
globs = []
|
|
317
|
-
for value in front.get(fmap.get("paths", ""), []):
|
|
318
|
-
globs.extend(g.strip() for g in value.split(",") if g.strip())
|
|
319
|
-
always = False
|
|
320
|
-
if fmap.get("always"):
|
|
321
|
-
key, _, want = fmap["always"].partition("=")
|
|
322
|
-
always = [v.lower() for v in front.get(key, [])] == [want.lower()]
|
|
323
|
-
if not raw and fmap.get("always_when_absent"):
|
|
324
|
-
always = True
|
|
325
|
-
title = " ".join(front.get(fmap.get("title", ""), [])).strip()
|
|
326
|
-
for n, line in enumerate(raw, 2):
|
|
327
|
-
if line.strip():
|
|
328
|
-
adoption.dropped.append({"source": path, "line": n, "text": line,
|
|
329
|
-
"reason": "rewritten: frontmatter", "by": "transform:rule"})
|
|
330
|
-
name = os.path.basename(path)
|
|
331
|
-
stem = name[:-len(".instructions.md")] if name.endswith(".instructions.md") else os.path.splitext(name)[0]
|
|
332
|
-
base = row["source"].split("*")[0]
|
|
333
|
-
sub = os.path.dirname(path[len(base):]) if path.startswith(base) else ""
|
|
334
|
-
slug = slugify((sub + "-" if sub else "") + stem)
|
|
335
|
-
dirs = []
|
|
336
|
-
for g in globs:
|
|
337
|
-
d = literal_dir(g)
|
|
338
|
-
if d and os.path.isdir(os.path.join(plan.root, d)) and d not in dirs:
|
|
339
|
-
dirs.append(d)
|
|
340
|
-
head = ("# %s\n\n" % title) if title else ""
|
|
341
|
-
body = body.lstrip("\n")
|
|
342
|
-
if dirs and not always:
|
|
343
|
-
front_out = "---\ndirs: [%s]\npaths: [%s]\n---\n\n" % (
|
|
344
|
-
", ".join(dirs), ", ".join('"%s"' % g for g in globs))
|
|
345
|
-
dest = ".ai/rules/%s.md" % slug
|
|
346
|
-
note = "rule: %s -> paths:, dirs: [%s]" % (fmap.get("paths"), ", ".join(dirs))
|
|
347
|
-
else:
|
|
348
|
-
front_out = ("---\npaths: [%s]\n---\n\n" % ", ".join('"%s"' % g for g in globs)) if globs else ""
|
|
349
|
-
dest = ".ai/policies/adopted/%s-%s.md" % (tool, slug)
|
|
350
|
-
note = "rule: %s" % ("always" if always else "on demand, router row")
|
|
351
|
-
return dest, (front_out + head + body).encode("utf-8"), note
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
DECISION_DOCS_RE = re.compile(r"\Adocs/sdlc/(?:intent|specs|plans)/[^/]")
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def decision_dest_ok(dest):
|
|
358
|
-
"""I6: a human's `copy` goes to the I5 allow-list or under docs/sdlc/{intent,specs,plans}/."""
|
|
359
|
-
return safe_rel(dest) and bool(ALLOWED_DEST_RE.match(dest) or DECISION_DOCS_RE.match(dest))
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def plan_file(plan, adoption, path, row, tool, roots):
|
|
363
|
-
"""Plan one matched source."""
|
|
364
|
-
transform = row["transform"]
|
|
365
|
-
adoption.rows_used.add(row["_n"])
|
|
366
|
-
if transform == "ignore":
|
|
367
|
-
adoption.note("ignored", path, "[%s] %s" % (tool, row["why"]))
|
|
368
|
-
return
|
|
369
|
-
adoption.sources.append((path, tool, transform, row["_n"], bool(row.get("cleanup"))))
|
|
370
|
-
data = plan.read(path) or b""
|
|
371
|
-
for n, line in enumerate(data.decode("utf-8", "replace").split("\n"), 1):
|
|
372
|
-
if SECRET_RE.search(line):
|
|
373
|
-
adoption.note("hint", "%s:%d" % (path, n), "looks like a secret (value not shown)")
|
|
374
|
-
if transform == "drop":
|
|
375
|
-
adoption.dropped.append({"source": path, "line": "*", "sha": "sha256:" + hashlib.sha256(data).hexdigest(),
|
|
376
|
-
"reason": row["why"], "by": "transform:drop"})
|
|
377
|
-
adoption.note("dropped", path, "[%s] %s" % (tool, row["why"]))
|
|
378
|
-
return
|
|
379
|
-
if adoption.mode == "coexist":
|
|
380
|
-
adoption.note("kept", path, "[%s] %s, kept in place (coexist)" % (tool, transform))
|
|
381
|
-
globs = transform == "rule" and has_globs(data, row)
|
|
382
|
-
adoption.destinations.append((path, tool, {"text": row.get("router"), "globs": globs}, True))
|
|
383
|
-
return
|
|
384
|
-
text = data.decode("utf-8", "replace")
|
|
385
|
-
if not is_binary(data) and is_utf8(data): # a lossy re-encode would change bytes nobody rewrote
|
|
386
|
-
new_text = rewrite_refs(path, text, adoption.moves, adoption, transform)
|
|
387
|
-
if new_text != text:
|
|
388
|
-
text, data = new_text, new_text.encode("utf-8")
|
|
389
|
-
if transform == "rule":
|
|
390
|
-
dest, content, note = rule_content(plan, path, text, row, tool, adoption)
|
|
391
|
-
if dest is None:
|
|
392
|
-
adoption.unmapped.append((path, tool))
|
|
393
|
-
adoption.note("unmapped", path, "[%s] %s" % (tool, note))
|
|
394
|
-
return
|
|
395
|
-
if note == "rule: always" and adoption.mode == "migrate":
|
|
396
|
-
append_always(plan, adoption, path, text, tool, row)
|
|
397
|
-
return
|
|
398
|
-
elif transform == "append-section":
|
|
399
|
-
dest = expand(row["dest"], path, roots)
|
|
400
|
-
# Not "## Adopted from <path>" (spec I2): that heading is itself a
|
|
401
|
-
# reference to the old path, and would dangle after cleanup (I8).
|
|
402
|
-
heading = "## Adopted from %s (%s)" % (TOOL_TITLE.get(tool, tool), os.path.basename(path))
|
|
403
|
-
current = (plan.read(dest) or b"").decode("utf-8", "replace")
|
|
404
|
-
note_adopted(adoption, dest, text)
|
|
405
|
-
updated = rewrite_adopted(adoption, dest, current)
|
|
406
|
-
if heading in current.split("\n"):
|
|
407
|
-
if updated != current:
|
|
408
|
-
plan.add("adopt", dest, note="[%s] append-section%s" % (tool, REFS_UPDATED),
|
|
409
|
-
content=updated.encode("utf-8"), src=path, tool=tool)
|
|
410
|
-
adoption.destinations.append((dest, tool, row.get("router"), False))
|
|
411
|
-
return # already appended by an earlier run
|
|
412
|
-
content = (updated.rstrip("\n") + "\n\n" + heading + "\n\n" + text.strip("\n") + "\n").lstrip("\n").encode("utf-8")
|
|
413
|
-
principles = sum(1 for line in text.split("\n") if line.startswith("### "))
|
|
414
|
-
note = "append-section" + (" (hint: %d principles, at most 15)" % principles if principles > 15 else "") \
|
|
415
|
-
+ (REFS_UPDATED if updated != current else "")
|
|
416
|
-
else: # copy
|
|
417
|
-
dest = expand(row["dest"], path, roots)
|
|
418
|
-
content, note = data, "copy"
|
|
419
|
-
note_adopted(adoption, dest, text)
|
|
420
|
-
router = row.get("router")
|
|
421
|
-
if transform == "rule" and dest.startswith(".ai/policies/adopted/"):
|
|
422
|
-
# A5: an on-demand rule is found by what it is about, so it gets a row of its own.
|
|
423
|
-
router = {"text": rule_trigger(content), "file": True}
|
|
424
|
-
existing = plan.read(dest)
|
|
425
|
-
tag = "[%s] %s" % (tool, note)
|
|
426
|
-
if transform == "append-section":
|
|
427
|
-
plan.add("adopt", dest, note=tag, content=content, src=path, tool=tool)
|
|
428
|
-
adoption.destinations.append((dest, tool, router, False))
|
|
429
|
-
elif existing is not None and existing != content and only_new_refs(existing, content, adoption.moves):
|
|
430
|
-
# A3: adopted earlier, untouched since; a file moved by this run is
|
|
431
|
-
# named in it, and only that reference changes.
|
|
432
|
-
plan.add("adopt", dest, note=tag + "; references to newly moved files updated", content=content,
|
|
433
|
-
src=path, tool=tool)
|
|
434
|
-
adoption.destinations.append((dest, tool, router, False))
|
|
435
|
-
elif existing is not None and existing != content:
|
|
436
|
-
plan.add("conflict", dest, note=tag + "; the destination exists and differs", src=path, tool=tool)
|
|
437
|
-
elif existing != content:
|
|
438
|
-
plan.add("adopt", dest, note=tag, content=content, src=path, tool=tool)
|
|
439
|
-
adoption.destinations.append((dest, tool, router, False))
|
|
440
|
-
else:
|
|
441
|
-
adoption.destinations.append((dest, tool, router, False)) # already there
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def latest_with(root, name):
|
|
445
|
-
"""The greatest `.ai/reports/adopt-*` directory holding `name`, or None."""
|
|
446
|
-
base = os.path.join(root, ".ai", "reports")
|
|
447
|
-
try:
|
|
448
|
-
dirs = sorted((d for d in os.listdir(base) if d.startswith("adopt-")), reverse=True)
|
|
449
|
-
except OSError:
|
|
450
|
-
return None
|
|
451
|
-
for d in dirs:
|
|
452
|
-
if os.path.isfile(os.path.join(base, d, name)):
|
|
453
|
-
return ".ai/reports/%s" % d
|
|
454
|
-
return None
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def load_decisions(root):
|
|
458
|
-
where = latest_with(root, "decisions.json")
|
|
459
|
-
if where is None:
|
|
460
|
-
return {}
|
|
461
|
-
try:
|
|
462
|
-
with open(os.path.join(root, where, "decisions.json"), encoding="utf-8") as fh:
|
|
463
|
-
return json.load(fh)
|
|
464
|
-
except (OSError, ValueError, RecursionError) as exc:
|
|
465
|
-
raise TableError("%s/decisions.json: %s" % (where, exc)) from exc
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def moves_of(plan, planned):
|
|
469
|
-
"""A3: every source this run writes to exactly one destination, found
|
|
470
|
-
before anything is planned so a text can name a file planned after it."""
|
|
471
|
-
moves = {}
|
|
472
|
-
scratch = Adoption("migrate") # rule_content logs into it; nothing is kept
|
|
473
|
-
for path, row, tool, roots in planned:
|
|
474
|
-
transform = row["transform"]
|
|
475
|
-
if transform in ("copy", "append-section") and row.get("dest"):
|
|
476
|
-
moves[path] = expand(row["dest"], path, roots)
|
|
477
|
-
elif transform == "rule":
|
|
478
|
-
text = (plan.read(path) or b"").decode("utf-8", "replace")
|
|
479
|
-
dest, _content, note = rule_content(plan, path, text, row, tool, scratch)
|
|
480
|
-
if dest and note == "rule: always":
|
|
481
|
-
moves[path] = ALWAYS_FILE # A4: where plan_file will really put it
|
|
482
|
-
elif dest:
|
|
483
|
-
moves[path] = dest
|
|
484
|
-
return moves
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
ALWAYS_FILE = ".ai/policies/adopted/always.md"
|
|
488
|
-
ALWAYS_POINTER = "Read `%s` first." % ALWAYS_FILE
|
|
489
|
-
ALWAYS_HEAD = "# Always applied\n\nRules their own tool loaded on every request, adopted as they were.\n"
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def append_always(plan, adoption, path, text, tool, row):
|
|
493
|
-
"""A4 as amended: a rule its tool loads on every request goes into one
|
|
494
|
-
file, ALWAYS_FILE, under a heading of its own — the rule's path under its
|
|
495
|
-
row's root, so `a/index.mdc` and `b/index.mdc` stay apart — idempotent by
|
|
496
|
-
that heading, as append-section is. Every root instruction file with the
|
|
497
|
-
managed block gets ALWAYS_POINTER, outside the block; the planned pointer
|
|
498
|
-
is placed after the splits, which keep it (Candidate, `rewritten`)."""
|
|
499
|
-
front, body, _raw = frontmatter(text)
|
|
500
|
-
body = body.strip("\n")
|
|
501
|
-
base = row["source"].split("*")[0]
|
|
502
|
-
label = path[len(base):] if path.startswith(base) and len(path) > len(base) else os.path.basename(path)
|
|
503
|
-
heading = "## Adopted from %s (%s)" % (TOOL_TITLE.get(tool, tool), label)
|
|
504
|
-
title = " ".join(front.get(row.get("frontmatter", {}).get("title", ""), [])).strip()
|
|
505
|
-
section = (("_%s_\n\n" % title) if title else "") + body
|
|
506
|
-
note_adopted(adoption, ALWAYS_FILE, section)
|
|
507
|
-
current = (plan.read(ALWAYS_FILE) or b"").decode("utf-8", "replace") or ALWAYS_HEAD
|
|
508
|
-
updated = rewrite_adopted(adoption, ALWAYS_FILE, current)
|
|
509
|
-
refs = REFS_UPDATED if updated != current else ""
|
|
510
|
-
if heading not in current.split("\n"):
|
|
511
|
-
content = (updated.rstrip("\n") + "\n\n" + heading + "\n\n" + section + "\n").encode("utf-8")
|
|
512
|
-
plan.add("adopt", ALWAYS_FILE, note="[%s] rule: always%s" % (tool, refs), content=content, src=path, tool=tool)
|
|
513
|
-
elif refs: # adopted by an earlier run; a file this run moves is named in it
|
|
514
|
-
plan.add("adopt", ALWAYS_FILE, note="[%s] rule: always%s" % (tool, refs),
|
|
515
|
-
content=updated.encode("utf-8"), src=path, tool=tool)
|
|
516
|
-
if adoption.instruction_targets:
|
|
517
|
-
adoption.destinations.append((ALWAYS_FILE, tool, {"skip": True}, False))
|
|
518
|
-
adoption.wants_pointer = True
|
|
519
|
-
else: # no instruction file to point from: the router's first row does it
|
|
520
|
-
adoption.destinations.append((ALWAYS_FILE, tool, {"text": "every task, first", "file": True}, False))
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def pointer_bytes(adoption, target, text):
|
|
524
|
-
"""The bytes plan_pointers will add to `target`: 0 when it adds none."""
|
|
525
|
-
if not adoption.wants_pointer or target not in adoption.instruction_targets \
|
|
526
|
-
or ALWAYS_POINTER in text.split("\n"):
|
|
527
|
-
return 0
|
|
528
|
-
return len(ALWAYS_POINTER.encode("utf-8")) + 2
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
def plan_pointers(plan, adoption):
|
|
532
|
-
"""A4: the pointer to ALWAYS_FILE in each root instruction file, once."""
|
|
533
|
-
for target in adoption.instruction_targets if adoption.wants_pointer else ():
|
|
534
|
-
current = (plan.read(target) or b"").decode("utf-8", "replace")
|
|
535
|
-
if ALWAYS_POINTER in current.split("\n"):
|
|
536
|
-
continue
|
|
537
|
-
content = (current.rstrip("\n") + "\n\n" + ALWAYS_POINTER + "\n").encode("utf-8")
|
|
538
|
-
plan.add("adopt", target, note="pointer to %s" % ALWAYS_FILE, content=content)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def has_globs(data, row):
|
|
542
|
-
"""L: does this rule file itself carry a globs/paths value (coexist suffix)."""
|
|
543
|
-
key = row.get("frontmatter", {}).get("paths")
|
|
544
|
-
if not key:
|
|
545
|
-
return False
|
|
546
|
-
try:
|
|
547
|
-
front = frontmatter(data.decode("utf-8", "replace"))[0]
|
|
548
|
-
except render_instructions.RenderError:
|
|
549
|
-
return False
|
|
550
|
-
return any(v.strip() for v in front.get(key, []))
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def is_utf8(data):
|
|
554
|
-
try:
|
|
555
|
-
data.decode("utf-8")
|
|
556
|
-
except UnicodeDecodeError:
|
|
557
|
-
return False
|
|
558
|
-
return True
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
def only_new_refs(existing, content, moves):
|
|
562
|
-
"""A3, a later run: the destination on disk is what an earlier run wrote,
|
|
563
|
-
and today's content differs from it only by references to files this run
|
|
564
|
-
moves — the same text once those references are rewritten on disk too."""
|
|
565
|
-
try:
|
|
566
|
-
old = existing.decode("utf-8")
|
|
567
|
-
except UnicodeDecodeError:
|
|
568
|
-
return False
|
|
569
|
-
return rewrite_refs(None, old, moves, None, None).encode("utf-8") == content
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def rule_trigger(content):
|
|
573
|
-
"""A5: what a router row says an adopted rule is for — its title (the
|
|
574
|
-
frontmatter description, rendered as the first `# ` heading), or None."""
|
|
575
|
-
for line in content.decode("utf-8", "replace").split("\n"):
|
|
576
|
-
if line.startswith("# "):
|
|
577
|
-
return line[2:].strip().replace("|", "/") or None
|
|
578
|
-
return None
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
def rewrite_refs(path, text, moves, adoption, transform):
|
|
582
|
-
"""A3: rewrite each reference to a moved file to its destination, line by
|
|
583
|
-
line, logging every rewritten source line so no-line-lost skips it as it
|
|
584
|
-
skips a rewritten frontmatter line. A directory reference has no single
|
|
585
|
-
destination and is left alone (check_refs names it)."""
|
|
586
|
-
if not moves:
|
|
587
|
-
return text
|
|
588
|
-
rules = [(ref_pattern(old), new) for old, new in sorted(moves.items(), key=lambda m: -len(m[0]))
|
|
589
|
-
if old != path]
|
|
590
|
-
out = []
|
|
591
|
-
for n, line in enumerate(text.split("\n"), 1):
|
|
592
|
-
new = line
|
|
593
|
-
for rx, dest in rules:
|
|
594
|
-
new = rx.sub(lambda m, d=dest: m.group(1) + d, new)
|
|
595
|
-
if new != line and adoption is not None:
|
|
596
|
-
adoption.dropped.append({"source": path, "line": n, "text": line,
|
|
597
|
-
"reason": "rewritten: path reference", "by": "transform:%s" % transform})
|
|
598
|
-
out.append(new)
|
|
599
|
-
return "\n".join(out)
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
REFS_UPDATED = "; references to newly moved files updated"
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
def rewrite_adopted(adoption, dest, current):
|
|
606
|
-
"""A3 for a destination several sources share (append-section, ALWAYS_FILE):
|
|
607
|
-
a line an earlier run adopted names a file this run moves. Only a line whose
|
|
608
|
-
rewrite is a line this run adopts into `dest` changes; the project's own
|
|
609
|
-
lines stay as written, and check_refs names them (M-new-1)."""
|
|
610
|
-
foreign = adoption.adopted_lines.get(dest, ())
|
|
611
|
-
out = []
|
|
612
|
-
for line in current.split("\n"):
|
|
613
|
-
new = rewrite_refs(None, line, adoption.moves, None, None)
|
|
614
|
-
out.append(new if new != line and adopted_key(new) in foreign else line)
|
|
615
|
-
return "\n".join(out)
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
def adopted_key(line):
|
|
619
|
-
"""A3's line identity: the stripped line. Not `normalise`, which drops
|
|
620
|
-
headings — and a foreign heading can carry a path too."""
|
|
621
|
-
return line.strip()
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
def note_adopted(adoption, dest, text):
|
|
625
|
-
"""A3: remember which lines of `dest` came from a foreign source."""
|
|
626
|
-
lines = adoption.adopted_lines.setdefault(dest, set())
|
|
627
|
-
for raw in text.split("\n"):
|
|
628
|
-
if adopted_key(raw):
|
|
629
|
-
lines.add(adopted_key(raw))
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def decision_for(path, decisions):
|
|
633
|
-
"""A2: the human's decision for `path` — an exact key first, else the
|
|
634
|
-
longest glob key that matches it (the table's own `*` / `**` rules).
|
|
635
|
-
Returns (decision or None, the key it came from)."""
|
|
636
|
-
if path in decisions:
|
|
637
|
-
return decisions[path], path
|
|
638
|
-
globs = sorted((k for k in decisions if any(c in k for c in "*?")), key=len, reverse=True)
|
|
639
|
-
for key in globs:
|
|
640
|
-
if glob_re(key).match(path):
|
|
641
|
-
return decisions[key], key
|
|
642
|
-
return None, None
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
def plan_adopt(plan, table, mode="migrate", tools=None, shipped_block=None, skeleton_cap=None,
|
|
646
|
-
instruction_files=frozenset(), shipped_files=None, run_checks=True, split_mode=None,
|
|
647
|
-
splits=True):
|
|
648
|
-
"""Detect every foreign structure at the root and plan it onto `plan`.
|
|
649
|
-
Returns the Adoption. Nothing is written."""
|
|
650
|
-
root = plan.root
|
|
651
|
-
adoption = Adoption(mode)
|
|
652
|
-
wanted = [t for t in table["tools"] if tools is None or t in tools]
|
|
653
|
-
# Instruction files (plan step 4, rules a and b): the plugin's own carry the
|
|
654
|
-
# managed block and are only ever split candidates; a file without it is foreign.
|
|
655
|
-
instruction = {}
|
|
656
|
-
for tool in wanted:
|
|
657
|
-
spec = table["tools"][tool]
|
|
658
|
-
if spec["roots"]:
|
|
659
|
-
continue
|
|
660
|
-
for sig in spec["signature"]:
|
|
661
|
-
path = os.path.join(root, sig)
|
|
662
|
-
if not os.path.isfile(path):
|
|
663
|
-
continue
|
|
664
|
-
instruction[tool] = sig
|
|
665
|
-
detected = [t for t in wanted if table["tools"][t]["roots"]
|
|
666
|
-
and any(has_signature(root, s) for s in table["tools"][t]["signature"])]
|
|
667
|
-
files = walk(root)
|
|
668
|
-
all_decisions = load_decisions(root)
|
|
669
|
-
decisions = all_decisions.get("unmapped", {})
|
|
670
|
-
by_tool = OrderedDict((t, []) for t in detected)
|
|
671
|
-
rows_of = {t: [r for r in table["rows"] if r["tool"] == t] for t in table["tools"]}
|
|
672
|
-
for path in files:
|
|
673
|
-
owners = [t for t in detected if any(under_root(path, r) for r in table["tools"][t]["roots"])]
|
|
674
|
-
if not owners:
|
|
675
|
-
continue
|
|
676
|
-
taken = None
|
|
677
|
-
for tool in owners:
|
|
678
|
-
row = next((r for r in rows_of[tool] if r["_re"].match(path)), None)
|
|
679
|
-
if row is not None:
|
|
680
|
-
taken = (tool, row)
|
|
681
|
-
break
|
|
682
|
-
tool = taken[0] if taken else owners[0]
|
|
683
|
-
by_tool[tool].append((path, taken[1] if taken else None))
|
|
684
|
-
planned = []
|
|
685
|
-
for tool, entries in by_tool.items():
|
|
686
|
-
groups = OrderedDict()
|
|
687
|
-
for path, _ in entries:
|
|
688
|
-
group = next((r for r in table["tools"][tool]["roots"] if under_root(path, r)), path)
|
|
689
|
-
groups[group] = groups.get(group, 0) + 1
|
|
690
|
-
adoption.detected[tool] = groups
|
|
691
|
-
roots = table["tools"][tool]["roots"]
|
|
692
|
-
for path, row in entries:
|
|
693
|
-
if row is None:
|
|
694
|
-
decided, key = decision_for(path, decisions)
|
|
695
|
-
if decided and decided.get("action") == "copy" and key != path:
|
|
696
|
-
adoption.unmapped.append((path, tool))
|
|
697
|
-
adoption.note("unmapped", path, "[%s] decisions.json key %r: a glob decision cannot copy — "
|
|
698
|
-
"one destination per file, so name the file" % (tool, key))
|
|
699
|
-
continue
|
|
700
|
-
if decided and decided.get("action") == "copy" and not decision_dest_ok(decided.get("dest")):
|
|
701
|
-
adoption.unmapped.append((path, tool))
|
|
702
|
-
adoption.note("unmapped", path, "[%s] decisions.json copies it to %r, not an allowed destination"
|
|
703
|
-
% (tool, decided.get("dest")))
|
|
704
|
-
continue
|
|
705
|
-
if decided and decided.get("action") in ("drop", "ignore", "copy"):
|
|
706
|
-
row = {"tool": tool, "source": path, "transform": decided["action"], "_n": 0,
|
|
707
|
-
"why": decided.get("why", "decided by a human"), "dest": decided.get("dest"),
|
|
708
|
-
"cleanup": decided["action"] != "ignore"}
|
|
709
|
-
else:
|
|
710
|
-
adoption.unmapped.append((path, tool))
|
|
711
|
-
adoption.note("unmapped", path, "[%s] no mapping row — settle it in %s/decisions.json"
|
|
712
|
-
% (tool, report_dir(plan)))
|
|
713
|
-
continue
|
|
714
|
-
planned.append((path, row, tool, roots))
|
|
715
|
-
adoption.moves = moves_of(plan, planned)
|
|
716
|
-
if adoption.mode == "migrate": # A4: where the pointer to ALWAYS_FILE goes
|
|
717
|
-
adoption.instruction_targets = [
|
|
718
|
-
f for f in sorted(instruction_files)
|
|
719
|
-
if render_instructions.block_of((plan.read(f) or b"").decode("utf-8", "replace")) is not None]
|
|
720
|
-
for path, row, tool, roots in planned:
|
|
721
|
-
plan_file(plan, adoption, path, row, tool, roots)
|
|
722
|
-
# Foreign signatures below the root are never adopted, only listed (R4).
|
|
723
|
-
for path in files:
|
|
724
|
-
nested = nested_signature(path, table, wanted)
|
|
725
|
-
if nested and decision_for(path, decisions)[0] is None and all(p != path for p, _ in adoption.unmapped):
|
|
726
|
-
tool, head = nested
|
|
727
|
-
adoption.unmapped.append((path, tool))
|
|
728
|
-
adoption.note("unmapped", path, "[%s] below the root (%s/) — only the root is adopted" % (tool, head))
|
|
729
|
-
# A4: a file outside --tool still gets the pointer, so its budget is checked too (M-new-2).
|
|
730
|
-
pointed = [(t, s) for t in table["tools"] if t not in instruction and not table["tools"][t]["roots"]
|
|
731
|
-
for s in table["tools"][t]["signature"]
|
|
732
|
-
if pointer_bytes(adoption, s, (plan.read(s) or b"").decode("utf-8", "replace"))]
|
|
733
|
-
for tool, sig in list(instruction.items()) + pointed:
|
|
734
|
-
text = (plan.read(sig) or b"").decode("utf-8", "replace")
|
|
735
|
-
block = render_instructions.block_of(text)
|
|
736
|
-
row = next(r for r in rows_of[tool])
|
|
737
|
-
if block is None:
|
|
738
|
-
adoption.detected[tool] = OrderedDict([(sig, 1)])
|
|
739
|
-
adoption.rows_used.add(row["_n"])
|
|
740
|
-
adoption.sources.append((sig, tool, "instruction-file", row["_n"], False))
|
|
741
|
-
for n, line in enumerate(text.split("\n"), 1):
|
|
742
|
-
if SECRET_RE.search(line):
|
|
743
|
-
adoption.note("hint", "%s:%d" % (sig, n), "looks like a secret (value not shown)")
|
|
744
|
-
if shipped_block is None or not skeleton_cap:
|
|
745
|
-
continue
|
|
746
|
-
shipped = shipped_block(tool)
|
|
747
|
-
whole = (text.replace(block, shipped) if block is not None else text.rstrip("\n") + "\n\n" + shipped)
|
|
748
|
-
# A4: the pointer this run will add counts against the budget, and a
|
|
749
|
-
# split keeps it and leaves room for it (Candidate).
|
|
750
|
-
reserve = pointer_bytes(adoption, sig, text)
|
|
751
|
-
size = len(whole.encode("utf-8")) + reserve
|
|
752
|
-
if size > skeleton_cap:
|
|
753
|
-
adoption.rows_used.add(row["_n"])
|
|
754
|
-
adoption.candidates.append((Candidate(sig, tool, text, shipped, skeleton_cap - reserve), row["_n"]))
|
|
755
|
-
if splits and adoption.mode == "migrate":
|
|
756
|
-
plan_splits(plan, adoption, adoption.candidates, split_mode, all_decisions)
|
|
757
|
-
elif not splits:
|
|
758
|
-
adoption.split = [c.path for c, _ in adoption.candidates]
|
|
759
|
-
plan_pointers(plan, adoption)
|
|
760
|
-
add_router_rows(plan, router_rows_text(adoption))
|
|
761
|
-
if run_checks and not adoption.unmapped:
|
|
762
|
-
line_status, lines, missing, missing_n = check_lines(plan, adoption)
|
|
763
|
-
adoption.checks["no-line-lost"] = (line_status, lines, missing, missing_n)
|
|
764
|
-
ref_status, hard_misses, warns = check_refs(plan, adoption, table, instruction_files, shipped_files)
|
|
765
|
-
adoption.checks["no-dangling"] = (ref_status, hard_misses, warns)
|
|
766
|
-
return adoption
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
def nested_signature(path, table, tools):
|
|
770
|
-
"""(tool, directory) when `path` sits under a foreign signature that is not
|
|
771
|
-
at the root — `packages/x/.cursor/rules/a.mdc` -> ("cursor", "packages/x")."""
|
|
772
|
-
parts = path.split("/")
|
|
773
|
-
for tool in tools:
|
|
774
|
-
spec = table["tools"][tool]
|
|
775
|
-
if not spec["roots"] or any(under_root(path, r) for r in spec["roots"]):
|
|
776
|
-
continue
|
|
777
|
-
for sig in spec["signature"]:
|
|
778
|
-
want = sig.rstrip("/").split("/")
|
|
779
|
-
is_dir = sig.endswith("/")
|
|
780
|
-
for k in range(1, len(parts) - len(want) + 1):
|
|
781
|
-
if parts[k:k + len(want)] != want:
|
|
782
|
-
continue
|
|
783
|
-
last = k + len(want)
|
|
784
|
-
if (is_dir and last < len(parts)) or (not is_dir and last == len(parts)):
|
|
785
|
-
return tool, "/".join(parts[:k])
|
|
786
|
-
return None
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def today():
|
|
790
|
-
"""UTC date; ADOPT_TODAY overrides it only under RISKRAILS_TEST=1."""
|
|
791
|
-
if test_mode() and os.environ.get("ADOPT_TODAY"):
|
|
792
|
-
return os.environ["ADOPT_TODAY"]
|
|
793
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
def report_dir(plan):
|
|
797
|
-
return getattr(plan, "adopt_report_dir", None) or ".ai/reports/adopt-%s" % today()
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
NOTE_ORDER = ("kept", "split?", "dropped", "ignored", "unmapped", "hint")
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
def report(plan, adoption, applied=False):
|
|
804
|
-
"""The adopt report in spec I3's layout."""
|
|
805
|
-
head = "adopt, applied" if applied else \
|
|
806
|
-
"adopt dry run, mode: %s, nothing written; --apply to write" % adoption.mode
|
|
807
|
-
print("project-update: %s (%s)" % (plan.root, head))
|
|
808
|
-
if not (adoption.detected or adoption.candidates or adoption.unmapped):
|
|
809
|
-
print(" no foreign structure detected")
|
|
810
|
-
|
|
811
|
-
def shown(i):
|
|
812
|
-
return "%s -> %s" % (i["src"], i["target"]) if i.get("src") else i["target"]
|
|
813
|
-
width = max([len(shown(i)) for i in plan.items] + [len(n[1]) for n in adoption.notes]
|
|
814
|
-
+ [len(t) for t in adoption.detected] + [10])
|
|
815
|
-
|
|
816
|
-
def row(action, subject, text):
|
|
817
|
-
print(" %-9s %-*s %s" % (action, width, subject, text))
|
|
818
|
-
|
|
819
|
-
for tool, groups in adoption.detected.items():
|
|
820
|
-
text = ", ".join("%s (%d)" % (g, c) if c > 1 or g.endswith("/") else g for g, c in groups.items())
|
|
821
|
-
row("detect", tool, text)
|
|
822
|
-
for i in plan.items:
|
|
823
|
-
row(i["action"], shown(i), i["note"])
|
|
824
|
-
for action in NOTE_ORDER:
|
|
825
|
-
for act, subject, text in adoption.notes:
|
|
826
|
-
if act == action:
|
|
827
|
-
row(act, subject, text)
|
|
828
|
-
if "no-line-lost" in adoption.checks:
|
|
829
|
-
status, lines, missing, missing_n = adoption.checks["no-line-lost"]
|
|
830
|
-
if status == "not_applicable":
|
|
831
|
-
row("check", "no-line-lost", "not applicable (coexist)")
|
|
832
|
-
elif status == "pass":
|
|
833
|
-
row("check", "no-line-lost",
|
|
834
|
-
"PASS (%d line(s), %d rewritten -> dropped.jsonl)" % (lines, len(adoption.dropped)))
|
|
835
|
-
else:
|
|
836
|
-
row("check", "no-line-lost", "FAIL: %d line(s): %s" % (missing_n, ", ".join(missing)))
|
|
837
|
-
if "no-dangling" in adoption.checks:
|
|
838
|
-
status, hard_misses, warns = adoption.checks["no-dangling"]
|
|
839
|
-
if status == "pass":
|
|
840
|
-
row("check", "no-dangling", "PASS (%s)" % warnings_text(warns))
|
|
841
|
-
elif adoption.mode == "coexist":
|
|
842
|
-
row("check", "no-dangling", "FAIL: %s" % ", ".join(p for _tag, p in hard_misses[:20]))
|
|
843
|
-
else:
|
|
844
|
-
row("check", "no-dangling", "FAIL: %s" % ", ".join("%s:%s -> %s" % m for m in hard_misses[:20]))
|
|
845
|
-
cleanup_n = sum(1 for _p, _t, _tr, _n, cleanup in adoption.sources if cleanup)
|
|
846
|
-
both_pass = all(c[0] in ("pass", "not_applicable") for c in adoption.checks.values())
|
|
847
|
-
if adoption.mode == "migrate" and cleanup_n and both_pass and not adoption.unmapped:
|
|
848
|
-
row("cleanup?", "", "%d file(s) offered after both checks pass: --adopt --cleanup" % cleanup_n)
|
|
849
|
-
auto = [i for i in plan.items if i["action"] not in ("conflict", "delete?")]
|
|
850
|
-
conflicts = [i for i in plan.items if i["action"] == "conflict"]
|
|
851
|
-
tail = ""
|
|
852
|
-
if adoption.split:
|
|
853
|
-
tail += ", %d split(s) awaiting a proposal" % len(adoption.split)
|
|
854
|
-
if adoption.unmapped:
|
|
855
|
-
tail += ", %d unmapped" % len(adoption.unmapped)
|
|
856
|
-
print("%d automatic, %d conflict(s)%s" % (len(auto), len(conflicts), tail))
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
def run(plan, args, shipped_block, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
|
|
860
|
-
"""`update.py --adopt` without --apply: plan, report, and the exit code."""
|
|
861
|
-
try:
|
|
862
|
-
table = load_table()
|
|
863
|
-
except TableError as exc:
|
|
864
|
-
print("project-update: %s" % exc, file=sys.stderr)
|
|
865
|
-
return 2
|
|
866
|
-
tools = [t.strip() for t in args.tool.split(",")] if args.tool else None
|
|
867
|
-
if tools:
|
|
868
|
-
unknown = [t for t in tools if t not in table["tools"]]
|
|
869
|
-
if unknown:
|
|
870
|
-
print("project-update: unknown --tool %s (known: %s)" % (", ".join(unknown), ", ".join(table["tools"])),
|
|
871
|
-
file=sys.stderr)
|
|
872
|
-
return 2
|
|
873
|
-
if getattr(args, "check", False):
|
|
874
|
-
return check_state(plan.root, table, shipped_block, skeleton_cap, instruction_files, shipped_files)
|
|
875
|
-
requesting = getattr(args, "split_request", False)
|
|
876
|
-
try:
|
|
877
|
-
adoption = plan_adopt(plan, table, args.mode, tools, shipped_block, skeleton_cap, instruction_files,
|
|
878
|
-
shipped_files, run_checks=not requesting, split_mode=getattr(args, "split", None),
|
|
879
|
-
splits=not requesting)
|
|
880
|
-
except TableError as exc:
|
|
881
|
-
print("project-update: %s" % exc, file=sys.stderr)
|
|
882
|
-
return 2
|
|
883
|
-
if requesting:
|
|
884
|
-
return write_requests(plan, adoption.candidates)
|
|
885
|
-
# I1: the dry run answers what --apply would, so exit 4 covers a missing
|
|
886
|
-
# proposal, a conflict and a failed check as well as the unmapped files.
|
|
887
|
-
problems = incomplete_reasons(plan, adoption)
|
|
888
|
-
if problems:
|
|
889
|
-
print("%s: %s" % (INCOMPLETE, "; ".join(problems)))
|
|
890
|
-
report(plan, adoption)
|
|
891
|
-
if getattr(args, "diff", False):
|
|
892
|
-
print_diff(plan)
|
|
893
|
-
return 4 if problems else 0
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
def incomplete_reasons(plan, adoption, conflicts=True, checks=True):
|
|
897
|
-
"""Why an adopt cannot be applied as planned (exit 4), in the order a
|
|
898
|
-
human settles them."""
|
|
899
|
-
problems = []
|
|
900
|
-
if adoption.unmapped:
|
|
901
|
-
problems.append("%d file(s) have no mapping row" % len(adoption.unmapped))
|
|
902
|
-
problems += ["%s: %s" % err for err in adoption.split_errors]
|
|
903
|
-
if conflicts:
|
|
904
|
-
if adoption.split:
|
|
905
|
-
problems.append("%s: a split needs a proposal or --split fallback" % ", ".join(adoption.split))
|
|
906
|
-
clash = [i["target"] for i in plan.items if i["action"] == "conflict"]
|
|
907
|
-
if clash:
|
|
908
|
-
problems.append("destination(s) exist and differ: %s" % ", ".join(clash))
|
|
909
|
-
if checks:
|
|
910
|
-
failed = [name for name, c in adoption.checks.items() if c[0] == "fail"]
|
|
911
|
-
if failed:
|
|
912
|
-
problems.append("%s FAIL on the plan" % ", ".join(failed))
|
|
913
|
-
return problems
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
# ---------------------------------------------------------------------------
|
|
917
|
-
# I11 --adopt --check
|
|
918
|
-
# ---------------------------------------------------------------------------
|
|
919
|
-
|
|
920
|
-
def check_state(root, table, shipped_block, skeleton_cap, instruction_files, shipped_files=None):
|
|
921
|
-
"""`update.py --adopt --check`: exactly one of I11's five lines."""
|
|
922
|
-
code, line = state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files)
|
|
923
|
-
print(line)
|
|
924
|
-
return code
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
def state_line(root, shipped_block, skeleton_cap, table=None, instruction_files=frozenset(), shipped_files=None):
|
|
928
|
-
"""(exit code, one I11 line). Detection only — no reference scan — so the
|
|
929
|
-
plain dry run (D5) can afford it on every call. A broken adopt artefact is
|
|
930
|
-
one line, never a traceback: /ai-status and the plain update read this."""
|
|
931
|
-
try:
|
|
932
|
-
return _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files)
|
|
933
|
-
except (TableError, OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
934
|
-
return 1, "adopt state unreadable (%s) — run /rr-setup --adopt" % exc
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files):
|
|
938
|
-
if table is None:
|
|
939
|
-
try:
|
|
940
|
-
table = load_table()
|
|
941
|
-
except TableError:
|
|
942
|
-
return 0, "no foreign structure detected"
|
|
943
|
-
where = latest_with(root, "adopt.json")
|
|
944
|
-
if where is None:
|
|
945
|
-
plan = _ThrowawayPlan(root)
|
|
946
|
-
adoption = plan_adopt(plan, table, "migrate", None, shipped_block, skeleton_cap,
|
|
947
|
-
instruction_files, shipped_files, run_checks=False, splits=False)
|
|
948
|
-
if not adoption.detected and not adoption.candidates:
|
|
949
|
-
return 0, "no foreign structure detected"
|
|
950
|
-
bits = ["%s (%d file(s))" % (t, sum(g.values())) for t, g in adoption.detected.items()]
|
|
951
|
-
bits += ["%s (split?)" % c.path for c, _ in adoption.candidates]
|
|
952
|
-
return 1, "foreign structure detected: %s — run /rr-setup --adopt" % ", ".join(bits)
|
|
953
|
-
try:
|
|
954
|
-
with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
|
|
955
|
-
record_data = json.load(fh)
|
|
956
|
-
except (OSError, ValueError, RecursionError):
|
|
957
|
-
return 0, "no foreign structure detected"
|
|
958
|
-
date = where.rsplit("adopt-", 1)[-1]
|
|
959
|
-
regenerated = []
|
|
960
|
-
deleted = set((record_data.get("cleanup") or {}).get("deleted") or [])
|
|
961
|
-
for src in record_data.get("sources", []):
|
|
962
|
-
if not safe_rel(src.get("path")):
|
|
963
|
-
continue
|
|
964
|
-
full = os.path.join(root, src["path"])
|
|
965
|
-
if src["path"] in deleted and os.path.isfile(full):
|
|
966
|
-
regenerated.append(src["path"]) # it reappeared after cleanup (R16)
|
|
967
|
-
continue
|
|
968
|
-
if src.get("transform") == "instruction-file" or not os.path.isfile(full):
|
|
969
|
-
# An instruction file stays, and the plain update re-renders its
|
|
970
|
-
# block; growing again shows as split? in the dry run, not here.
|
|
971
|
-
continue
|
|
972
|
-
with open(full, "rb") as fh:
|
|
973
|
-
current = "sha256:" + hashlib.sha256(fh.read()).hexdigest()
|
|
974
|
-
if current != src.get("sha"):
|
|
975
|
-
regenerated.append(src["path"])
|
|
976
|
-
if regenerated:
|
|
977
|
-
return 1, ("foreign files regenerated since the adopt of %s: %s — run /rr-setup --adopt"
|
|
978
|
-
% (date, ", ".join(sorted(regenerated))))
|
|
979
|
-
checks = record_data.get("checks", {})
|
|
980
|
-
failed = [name for name, c in checks.items() if c.get("status") not in ("pass", "not_applicable")]
|
|
981
|
-
if failed or record_data.get("status") == "partial":
|
|
982
|
-
what = failed[0].replace("_", "-") + " FAIL" if failed else "the apply was interrupted"
|
|
983
|
-
return 1, "adoption of %s incomplete: %s — run /rr-setup --adopt" % (date, what)
|
|
984
|
-
tools = ", ".join(sorted(record_data.get("tools", {})))
|
|
985
|
-
pending = sum(1 for s in record_data.get("sources", []) if s.get("cleanup") and safe_rel(s.get("path"))
|
|
986
|
-
and os.path.isfile(os.path.join(root, s["path"])))
|
|
987
|
-
offered = (record_data.get("cleanup") or {}).get("offered", True)
|
|
988
|
-
tail = "; %d file(s) await cleanup" % pending if pending and offered else ""
|
|
989
|
-
return 0, "adopted %s: %s — up to date%s" % (date, tools, tail)
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
class _ThrowawayPlan:
|
|
993
|
-
"""The minimal reader `plan_adopt` needs, for `--adopt --check` when no
|
|
994
|
-
record exists yet: nothing is ever written through it."""
|
|
995
|
-
|
|
996
|
-
def __init__(self, root):
|
|
997
|
-
self.root = root
|
|
998
|
-
self.items = []
|
|
999
|
-
self.hints = []
|
|
1000
|
-
self.final = {}
|
|
1001
|
-
|
|
1002
|
-
def exists(self, target):
|
|
1003
|
-
return os.path.exists(os.path.join(self.root, target))
|
|
1004
|
-
|
|
1005
|
-
def read(self, target):
|
|
1006
|
-
path = os.path.join(self.root, target)
|
|
1007
|
-
if not os.path.isfile(path):
|
|
1008
|
-
return None
|
|
1009
|
-
with open(path, "rb") as fh:
|
|
1010
|
-
return fh.read()
|
|
1011
|
-
|
|
1012
|
-
def add(self, *_a, **_k):
|
|
1013
|
-
pass # a check never writes
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
# ---------------------------------------------------------------------------
|
|
1017
|
-
# I7 no-line-lost: normalise, source/destination sets, dropped.jsonl coverage
|
|
1018
|
-
# ---------------------------------------------------------------------------
|
|
1019
|
-
|
|
1020
|
-
LIST_MARKER_RE = re.compile(r"^([-*+]|\d+[.)])\s+")
|
|
1021
|
-
CHECKBOX_RE = re.compile(r"^\[[ xX]\]\s+")
|
|
1022
|
-
HEADING_RE = re.compile(r"^#{1,6}\s")
|
|
1023
|
-
ALNUM_RE = re.compile(r"[A-Za-z0-9]")
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
def normalise(line):
|
|
1027
|
-
"""I7: strip, ignorable (empty / bare heading / no alphanumeric character)
|
|
1028
|
-
is None, otherwise one list marker and one checkbox marker stripped,
|
|
1029
|
-
whitespace collapsed, casefolded."""
|
|
1030
|
-
line = line.strip()
|
|
1031
|
-
if not line or HEADING_RE.match(line) or not ALNUM_RE.search(line):
|
|
1032
|
-
return None
|
|
1033
|
-
line = LIST_MARKER_RE.sub("", line, count=1)
|
|
1034
|
-
line = CHECKBOX_RE.sub("", line, count=1)
|
|
1035
|
-
return re.sub(r"\s+", " ", line).casefold()
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
def text_of(content):
|
|
1039
|
-
return content.decode("utf-8", "replace") if isinstance(content, bytes) else content
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
def check_lines(plan, adoption):
|
|
1043
|
-
"""I7 no-line-lost: source lines, minus what a transform or a decision
|
|
1044
|
-
dropped, must all be in some destination the run writes. `plan.final`
|
|
1045
|
-
covers the whole file, per target, so a router row's own text never
|
|
1046
|
-
counts against a source (it is not a source, and dest coverage only ever
|
|
1047
|
-
helps). Returns (status "pass"/"fail", lines, missing[:20] as "file:line",
|
|
1048
|
-
missing_count)."""
|
|
1049
|
-
if adoption.mode == "coexist":
|
|
1050
|
-
return "not_applicable", 0, [], 0
|
|
1051
|
-
targets = {d for d, _t, _r, coexist in adoption.destinations if not coexist}
|
|
1052
|
-
targets |= {i["target"] for i in plan.items if i["content"] is not None}
|
|
1053
|
-
# A conflict leaves the destination as it is on disk: its lines are still there.
|
|
1054
|
-
targets |= {i["target"] for i in plan.items if i["action"] == "conflict"}
|
|
1055
|
-
targets |= set(adoption.split_dests) # on disk after --apply, `plan` has no items
|
|
1056
|
-
dest_lines = set()
|
|
1057
|
-
for target in targets:
|
|
1058
|
-
for raw in text_of(plan.read(target) or b"").split("\n"):
|
|
1059
|
-
n = normalise(raw)
|
|
1060
|
-
if n:
|
|
1061
|
-
dest_lines.add(n)
|
|
1062
|
-
dropped_star = {d["source"] for d in adoption.dropped if d.get("line") == "*"}
|
|
1063
|
-
dropped_at = {(d["source"], d["line"]) for d in adoption.dropped if d.get("line") != "*"}
|
|
1064
|
-
lines, missing = 0, []
|
|
1065
|
-
for path, _tool, transform, _n, _cleanup in adoption.sources:
|
|
1066
|
-
only = None
|
|
1067
|
-
if transform == "instruction-file":
|
|
1068
|
-
if path not in adoption.split_done:
|
|
1069
|
-
continue # not a split: the file stays where it is, whole
|
|
1070
|
-
# The text as the split read it: on disk it is already rewritten.
|
|
1071
|
-
text = adoption.split_done[path]["text"]
|
|
1072
|
-
only = set(adoption.split_done[path]["nums"])
|
|
1073
|
-
elif transform == "ignore":
|
|
1074
|
-
continue
|
|
1075
|
-
else:
|
|
1076
|
-
text = text_of(plan.read(path) or b"")
|
|
1077
|
-
whole_dropped = path in dropped_star
|
|
1078
|
-
for i, raw in enumerate(text.split("\n"), 1):
|
|
1079
|
-
if only is not None and i not in only:
|
|
1080
|
-
continue
|
|
1081
|
-
n = normalise(raw)
|
|
1082
|
-
if n is None:
|
|
1083
|
-
continue
|
|
1084
|
-
lines += 1
|
|
1085
|
-
if whole_dropped or (path, i) in dropped_at:
|
|
1086
|
-
continue
|
|
1087
|
-
if n not in dest_lines:
|
|
1088
|
-
missing.append("%s:%d" % (path, i))
|
|
1089
|
-
status = "fail" if missing else "pass"
|
|
1090
|
-
return status, lines, missing[:20], len(missing)
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
# ---------------------------------------------------------------------------
|
|
1094
|
-
# I8 no-dangling-reference
|
|
1095
|
-
# ---------------------------------------------------------------------------
|
|
1096
|
-
|
|
1097
|
-
REF_BOUNDARY_BEFORE = r"(^|[\s`\"'(@=:,])"
|
|
1098
|
-
REF_BOUNDARY_AFTER = r"(?=$|[\s`\"')>,:;])"
|
|
1099
|
-
MAX_SCAN_BYTES = 20 * 1024 * 1024
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
def ref_pattern(old_path):
|
|
1103
|
-
"""The reference pattern for a path (and for `./path`); a directory root
|
|
1104
|
-
(trailing `/`) also matches everything below it."""
|
|
1105
|
-
escaped = re.escape(old_path.rstrip("/"))
|
|
1106
|
-
if old_path.endswith("/"):
|
|
1107
|
-
return re.compile(REF_BOUNDARY_BEFORE + r"\.?/?" + escaped + r"/\S*")
|
|
1108
|
-
return re.compile(REF_BOUNDARY_BEFORE + r"\.?/?" + escaped + REF_BOUNDARY_AFTER)
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
def old_paths_for(adoption, table):
|
|
1112
|
-
"""I8: every source whose row has cleanup: true, plus every detected
|
|
1113
|
-
tool's `roots` entries."""
|
|
1114
|
-
paths = {path for path, _tool, _transform, _n, cleanup in adoption.sources if cleanup}
|
|
1115
|
-
for tool in adoption.detected:
|
|
1116
|
-
paths |= set(table["tools"][tool]["roots"])
|
|
1117
|
-
return sorted(paths)
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
HARD_PREFIXES = (".ai/", "docs/sdlc/", ".claude/", ".codex/", ".gemini/", ".junie/",
|
|
1121
|
-
".github/prompts/", ".github/instructions/")
|
|
1122
|
-
HARD_AI_EXCEPT = (".ai/reports/adopt-", ".ai/state/", ".ai/local/")
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
def is_hard_scope(path, text, instruction_files):
|
|
1126
|
-
if path in instruction_files:
|
|
1127
|
-
return True
|
|
1128
|
-
if os.path.basename(path) in (".junie/guidelines.md".rsplit("/", 1)[-1], "CLAUDE.md", "AGENTS.md") and render_instructions.RULE_MARKER_RE.search(text):
|
|
1129
|
-
return True # a nested instruction file carrying a rule block (I8)
|
|
1130
|
-
if path.startswith(".ai/"):
|
|
1131
|
-
return not any(path.startswith(p) for p in HARD_AI_EXCEPT)
|
|
1132
|
-
return any(path.startswith(p) for p in HARD_PREFIXES if p != ".ai/")
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
def is_binary(data):
|
|
1136
|
-
return b"\0" in data[:8192]
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
def git_files(root):
|
|
1140
|
-
try:
|
|
1141
|
-
proc = subprocess.run(["git", "-C", root, "ls-files", "-z"], capture_output=True, check=False)
|
|
1142
|
-
except OSError:
|
|
1143
|
-
return None
|
|
1144
|
-
if proc.returncode != 0:
|
|
1145
|
-
return None
|
|
1146
|
-
return [p for p in proc.stdout.decode("utf-8", "surrogateescape").split("\0") if p]
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
COEXIST_LINK_RE = re.compile(r"^\| .* \| (?P<path>\S+) \(kept in place(?:; its globs are "
|
|
1150
|
-
r"not applied by this runtime)?\) \|$", re.M)
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
def warnings_text(warns):
|
|
1154
|
-
"""The warn-scope references, named: a count alone tells a human nothing
|
|
1155
|
-
they can judge."""
|
|
1156
|
-
if not warns:
|
|
1157
|
-
return "0 warning(s) outside the new structure"
|
|
1158
|
-
shown = ", ".join("%s:%s -> %s" % w for w in warns[:10])
|
|
1159
|
-
more = " and %d more" % (len(warns) - 10) if len(warns) > 10 else ""
|
|
1160
|
-
return "%d warning(s) outside the new structure: %s%s" % (len(warns), shown, more)
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
def check_refs(plan, adoption, table, instruction_files=frozenset(), shipped_files=None):
|
|
1164
|
-
"""I8 no-dangling-reference: every old path (or, in coexist, every linked
|
|
1165
|
-
foreign path) still readable, and no reference to a path this run took
|
|
1166
|
-
away is left behind in the hard scope; a reference in the warn scope is
|
|
1167
|
-
reported but does not fail. Returns (status, hard_misses, warn_misses),
|
|
1168
|
-
each miss a (path, line, old path)."""
|
|
1169
|
-
if adoption.mode == "coexist":
|
|
1170
|
-
linked = {path for path, _tool, _router, _coexist in adoption.destinations}
|
|
1171
|
-
linked |= set(COEXIST_LINK_RE.findall(text_of(plan.read(ROUTER_FILE) or b"")))
|
|
1172
|
-
missing = sorted(path for path in linked if not plan.exists(path))
|
|
1173
|
-
return ("fail" if missing else "pass"), [("<coexist>", p) for p in missing], []
|
|
1174
|
-
old = old_paths_for(adoption, table)
|
|
1175
|
-
if not old:
|
|
1176
|
-
return "pass", [], []
|
|
1177
|
-
patterns = [ref_pattern(p) for p in old]
|
|
1178
|
-
moved = {path for path, _tool, _transform, _n, cleanup in adoption.sources if cleanup}
|
|
1179
|
-
files = git_files(plan.root)
|
|
1180
|
-
if files is None:
|
|
1181
|
-
files = walk(plan.root)
|
|
1182
|
-
# What the run leaves behind: planned content first (plan.final), then disk.
|
|
1183
|
-
files = sorted(set(files) | set(plan.final))
|
|
1184
|
-
hard_misses, warn_misses = [], []
|
|
1185
|
-
for path in files:
|
|
1186
|
-
if path in moved or path in old:
|
|
1187
|
-
continue
|
|
1188
|
-
if path.startswith(".ai/reports/adopt-"):
|
|
1189
|
-
continue # the adopt's own record: it names the old paths on purpose
|
|
1190
|
-
if path in plan.final:
|
|
1191
|
-
data = plan.final[path]
|
|
1192
|
-
else:
|
|
1193
|
-
full = os.path.join(plan.root, path)
|
|
1194
|
-
try:
|
|
1195
|
-
if os.path.getsize(full) > MAX_SCAN_BYTES:
|
|
1196
|
-
continue
|
|
1197
|
-
with open(full, "rb") as fh:
|
|
1198
|
-
data = fh.read()
|
|
1199
|
-
except OSError:
|
|
1200
|
-
continue
|
|
1201
|
-
if is_binary(data):
|
|
1202
|
-
continue
|
|
1203
|
-
# A file whose bytes still match what the plugin ships is the plugin's
|
|
1204
|
-
# own words, not this project's: a generic example ("a `.cursorrules`
|
|
1205
|
-
# or the like", `.ai/policies/security.md`) is not a dependency on
|
|
1206
|
-
# this project's foreign structure, so it never fails hard scope —
|
|
1207
|
-
# it still counts toward warn (harmless: warn never fails the run).
|
|
1208
|
-
unedited = shipped_files is not None and shipped_files.get(path) == data
|
|
1209
|
-
text = data.decode("utf-8", "replace")
|
|
1210
|
-
hard = is_hard_scope(path, text, instruction_files) and not unedited
|
|
1211
|
-
foreign = adoption.adopted_lines.get(path, ())
|
|
1212
|
-
for i, line in enumerate(text.split("\n"), 1):
|
|
1213
|
-
hit = next((p for p, rx in zip(old, patterns) if rx.search(line)), None)
|
|
1214
|
-
if hit is None:
|
|
1215
|
-
continue
|
|
1216
|
-
# A3: a line that came verbatim from a foreign source this run
|
|
1217
|
-
# adopts is that tool's own wording, named, not the project's dependency.
|
|
1218
|
-
# Every line is classified: with A3 a warned line can come first in
|
|
1219
|
-
# a file, and a file-level stop there would hide the project's own
|
|
1220
|
-
# stale reference further down. (`next` above already takes one hit per line.)
|
|
1221
|
-
if hard and adopted_key(line) not in foreign:
|
|
1222
|
-
hard_misses.append((path, i, hit))
|
|
1223
|
-
else:
|
|
1224
|
-
warn_misses.append((path, i, hit))
|
|
1225
|
-
return ("fail" if hard_misses else "pass"), hard_misses, warn_misses
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
# ---------------------------------------------------------------------------
|
|
1229
|
-
# I9 router rows
|
|
1230
|
-
# ---------------------------------------------------------------------------
|
|
1231
|
-
|
|
1232
|
-
TOOL_TITLE = {"speckit": "Spec Kit", "kiro": "Kiro", "aidlc": "AI-DLC", "cursor": "Cursor",
|
|
1233
|
-
"copilot": "Copilot", "junie": "Junie", "gemini": "Gemini", "claude": "Claude", "codex": "Codex"}
|
|
1234
|
-
ROUTER_FILE = ".ai/AGENTS.md"
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
def router_rows_text(adoption):
|
|
1238
|
-
"""I9 as amended (A5). Migrate: one row per (tool, destination directory),
|
|
1239
|
-
and one per file for an on-demand rule, triggered by its title. Coexist:
|
|
1240
|
-
one row per (tool, directory of the kept path), with the globs suffix only
|
|
1241
|
-
when a rule with a globs key is among them. Returns the row texts, in
|
|
1242
|
-
first-seen order."""
|
|
1243
|
-
rows = OrderedDict()
|
|
1244
|
-
for dest, tool, router, coexist in adoption.destinations:
|
|
1245
|
-
spec = router if isinstance(router, dict) else {"text": router}
|
|
1246
|
-
if spec.get("skip"):
|
|
1247
|
-
continue # A4: the instruction files point at it; it needs no row
|
|
1248
|
-
title = spec.get("text") or ("an adopted %s file" % TOOL_TITLE.get(tool, tool))
|
|
1249
|
-
if coexist:
|
|
1250
|
-
rel_dir = os.path.dirname(dest)
|
|
1251
|
-
key = (tool, rel_dir)
|
|
1252
|
-
shown = rel_dir + "/" if rel_dir else dest
|
|
1253
|
-
row = rows.setdefault(key, [title, shown, False])
|
|
1254
|
-
row[2] = row[2] or bool(spec.get("globs"))
|
|
1255
|
-
continue
|
|
1256
|
-
target = dest if spec.get("file") else os.path.dirname(dest) + "/"
|
|
1257
|
-
shown = target[len(".ai/"):] if target.startswith(".ai/") else target
|
|
1258
|
-
rows.setdefault((tool, target), [title, shown, None])
|
|
1259
|
-
out = []
|
|
1260
|
-
for title, shown, globs in rows.values():
|
|
1261
|
-
if globs is None:
|
|
1262
|
-
out.append("| %s | %s |" % (title, shown))
|
|
1263
|
-
else:
|
|
1264
|
-
out.append("| %s | %s (kept in place%s) |" % (
|
|
1265
|
-
title, shown, "; its globs are not applied by this runtime" if globs else ""))
|
|
1266
|
-
return out
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
def add_router_rows(plan, rows):
|
|
1270
|
-
"""Append new router rows after the last row of `.ai/AGENTS.md`'s routing
|
|
1271
|
-
table, idempotent by exact text. Returns the count actually added."""
|
|
1272
|
-
if not rows:
|
|
1273
|
-
return 0
|
|
1274
|
-
text = text_of(plan.read(ROUTER_FILE) or b"")
|
|
1275
|
-
if not text:
|
|
1276
|
-
return 0
|
|
1277
|
-
lines = text.split("\n")
|
|
1278
|
-
# A5: a coexist row per file, the form before 2026-09-27, is this tool's
|
|
1279
|
-
# own and is superseded by the row for its directory.
|
|
1280
|
-
dir_rows = {m.group("path") for r in rows for m in [COEXIST_LINK_RE.match(r)] if m
|
|
1281
|
-
and m.group("path").endswith("/")}
|
|
1282
|
-
stale = [l for l in lines for m in [COEXIST_LINK_RE.match(l)] if m
|
|
1283
|
-
and not m.group("path").endswith("/") and os.path.dirname(m.group("path")) + "/" in dir_rows]
|
|
1284
|
-
lines = [l for l in lines if l not in stale]
|
|
1285
|
-
last = max((i for i, l in enumerate(lines) if l.startswith("|")), default=None)
|
|
1286
|
-
if last is None:
|
|
1287
|
-
return 0
|
|
1288
|
-
to_add = [r for r in rows if r not in lines]
|
|
1289
|
-
if not to_add and not stale:
|
|
1290
|
-
return 0
|
|
1291
|
-
new_lines = lines[:last + 1] + to_add + lines[last + 1:]
|
|
1292
|
-
note = "+%d row(s)" % len(to_add) + (", -%d superseded" % len(stale) if stale else "")
|
|
1293
|
-
plan.add("router", ROUTER_FILE, note=note, content="\n".join(new_lines).encode("utf-8"))
|
|
1294
|
-
return len(to_add)
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
# ---------------------------------------------------------------------------
|
|
1298
|
-
# --adopt --apply (R5, R6, R15, R16)
|
|
1299
|
-
# ---------------------------------------------------------------------------
|
|
1300
|
-
|
|
1301
|
-
# Dirt an adopt tolerates: its own record, and the two git-ignored directories.
|
|
1302
|
-
ALLOWED_DIRT = (".ai/reports/adopt-", ".ai/state/", ".ai/local/")
|
|
1303
|
-
ORIGINAL_CAP = 1024 * 1024
|
|
1304
|
-
INSTRUCTION_ROOT = ("CLAUDE.md", "AGENTS.md", "GEMINI.md", ".junie/guidelines.md")
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
def sha256(data):
|
|
1308
|
-
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
def read_record(root, where):
|
|
1312
|
-
try:
|
|
1313
|
-
with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
|
|
1314
|
-
return json.load(fh)
|
|
1315
|
-
except (OSError, ValueError, RecursionError):
|
|
1316
|
-
return None
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
def partial_record(root):
|
|
1320
|
-
"""(directory, record) of the latest adopt.json when it is `partial` — an
|
|
1321
|
-
apply that stopped part-way — else (None, None)."""
|
|
1322
|
-
where = latest_with(root, "adopt.json")
|
|
1323
|
-
if where is None:
|
|
1324
|
-
return None, None
|
|
1325
|
-
rec = read_record(root, where)
|
|
1326
|
-
if rec and rec.get("status") == "partial":
|
|
1327
|
-
return where, rec
|
|
1328
|
-
return None, None
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
def task_in_flight(root):
|
|
1332
|
-
"""The task in .ai/state/state.json (or the legacy current.json) when its
|
|
1333
|
-
stage is not `done`."""
|
|
1334
|
-
path = os.path.join(root, ".ai", "state", "state.json")
|
|
1335
|
-
if not os.path.isfile(path):
|
|
1336
|
-
path = os.path.join(root, ".ai", "state", "current.json")
|
|
1337
|
-
try:
|
|
1338
|
-
with open(path, encoding="utf-8") as fh:
|
|
1339
|
-
state = json.load(fh)
|
|
1340
|
-
except (OSError, ValueError, RecursionError):
|
|
1341
|
-
return None
|
|
1342
|
-
stage = state.get("current_stage")
|
|
1343
|
-
return None if stage in (None, "done") else "%s at stage %s" % (state.get("task_id", "a task"), stage)
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
def gates(root, u, planned_writes):
|
|
1347
|
-
"""R5, in the order a human fixes them. Returns a refusal (reason, how) or None."""
|
|
1348
|
-
if not os.path.isdir(os.path.join(root, ".ai")):
|
|
1349
|
-
return "the project has no .ai/", "run /rr-setup first, then /rr-setup --adopt"
|
|
1350
|
-
dirty = clean_tree(root)
|
|
1351
|
-
if dirty is None:
|
|
1352
|
-
return "the project is not a git work tree", "run `git init` and commit, so the adopt can be undone"
|
|
1353
|
-
running = task_in_flight(root)
|
|
1354
|
-
if running:
|
|
1355
|
-
return "a task is in flight (%s)" % running, "finish or abandon it with /rr-build first"
|
|
1356
|
-
outside = [p for p in dirty if not p.startswith(ALLOWED_DIRT) and p not in planned_writes]
|
|
1357
|
-
if outside:
|
|
1358
|
-
more = " and %d more" % (len(outside) - 3) if len(outside) > 3 else ""
|
|
1359
|
-
how = "commit or stash them first, so the adopt is one reviewable change"
|
|
1360
|
-
if any(p.startswith(".ai/reports/T-") for p in outside):
|
|
1361
|
-
how += ("; an archived or abandoned task leaves its .ai/reports/<task-id>/ untracked — "
|
|
1362
|
-
"commit it or remove it")
|
|
1363
|
-
return ("the tree has uncommitted changes: %s%s" % (", ".join(outside[:3]), more), how)
|
|
1364
|
-
if u.plain_pending(root, ignore=planned_writes):
|
|
1365
|
-
return "the project is behind the installed plugin", "run /rr-setup --update --apply first, then adopt"
|
|
1366
|
-
return None
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
def keep_original_bounded(u, plan, rel, record):
|
|
1370
|
-
"""R6: a copy with its mode under <record dir>/original/, unless it is over
|
|
1371
|
-
1 MiB or binary — then git keeps it, and it is listed."""
|
|
1372
|
-
full = os.path.join(plan.root, rel)
|
|
1373
|
-
if not os.path.isfile(full):
|
|
1374
|
-
return
|
|
1375
|
-
target = os.path.join(plan.report_dir, "original", rel)
|
|
1376
|
-
if os.path.exists(os.path.join(plan.root, target)):
|
|
1377
|
-
return # a resumed apply already kept it
|
|
1378
|
-
size = os.path.getsize(full)
|
|
1379
|
-
with open(full, "rb") as fh:
|
|
1380
|
-
head = fh.read(8192)
|
|
1381
|
-
if size > ORIGINAL_CAP or b"\0" in head:
|
|
1382
|
-
if rel not in record["original_skipped"]:
|
|
1383
|
-
record["original_skipped"].append(rel)
|
|
1384
|
-
return
|
|
1385
|
-
u.write_file(plan.root, target, u.read(full), mode=os.stat(full).st_mode & 0o7777)
|
|
1386
|
-
record["original_bytes"] += size
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
def phase_of(item):
|
|
1390
|
-
"""1: destinations, router rows, rendered rule blocks; 2: a rewrite of a
|
|
1391
|
-
root instruction file. A source never loses a line before its destination
|
|
1392
|
-
exists (plan step 6)."""
|
|
1393
|
-
return 2 if item["target"] in INSTRUCTION_ROOT and item["action"] == "adopt" else 1
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
def test_stop_after():
|
|
1397
|
-
"""ADOPT_STOP_AFTER=<phase>, read only under RISKRAILS_TEST=1."""
|
|
1398
|
-
if not test_mode():
|
|
1399
|
-
return None
|
|
1400
|
-
try:
|
|
1401
|
-
return int(os.environ.get("ADOPT_STOP_AFTER", ""))
|
|
1402
|
-
except ValueError:
|
|
1403
|
-
return None
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
def write_json(u, root, rel, data):
|
|
1407
|
-
u.write_file(root, rel, (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
def apply_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
|
|
1411
|
-
"""`update.py --adopt --apply`: gates, originals, phased writes, the checks
|
|
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)."""
|
|
1414
|
-
root = plan.root
|
|
1415
|
-
try:
|
|
1416
|
-
table = load_table()
|
|
1417
|
-
except TableError as exc:
|
|
1418
|
-
print("project-update: %s" % exc, file=sys.stderr)
|
|
1419
|
-
return 2
|
|
1420
|
-
tools = [t.strip() for t in args.tool.split(",")] if args.tool else None
|
|
1421
|
-
resume_dir, resume = partial_record(root)
|
|
1422
|
-
planned_writes = set(resume.get("planned_writes", [])) if resume else set()
|
|
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
|
|
1428
|
-
if refusal:
|
|
1429
|
-
return refuse(*refusal)
|
|
1430
|
-
plan.report_dir = plan.adopt_report_dir = resume_dir or ".ai/reports/adopt-%s" % today()
|
|
1431
|
-
adoption = plan_adopt(plan, table, args.mode, tools, u.shipped_block, skeleton_cap,
|
|
1432
|
-
instruction_files, shipped_files, split_mode=getattr(args, "split", None))
|
|
1433
|
-
problems = incomplete_reasons(plan, adoption)
|
|
1434
|
-
if problems:
|
|
1435
|
-
print("%s: %s — nothing written" % (INCOMPLETE, "; ".join(problems)))
|
|
1436
|
-
report(plan, adoption)
|
|
1437
|
-
return 4
|
|
1438
|
-
# Nested rule blocks for the rules this run writes (plan step 6, HIGH 5).
|
|
1439
|
-
planned_rules = {i["target"]: text_of(i["content"]) for i in plan.items
|
|
1440
|
-
if i["content"] is not None and i["target"].startswith(".ai/rules/")}
|
|
1441
|
-
if planned_rules:
|
|
1442
|
-
u.rules_update(plan, u.project_runtimes(root), planned=planned_rules)
|
|
1443
|
-
writes = [i for i in plan.items if i["content"] is not None and i["action"] != "delete?"]
|
|
1444
|
-
record = resume or {"version": 1, "mode": args.mode, "adopted_at": u.utc_now(),
|
|
1445
|
-
"original_bytes": 0, "original_skipped": []}
|
|
1446
|
-
record["status"] = "partial"
|
|
1447
|
-
record["planned_writes"] = sorted(set(record.get("planned_writes", [])) | {i["target"] for i in writes})
|
|
1448
|
-
record.setdefault("original_bytes", 0)
|
|
1449
|
-
record.setdefault("original_skipped", [])
|
|
1450
|
-
rec_path = plan.report_dir + "/adopt.json"
|
|
1451
|
-
written, recorded = {}, []
|
|
1452
|
-
try:
|
|
1453
|
-
write_json(u, root, rec_path, record)
|
|
1454
|
-
# Every source cleanup may take away, and every project file outside
|
|
1455
|
-
# .ai/ this run rewrites, is kept before anything is written.
|
|
1456
|
-
for path, _tool, _transform, _n, cleanup in adoption.sources:
|
|
1457
|
-
if cleanup:
|
|
1458
|
-
keep_original_bounded(u, plan, path, record)
|
|
1459
|
-
for item in writes:
|
|
1460
|
-
if not item["target"].startswith(".ai/") and item["expect"] is not None:
|
|
1461
|
-
keep_original_bounded(u, plan, item["target"], record)
|
|
1462
|
-
write_json(u, root, rec_path, record)
|
|
1463
|
-
stop = test_stop_after()
|
|
1464
|
-
for phase in (1, 2):
|
|
1465
|
-
for item in writes:
|
|
1466
|
-
if phase_of(item) == phase:
|
|
1467
|
-
u.apply_item(plan, item, written, recorded)
|
|
1468
|
-
if stop == phase:
|
|
1469
|
-
print("project-update: stopped after phase %d (ADOPT_STOP_AFTER, test only)" % phase)
|
|
1470
|
-
return 3
|
|
1471
|
-
except u.Abort as exc:
|
|
1472
|
-
print("project-update: aborted: %s — re-run --adopt --apply to resume" % exc.why, file=sys.stderr)
|
|
1473
|
-
return 3
|
|
1474
|
-
except OSError as exc:
|
|
1475
|
-
print("project-update: aborted: %s — re-run --adopt --apply to resume" % exc, file=sys.stderr)
|
|
1476
|
-
return 3
|
|
1477
|
-
return finish(plan, adoption, record, rec_path, u, table, instruction_files, shipped_files)
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
def finish(plan, adoption, record, rec_path, u, table, instruction_files, shipped_files):
|
|
1481
|
-
"""The two checks on disk, then the record: adopt.json, report.md, dropped.jsonl."""
|
|
1482
|
-
root = plan.root
|
|
1483
|
-
disk = u.Plan(root) # reads the tree as it is now
|
|
1484
|
-
line_status, lines, missing, missing_n = check_lines(disk, adoption)
|
|
1485
|
-
ref_status, hard_misses, warns = check_refs(disk, adoption, table, instruction_files, shipped_files)
|
|
1486
|
-
added = check_added(disk, adoption)
|
|
1487
|
-
now = u.utc_now()
|
|
1488
|
-
tools = OrderedDict((t, {"files": sum(g.values())}) for t, g in adoption.detected.items())
|
|
1489
|
-
for cand, _row in adoption.candidates: # a split is never `detect`ed, but it is adopted
|
|
1490
|
-
if cand.path in adoption.split_done:
|
|
1491
|
-
tools.setdefault(cand.tool, {"files": 1})
|
|
1492
|
-
sources = []
|
|
1493
|
-
for path, tool, transform, _n, cleanup in adoption.sources:
|
|
1494
|
-
entry = {"path": path, "sha": sha256(disk.read(path) or b""), "tool": tool,
|
|
1495
|
-
"transform": transform, "cleanup": cleanup}
|
|
1496
|
-
item = next((i for i in plan.items if i.get("src") == path and i["content"] is not None), None)
|
|
1497
|
-
if item is not None:
|
|
1498
|
-
entry["dest"] = item["target"]
|
|
1499
|
-
entry["dest_sha"] = sha256(disk.read(item["target"]) or b"")
|
|
1500
|
-
if os.path.exists(os.path.join(root, plan.report_dir, "original", path)):
|
|
1501
|
-
entry["original"] = "original/" + path
|
|
1502
|
-
sources.append(entry)
|
|
1503
|
-
merged = OrderedDict((s["path"], s) for s in record.get("sources", []))
|
|
1504
|
-
for entry in sources: # same-day runs merge by source path (I10)
|
|
1505
|
-
merged[entry["path"]] = entry
|
|
1506
|
-
status = "applied" if "fail" not in (line_status, ref_status) and not added else "incomplete"
|
|
1507
|
-
split = dict(record.get("split", {}))
|
|
1508
|
-
for path, done in adoption.split_done.items():
|
|
1509
|
-
split[path] = {k: done[k] for k in ("by", "proposal_sha", "kept_bytes", "moves", "dropped")}
|
|
1510
|
-
if split:
|
|
1511
|
-
record["split"] = split
|
|
1512
|
-
record.update({
|
|
1513
|
-
"status": status, "mode": adoption.mode, "tools": dict(record.get("tools", {}), **tools),
|
|
1514
|
-
"sources": list(merged.values()), "dropped": len(adoption.dropped),
|
|
1515
|
-
"ignored": sorted({s for a, s, _t in adoption.notes if a == "ignored"}),
|
|
1516
|
-
"unmapped": [p for p, _t in adoption.unmapped],
|
|
1517
|
-
"checks": record_checks(line_status, lines, missing_n, ref_status, hard_misses, warns, now),
|
|
1518
|
-
"cleanup": record.get("cleanup") or {"offered": adoption.mode == "migrate" and status == "applied",
|
|
1519
|
-
"confirmed_by": None, "at": None, "via": None, "unattended": None,
|
|
1520
|
-
"tty": None, "deleted": []},
|
|
1521
|
-
})
|
|
1522
|
-
if adoption.split_done: # R9 for a split, on disk
|
|
1523
|
-
record["checks"]["no_line_added"] = {"status": "fail" if added else "pass", "checked_at": now,
|
|
1524
|
-
"added": len(added)}
|
|
1525
|
-
record.pop("planned_writes", None)
|
|
1526
|
-
write_json(u, root, rec_path, record)
|
|
1527
|
-
u.write_file(root, plan.report_dir + "/dropped.jsonl",
|
|
1528
|
-
"".join(json.dumps(d, ensure_ascii=False) + "\n" for d in adoption.dropped).encode("utf-8"))
|
|
1529
|
-
# The disk re-check is what the record holds, so report.md names the same warnings.
|
|
1530
|
-
adoption.checks["no-line-lost"] = (line_status, lines, missing, missing_n)
|
|
1531
|
-
adoption.checks["no-dangling"] = (ref_status, hard_misses, warns)
|
|
1532
|
-
u.write_file(root, plan.report_dir + "/report.md", report_md(plan, adoption, record).encode("utf-8"))
|
|
1533
|
-
report(plan, adoption, applied=True)
|
|
1534
|
-
skipped = record["original_skipped"]
|
|
1535
|
-
print(" original %d B kept in %s/original/%s" % (
|
|
1536
|
-
record["original_bytes"], plan.report_dir,
|
|
1537
|
-
"; %d skipped (git has them): %s" % (len(skipped), ", ".join(skipped)) if skipped else ""))
|
|
1538
|
-
print(" record %s" % rec_path)
|
|
1539
|
-
if added:
|
|
1540
|
-
row_fmt = " %-9s %s"
|
|
1541
|
-
print(row_fmt % ("check", "no-line-added FAIL: %s" % ", ".join(added[:20])))
|
|
1542
|
-
if status != "applied":
|
|
1543
|
-
print("%s: a check failed on disk — see %s" % (INCOMPLETE, rec_path))
|
|
1544
|
-
return 4
|
|
1545
|
-
return 0
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
def record_checks(line_status, lines, missing_n, ref_status, hard_misses, warn_misses, now):
|
|
1549
|
-
return {"no_line_lost": {"status": line_status, "checked_at": now, "lines": lines, "missing": missing_n},
|
|
1550
|
-
"no_dangling": {"status": ref_status, "checked_at": now, "hard": len(hard_misses), "warn": len(warn_misses)}}
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
def report_md(plan, adoption, record):
|
|
1554
|
-
"""The human rendering of the record, in the intent's order."""
|
|
1555
|
-
out = ["# Adopt %s (%s)" % (plan.report_dir.rsplit("adopt-", 1)[-1], adoption.mode), ""]
|
|
1556
|
-
out += ["## Detected", ""] + ["- %s: %d file(s)" % (t, v["files"]) for t, v in record["tools"].items()]
|
|
1557
|
-
out += ["", "## Where each piece went", ""]
|
|
1558
|
-
out += ["- `%s` -> `%s` (%s)" % (s["path"], s["dest"], s["transform"]) for s in record["sources"] if s.get("dest")]
|
|
1559
|
-
out += ["", "## Dropped", ""]
|
|
1560
|
-
out += ["- `%s%s`: %s" % (d["source"], "" if d["line"] == "*" else ":%s" % d["line"], d["reason"])
|
|
1561
|
-
for d in adoption.dropped]
|
|
1562
|
-
out += ["", "## Ignored or unmapped", ""] + ["- ignored `%s`" % p for p in record["ignored"]]
|
|
1563
|
-
out += ["- unmapped `%s`" % p for p in record["unmapped"]]
|
|
1564
|
-
checks = record["checks"]
|
|
1565
|
-
out += ["", "## Checks", "",
|
|
1566
|
-
"- no-line-lost: %s (%d lines, %d missing)" % (
|
|
1567
|
-
checks["no_line_lost"]["status"], checks["no_line_lost"]["lines"], checks["no_line_lost"]["missing"]),
|
|
1568
|
-
"- no-dangling: %s (%d hard, %d warning(s))" % (
|
|
1569
|
-
checks["no_dangling"]["status"], checks["no_dangling"]["hard"], checks["no_dangling"]["warn"])]
|
|
1570
|
-
warns = adoption.checks.get("no-dangling", (None, [], []))[2]
|
|
1571
|
-
out += [" - warning `%s:%s` -> `%s`" % w for w in warns[:20]]
|
|
1572
|
-
if len(warns) > 20:
|
|
1573
|
-
out += [" - and %d more" % (len(warns) - 20)]
|
|
1574
|
-
offered = [s["path"] for s in record["sources"]
|
|
1575
|
-
if s.get("cleanup") and os.path.exists(os.path.join(plan.root, s["path"]))]
|
|
1576
|
-
out += ["", "## Proposed for deletion", ""]
|
|
1577
|
-
if offered and record["cleanup"]["offered"]:
|
|
1578
|
-
out += ["- `%s`" % p for p in offered]
|
|
1579
|
-
out += ["", REVIEW_LINE]
|
|
1580
|
-
else:
|
|
1581
|
-
out += ["- nothing"]
|
|
1582
|
-
return "\n".join(out) + "\n"
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
# ---------------------------------------------------------------------------
|
|
1586
|
-
# --adopt --cleanup (R13, R14's refusal): the one deletion adopt makes, behind
|
|
1587
|
-
# the record, a clean tree, both checks recomputed, and a human
|
|
1588
|
-
# ---------------------------------------------------------------------------
|
|
1589
|
-
|
|
1590
|
-
CLEANUP_HEADING = "## Cleanup"
|
|
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."
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_files):
|
|
1597
|
-
"""(refusal, where, record, targets, adoption). `refusal` is (reason, how)
|
|
1598
|
-
or None; every gate runs before anything is listed, so a refusal never
|
|
1599
|
-
shows a list to confirm. `adoption` is set once the checks were recomputed."""
|
|
1600
|
-
root = plan.root
|
|
1601
|
-
|
|
1602
|
-
def no(reason, how, adoption=None):
|
|
1603
|
-
return (reason, how), None, None, None, adoption
|
|
1604
|
-
if not os.path.isdir(os.path.join(root, ".ai")):
|
|
1605
|
-
return no("the project has no .ai/", "run /rr-setup first")
|
|
1606
|
-
where = latest_with(root, "adopt.json")
|
|
1607
|
-
record = read_record(root, where) if where else None
|
|
1608
|
-
if record is None:
|
|
1609
|
-
return no("no adopt record to clean up", "run /rr-setup --adopt --apply first")
|
|
1610
|
-
if args.mode == "coexist" or record.get("mode") == "coexist":
|
|
1611
|
-
return no("coexist keeps the foreign files",
|
|
1612
|
-
"to move them, run /rr-setup --adopt --apply (mode migrate), then --cleanup")
|
|
1613
|
-
checks = record.get("checks", {})
|
|
1614
|
-
if record.get("status") != "applied" or any(c.get("status") != "pass" for c in checks.values()):
|
|
1615
|
-
return no("the adopt of %s is not complete (R12)" % where.rsplit("adopt-", 1)[-1],
|
|
1616
|
-
"run /rr-setup --adopt --apply until both checks pass")
|
|
1617
|
-
running = task_in_flight(root)
|
|
1618
|
-
if running:
|
|
1619
|
-
return no("a task is in flight (%s)" % running, "finish or abandon it with /rr-build first")
|
|
1620
|
-
dirty = clean_tree(root)
|
|
1621
|
-
if dirty is None:
|
|
1622
|
-
return no("the project is not a git work tree", "cleanup deletes only what git can restore")
|
|
1623
|
-
if dirty: # no exceptions: `git checkout -- <path>` is the rollback
|
|
1624
|
-
more = " and %d more" % (len(dirty) - 3) if len(dirty) > 3 else ""
|
|
1625
|
-
return no("the tree has uncommitted changes: %s%s" % (", ".join(dirty[:3]), more),
|
|
1626
|
-
"commit the adopt (its record included) first, so the deletion is its own change")
|
|
1627
|
-
unsafe = [str(s.get("path")) for s in record.get("sources", []) if s.get("cleanup") and not safe_rel(s.get("path"))]
|
|
1628
|
-
if unsafe:
|
|
1629
|
-
return no("adopt.json names path(s) outside the project: %s" % ", ".join(unsafe[:3]),
|
|
1630
|
-
"the record was edited; restore it from git, then --cleanup")
|
|
1631
|
-
targets, changed = [], []
|
|
1632
|
-
for src in record.get("sources", []):
|
|
1633
|
-
if not src.get("cleanup"):
|
|
1634
|
-
continue # safe_rel above holds for every cleanup entry, and only for those
|
|
1635
|
-
path = src["path"]
|
|
1636
|
-
if path in INSTRUCTION_ROOT or not os.path.isfile(os.path.join(root, path)):
|
|
1637
|
-
continue # R17: an instruction file is never deleted
|
|
1638
|
-
if sha256(plan.read(path) or b"") != src.get("sha"):
|
|
1639
|
-
changed.append(path)
|
|
1640
|
-
targets.append(src)
|
|
1641
|
-
if changed:
|
|
1642
|
-
return no("source(s) changed since the adopt: %s" % ", ".join(changed[:3]),
|
|
1643
|
-
"an adopted destination is never rewritten, so another --apply cannot take the change; "
|
|
1644
|
-
"restore the adopted version from git (`git log -p` on that file) and then --cleanup, "
|
|
1645
|
-
"or carry the change into the destination by hand and keep the foreign file")
|
|
1646
|
-
# R13: both checks recomputed on the tree as it is now. A pending adopt
|
|
1647
|
-
# write would let them pass against the plan instead of the disk.
|
|
1648
|
-
adoption = plan_adopt(plan, table, "migrate", None, u.shipped_block, skeleton_cap,
|
|
1649
|
-
instruction_files, shipped_files)
|
|
1650
|
-
what = ["%s FAIL" % name for name, c in adoption.checks.items() if c[0] == "fail"]
|
|
1651
|
-
pending = [i for i in plan.items if i["action"] != "delete?"]
|
|
1652
|
-
if pending:
|
|
1653
|
-
what.append("%d adopt item(s) pending" % len(pending))
|
|
1654
|
-
if adoption.unmapped or adoption.split:
|
|
1655
|
-
what.append("%d unmapped, %d split(s) waiting" % (len(adoption.unmapped), len(adoption.split)))
|
|
1656
|
-
if what:
|
|
1657
|
-
return no("the adopt no longer holds on the current tree: %s" % "; ".join(what),
|
|
1658
|
-
"run /rr-setup --adopt to see why, settle it, then --cleanup", adoption)
|
|
1659
|
-
# Only what the adopt finds again as a cleanup source, and only what git
|
|
1660
|
-
# can restore: an ignored file skipped from original/ would be gone for good.
|
|
1661
|
-
found = {path for path, _t, _tr, _n, cleanup in adoption.sources if cleanup}
|
|
1662
|
-
stranger = [s["path"] for s in targets if s["path"] not in found]
|
|
1663
|
-
if stranger:
|
|
1664
|
-
return no("adopt.json lists file(s) the adopt does not map for cleanup: %s" % ", ".join(stranger[:3]),
|
|
1665
|
-
"the record was edited; restore it from git, then --cleanup")
|
|
1666
|
-
tracked = set(git_files(root) or [])
|
|
1667
|
-
untracked = [s["path"] for s in targets if s["path"] not in tracked]
|
|
1668
|
-
if untracked:
|
|
1669
|
-
return no("file(s) git does not track (ignored?): %s" % ", ".join(untracked[:3]),
|
|
1670
|
-
"cleanup deletes only what git can restore; commit them or delete them yourself")
|
|
1671
|
-
return None, where, record, targets, adoption
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
def cleanup_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
|
|
1675
|
-
"""`update.py --adopt --cleanup [--apply --confirm-delete NAME]`. Exit 0 or
|
|
1676
|
-
5 (refused); a failed removal is exit 3 with the record naming what went."""
|
|
1677
|
-
try:
|
|
1678
|
-
table = load_table()
|
|
1679
|
-
except TableError as exc:
|
|
1680
|
-
print("project-update: %s" % exc, file=sys.stderr)
|
|
1681
|
-
return 2
|
|
1682
|
-
refusal, where, record, targets, adoption = plan_cleanup(
|
|
1683
|
-
plan, args, u, table, skeleton_cap, instruction_files, shipped_files)
|
|
1684
|
-
if refusal:
|
|
1685
|
-
code = refuse(*refusal)
|
|
1686
|
-
if adoption is not None:
|
|
1687
|
-
report(plan, adoption) # the recomputed checks, so the human sees what fails
|
|
1688
|
-
return code
|
|
1689
|
-
confirm = (getattr(args, "confirm_delete", None) or "").strip()
|
|
1690
|
-
if confirm and placeholder_name(confirm): # update.py checks too; this module stands alone
|
|
1691
|
-
return refuse("--confirm-delete got the placeholder %r" % confirm,
|
|
1692
|
-
"type your own name: it is the audit trail")
|
|
1693
|
-
passed, via, why = human_gate() if confirm else (False, None, "")
|
|
1694
|
-
if confirm and not passed: # update.py checks too; this module stands alone
|
|
1695
|
-
return refuse("--confirm-delete is typed by a human, and %s" % why,
|
|
1696
|
-
"run the same command yourself in a terminal outside Claude Code or Codex")
|
|
1697
|
-
date = where.rsplit("adopt-", 1)[-1]
|
|
1698
|
-
head = "adopt cleanup of %s, applied" % date if confirm else \
|
|
1699
|
-
"adopt cleanup dry run of %s, nothing deleted; a human confirms with --apply --confirm-delete NAME" % date
|
|
1700
|
-
print("project-update: %s (%s)" % (plan.root, head))
|
|
1701
|
-
width = max([len(s["path"]) for s in targets] + [12])
|
|
1702
|
-
for name in ("no-line-lost", "no-dangling"):
|
|
1703
|
-
print(" %-9s %-*s PASS (recomputed on the current tree)" % ("check", width, name))
|
|
1704
|
-
if not targets:
|
|
1705
|
-
print("0 deletion(s): nothing left to clean up")
|
|
1706
|
-
return 0
|
|
1707
|
-
if not confirm:
|
|
1708
|
-
for src in targets:
|
|
1709
|
-
print(" %-9s %-*s [%s] %s; needs --apply --confirm-delete NAME"
|
|
1710
|
-
% ("delete?", width, src["path"], src["tool"], src["transform"]))
|
|
1711
|
-
print("%d deletion(s) awaiting --confirm-delete" % len(targets))
|
|
1712
|
-
return 0
|
|
1713
|
-
tty = os.isatty(0)
|
|
1714
|
-
prior = record.get("cleanup") or {}
|
|
1715
|
-
history = list(prior.get("history") or [])
|
|
1716
|
-
if prior.get("at"): # an earlier, interrupted cleanup keeps its line in the trail
|
|
1717
|
-
history.append({k: prior.get(k) for k in ("confirmed_by", "at", "via", "unattended", "tty")})
|
|
1718
|
-
record["cleanup"] = {"offered": True, "confirmed_by": confirm, "at": u.utc_now(), "via": via,
|
|
1719
|
-
"unattended": via == "unattended", "tty": tty,
|
|
1720
|
-
"deleted": list(prior.get("deleted") or [])}
|
|
1721
|
-
if history:
|
|
1722
|
-
record["cleanup"]["history"] = history
|
|
1723
|
-
plan.report_dir = plan.adopt_report_dir = where # the record's own directory, not today's
|
|
1724
|
-
rec_path = where + "/adopt.json"
|
|
1725
|
-
write_json(u, plan.root, rec_path, record) # who and when, before the first removal
|
|
1726
|
-
code = 0
|
|
1727
|
-
try:
|
|
1728
|
-
for src in targets:
|
|
1729
|
-
remove_confirmed(u, plan, src["path"], record, table)
|
|
1730
|
-
print(" %-9s %-*s [%s] confirmed by %s" % ("delete", width, src["path"], src["tool"], confirm))
|
|
1731
|
-
except OSError as exc:
|
|
1732
|
-
print("project-update: aborted: %s — %s names what was deleted" % (exc, rec_path), file=sys.stderr)
|
|
1733
|
-
code = 3
|
|
1734
|
-
write_json(u, plan.root, rec_path, record)
|
|
1735
|
-
cleanup_report_md(u, plan.root, where, record)
|
|
1736
|
-
print("%d deleted, confirmed by %s%s; originals in %s/original/" % (
|
|
1737
|
-
len(record["cleanup"]["deleted"]), confirm,
|
|
1738
|
-
" (deleted unattended)" if record["cleanup"]["unattended"] else "", where))
|
|
1739
|
-
return code
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
def cleanup_top(rel, tool_roots):
|
|
1743
|
-
"""The highest directory a cleanup of `rel` may remove once it is empty: a
|
|
1744
|
-
directory root (`.specify/`) itself, or the directory a glob root
|
|
1745
|
-
(`.claude/skills/speckit-*`) matches — with everything below it, nested
|
|
1746
|
-
directories included, and nothing above it. None when `rel` is under no
|
|
1747
|
-
root that names a directory."""
|
|
1748
|
-
for root in tool_roots:
|
|
1749
|
-
if root.endswith("/") and rel.startswith(root):
|
|
1750
|
-
return root.rstrip("/")
|
|
1751
|
-
globs = [glob_re(r) for r in tool_roots if "*" in r]
|
|
1752
|
-
ancestor = os.path.dirname(rel)
|
|
1753
|
-
while ancestor:
|
|
1754
|
-
if any(rx.match(ancestor) for rx in globs):
|
|
1755
|
-
return ancestor
|
|
1756
|
-
ancestor = os.path.dirname(ancestor)
|
|
1757
|
-
return None
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
def remove_confirmed(u, plan, rel, record, table):
|
|
1761
|
-
"""Delete one confirmed source: its original kept first (bounded, into the
|
|
1762
|
-
record's directory), then the file, then any directory left empty inside
|
|
1763
|
-
its tool's roots — so the signature goes with the files."""
|
|
1764
|
-
if not safe_rel(rel): # plan_cleanup refused it already; never trust one caller
|
|
1765
|
-
raise OSError("refusing to delete %r: not a path inside the project" % rel)
|
|
1766
|
-
keep_original_bounded(u, plan, rel, record)
|
|
1767
|
-
os.remove(os.path.join(plan.root, rel))
|
|
1768
|
-
record["cleanup"]["deleted"].append(rel)
|
|
1769
|
-
tool = next((s["tool"] for s in record.get("sources", []) if s["path"] == rel), None)
|
|
1770
|
-
top = cleanup_top(rel, table["tools"].get(tool, {}).get("roots", []))
|
|
1771
|
-
parent = os.path.dirname(rel)
|
|
1772
|
-
while top and parent and (parent == top or parent.startswith(top + "/")):
|
|
1773
|
-
full = os.path.join(plan.root, parent)
|
|
1774
|
-
if not os.path.isdir(full) or os.listdir(full):
|
|
1775
|
-
break
|
|
1776
|
-
os.rmdir(full)
|
|
1777
|
-
parent = os.path.dirname(parent)
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
def cleanup_report_md(u, root, where, record):
|
|
1781
|
-
"""report.md gains (or replaces) its Cleanup section."""
|
|
1782
|
-
path = os.path.join(root, where, "report.md")
|
|
1783
|
-
try:
|
|
1784
|
-
with open(path, encoding="utf-8") as fh:
|
|
1785
|
-
text = fh.read()
|
|
1786
|
-
except OSError:
|
|
1787
|
-
text = ""
|
|
1788
|
-
text = text.split("\n" + CLEANUP_HEADING + "\n", 1)[0].rstrip("\n") + "\n"
|
|
1789
|
-
for line in (REVIEW_LINE, LEGACY_REVIEW_LINE):
|
|
1790
|
-
text = text.replace(line, "The deletion is recorded under Cleanup below.")
|
|
1791
|
-
c = record["cleanup"]
|
|
1792
|
-
runs = list(c.get("history") or []) + [c]
|
|
1793
|
-
|
|
1794
|
-
def who(entry):
|
|
1795
|
-
return "%s, confirmed by %s%s" % (entry.get("at"), entry.get("confirmed_by"),
|
|
1796
|
-
" (deleted unattended)" if entry.get("unattended") else "")
|
|
1797
|
-
# deleted is cumulative and not per run, so an earlier interrupted run is
|
|
1798
|
-
# named, never credited with a count it cannot prove.
|
|
1799
|
-
out = ["", CLEANUP_HEADING, "", "- %d file(s) deleted over %d run(s)" % (len(c["deleted"]), len(runs))]
|
|
1800
|
-
out += ["- run at %s" % who(entry) for entry in runs]
|
|
1801
|
-
out += ["- `%s`" % p for p in c["deleted"]]
|
|
1802
|
-
u.write_file(root, where + "/report.md", (text + "\n".join(out) + "\n").encode("utf-8"))
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
# ---------------------------------------------------------------------------
|
|
1806
|
-
# The instruction-file split (R7, R8, R9, R17, R19): a request with no text,
|
|
1807
|
-
# a proposal of line ranges, the deterministic fallback, the diff
|
|
1808
|
-
# ---------------------------------------------------------------------------
|
|
1809
|
-
|
|
1810
|
-
SPLIT_KEYS = frozenset(("version", "source", "source_sha", "keep", "moves", "dropped"))
|
|
1811
|
-
MOVE_KEYS = frozenset(("lines", "dest", "heading", "dirs", "paths"))
|
|
1812
|
-
DROP_KEYS = frozenset(("lines", "why"))
|
|
1813
|
-
ALLOWED_DEST = (".ai/policies/adopted/<slug>.md", ".ai/rules/<slug>.md", ".ai/project/<slug>.md",
|
|
1814
|
-
"docs/sdlc/constitution.md")
|
|
1815
|
-
ALLOWED_DEST_RE = re.compile(r"\A(?:\.ai/(?:policies/adopted|rules|project)/[a-z0-9][a-z0-9-]*\.md"
|
|
1816
|
-
r"|docs/sdlc/constitution\.md)\Z")
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
class SplitError(Exception):
|
|
1820
|
-
"""A proposal the tool will not apply: exit 4 with this reason."""
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
class Candidate:
|
|
1824
|
-
"""A root instruction file over `_budgets.skeleton` once its block is the
|
|
1825
|
-
shipped one. `nums` are the 1-based lines a split must place: every line
|
|
1826
|
-
outside the block, or only the markers left out when the block was edited
|
|
1827
|
-
(WP3 R13's case), so the human's edits inside it are split too."""
|
|
1828
|
-
|
|
1829
|
-
def __init__(self, path, tool, text, shipped, cap):
|
|
1830
|
-
self.path, self.tool, self.text, self.shipped = path, tool, text, shipped
|
|
1831
|
-
self.sha = sha256(text.encode("utf-8"))
|
|
1832
|
-
self.lines = text.split("\n")
|
|
1833
|
-
if self.lines and self.lines[-1] == "":
|
|
1834
|
-
self.lines.pop()
|
|
1835
|
-
found = render_instructions.find_block(text)
|
|
1836
|
-
block = self.block = None
|
|
1837
|
-
if found is not None:
|
|
1838
|
-
block = text[found[0]:found[1]]
|
|
1839
|
-
start = next(i for i, l in enumerate(self.lines, 1) if found[2] in l)
|
|
1840
|
-
end = next(i for i, l in enumerate(self.lines, 1) if found[3] in l)
|
|
1841
|
-
self.block = (start, end)
|
|
1842
|
-
# A legacy pair is compared with its markers renamed: a pristine legacy
|
|
1843
|
-
# block is the shipped block, not an edit.
|
|
1844
|
-
self.edited = block is not None and render_instructions.current_markers(block, found[2], found[3]) != shipped
|
|
1845
|
-
pointer = {n for n, l in enumerate(self.lines, 1) if l == ALWAYS_POINTER}
|
|
1846
|
-
if self.block is None:
|
|
1847
|
-
self.nums = list(range(1, len(self.lines) + 1))
|
|
1848
|
-
elif self.edited:
|
|
1849
|
-
self.nums = [n for n in range(1, len(self.lines) + 1) if n not in self.block]
|
|
1850
|
-
else:
|
|
1851
|
-
self.nums = [n for n in range(1, len(self.lines) + 1) if not self.block[0] <= n <= self.block[1]]
|
|
1852
|
-
# A4: the pointer to ALWAYS_FILE is the plugin's own line; a split keeps
|
|
1853
|
-
# it where it is (`rewritten`), and it is paid for out of the budget.
|
|
1854
|
-
self.nums = [n for n in self.nums if n not in pointer]
|
|
1855
|
-
self.keep_budget = cap - len(shipped.encode("utf-8")) - sum(len(ALWAYS_POINTER.encode("utf-8")) + 1
|
|
1856
|
-
for _ in pointer)
|
|
1857
|
-
self.outline = [{"line": n, "heading": self.lines[n - 1]} for n in self.nums
|
|
1858
|
-
if HEADING_RE.match(self.lines[n - 1])]
|
|
1859
|
-
|
|
1860
|
-
def line(self, n):
|
|
1861
|
-
return self.lines[n - 1]
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
def file_slug(path):
|
|
1865
|
-
return slugify(path) # CLAUDE.md -> claude-md, .junie/guidelines.md -> junie-guidelines-md
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
def spans(nums):
|
|
1869
|
-
"""[3, 4, 5, 9] -> "3-5, 9"."""
|
|
1870
|
-
out, nums = [], sorted(nums)
|
|
1871
|
-
for n in nums:
|
|
1872
|
-
if out and n == out[-1][1] + 1:
|
|
1873
|
-
out[-1][1] = n
|
|
1874
|
-
else:
|
|
1875
|
-
out.append([n, n])
|
|
1876
|
-
return ", ".join("%d" % a if a == b else "%d-%d" % (a, b) for a, b in out)
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
def request_name(cand, all_candidates, kind):
|
|
1880
|
-
"""`split-request.json` / `split-proposal.json` for one candidate, with the
|
|
1881
|
-
file slug in the name when several files are split in one adopt."""
|
|
1882
|
-
if len(all_candidates) == 1:
|
|
1883
|
-
return "split-%s.json" % kind
|
|
1884
|
-
return "split-%s-%s.json" % (kind, file_slug(cand.path))
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
def split_request(cand, where, all_candidates):
|
|
1888
|
-
"""I4: the outline, the allow-list and the keep budget. No text."""
|
|
1889
|
-
return OrderedDict([
|
|
1890
|
-
("version", 1), ("source", cand.path), ("source_sha", cand.sha), ("lines", len(cand.lines)),
|
|
1891
|
-
("block", list(cand.block) if cand.block else None), ("block_edited", cand.edited),
|
|
1892
|
-
("cover", spans(cand.nums)), ("outline", cand.outline), ("keep_budget_bytes", cand.keep_budget),
|
|
1893
|
-
("allowed_dest", list(ALLOWED_DEST)),
|
|
1894
|
-
("proposal", "%s/%s" % (where, request_name(cand, all_candidates, "proposal")))])
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
def find_proposal(root, cand, stale=False):
|
|
1898
|
-
"""(where, data) of a split-proposal*.json in any adopt-* directory whose
|
|
1899
|
-
`source` and `source_sha` match the file on disk; newest first. One
|
|
1900
|
-
proposal per sha: a match is always reused, never re-requested (R7).
|
|
1901
|
-
With `stale`, the newest proposal for this file whatever its sha."""
|
|
1902
|
-
base = os.path.join(root, ".ai", "reports")
|
|
1903
|
-
try:
|
|
1904
|
-
dirs = sorted((d for d in os.listdir(base) if d.startswith("adopt-")), reverse=True)
|
|
1905
|
-
except OSError:
|
|
1906
|
-
return None, None
|
|
1907
|
-
for d in dirs:
|
|
1908
|
-
try:
|
|
1909
|
-
names = sorted(n for n in os.listdir(os.path.join(base, d))
|
|
1910
|
-
if n.startswith("split-proposal") and n.endswith(".json"))
|
|
1911
|
-
except OSError:
|
|
1912
|
-
continue
|
|
1913
|
-
for name in names:
|
|
1914
|
-
try:
|
|
1915
|
-
with open(os.path.join(base, d, name), encoding="utf-8") as fh:
|
|
1916
|
-
data = json.load(fh)
|
|
1917
|
-
except (OSError, ValueError, RecursionError):
|
|
1918
|
-
continue
|
|
1919
|
-
if isinstance(data, dict) and data.get("source") == cand.path and (stale or data.get("source_sha") == cand.sha):
|
|
1920
|
-
return ".ai/reports/%s/%s" % (d, name), data
|
|
1921
|
-
return None, None
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
def _range(value, cand, what):
|
|
1925
|
-
if (not isinstance(value, list) or len(value) != 2 or not all(isinstance(v, int) and not isinstance(v, bool)
|
|
1926
|
-
for v in value)):
|
|
1927
|
-
raise SplitError("%s: a range is [first, last], two line numbers" % what)
|
|
1928
|
-
first, last = value
|
|
1929
|
-
if not 1 <= first <= last <= len(cand.lines):
|
|
1930
|
-
raise SplitError("%s: range [%d, %d] is outside lines 1-%d" % (what, first, last, len(cand.lines)))
|
|
1931
|
-
return list(range(first, last + 1))
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
def _strings(value, what):
|
|
1935
|
-
if not isinstance(value, list) or not value or not all(isinstance(v, str) and v for v in value):
|
|
1936
|
-
raise SplitError("%s must be a non-empty list of strings" % what)
|
|
1937
|
-
return value
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
def load_proposal(data, cand, root, tracked):
|
|
1941
|
-
"""Validate an I5 proposal against the file on disk. Returns
|
|
1942
|
-
(keep nums, moves, dropped) where each move is a dict with `nums`; raises
|
|
1943
|
-
SplitError naming the first reason it cannot be applied. No key carries
|
|
1944
|
-
text into a destination: `heading` must be empty or an outline heading,
|
|
1945
|
-
byte for byte (plan-review MEDIUM 14)."""
|
|
1946
|
-
if not isinstance(data, dict):
|
|
1947
|
-
raise SplitError("the proposal is not a JSON object")
|
|
1948
|
-
extra = sorted(set(data) - SPLIT_KEYS)
|
|
1949
|
-
if extra:
|
|
1950
|
-
raise SplitError("unknown key(s) %s — a proposal carries line ranges, never text" % ", ".join(extra))
|
|
1951
|
-
if data.get("version") != 1:
|
|
1952
|
-
raise SplitError("version must be 1")
|
|
1953
|
-
if data.get("source") != cand.path:
|
|
1954
|
-
raise SplitError("source is %r, not %s" % (data.get("source"), cand.path))
|
|
1955
|
-
if data.get("source_sha") != cand.sha:
|
|
1956
|
-
raise SplitError("source_sha does not match %s on disk — ask for a new --split-request" % cand.path)
|
|
1957
|
-
covered = {}
|
|
1958
|
-
|
|
1959
|
-
def claim(nums, what):
|
|
1960
|
-
for n in nums:
|
|
1961
|
-
if n in covered:
|
|
1962
|
-
raise SplitError("line %d is in both %s and %s" % (n, covered[n], what))
|
|
1963
|
-
if n not in cand_nums:
|
|
1964
|
-
raise SplitError("%s: line %d is %s" % (what, n, "a managed-block marker" if cand.edited
|
|
1965
|
-
else "inside the managed block"))
|
|
1966
|
-
covered[n] = what
|
|
1967
|
-
return nums
|
|
1968
|
-
cand_nums = set(cand.nums)
|
|
1969
|
-
for key in ("keep", "moves", "dropped"):
|
|
1970
|
-
if not isinstance(data.get(key, []), list):
|
|
1971
|
-
raise SplitError("%s must be a list" % key)
|
|
1972
|
-
keep = []
|
|
1973
|
-
for i, rng in enumerate(data.get("keep", []), 1):
|
|
1974
|
-
keep += claim(_range(rng, cand, "keep[%d]" % i), "keep[%d]" % i)
|
|
1975
|
-
headings = {o["heading"] for o in cand.outline}
|
|
1976
|
-
moves = []
|
|
1977
|
-
for i, move in enumerate(data.get("moves", []), 1):
|
|
1978
|
-
what = "moves[%d]" % i
|
|
1979
|
-
if not isinstance(move, dict):
|
|
1980
|
-
raise SplitError("%s is not an object" % what)
|
|
1981
|
-
extra = sorted(set(move) - MOVE_KEYS)
|
|
1982
|
-
if extra:
|
|
1983
|
-
raise SplitError("%s: unknown key(s) %s — a move carries line ranges, never text" % (what, ", ".join(extra)))
|
|
1984
|
-
dest = move.get("dest")
|
|
1985
|
-
if not isinstance(dest, str) or not ALLOWED_DEST_RE.match(dest):
|
|
1986
|
-
raise SplitError("%s: dest %r is not one of %s" % (what, dest, ", ".join(ALLOWED_DEST)))
|
|
1987
|
-
heading = move.get("heading") or ""
|
|
1988
|
-
if not isinstance(heading, str) or (heading and heading not in headings):
|
|
1989
|
-
raise SplitError("%s: heading %r is not a heading of the request's outline" % (what, heading))
|
|
1990
|
-
rule = dest.startswith(".ai/rules/")
|
|
1991
|
-
dirs, paths = [], []
|
|
1992
|
-
if rule:
|
|
1993
|
-
dirs = _strings(move.get("dirs"), "%s: dirs (required for .ai/rules/)" % what)
|
|
1994
|
-
for d in dirs:
|
|
1995
|
-
d = d.rstrip("/")
|
|
1996
|
-
if d.startswith("/") or ".." in d.split("/") or not os.path.isdir(os.path.join(root, d)):
|
|
1997
|
-
raise SplitError("%s: dirs entry %r is not a directory of the tree" % (what, d))
|
|
1998
|
-
if "paths" in move:
|
|
1999
|
-
paths = _strings(move["paths"], "%s: paths" % what)
|
|
2000
|
-
for p in paths:
|
|
2001
|
-
pat = glob_re(p)
|
|
2002
|
-
if not any(pat.match(f) for f in tracked or ()):
|
|
2003
|
-
raise SplitError("%s: paths glob %r matches no tracked file" % (what, p))
|
|
2004
|
-
elif "dirs" in move or "paths" in move:
|
|
2005
|
-
raise SplitError("%s: dirs and paths belong to an .ai/rules/ destination only" % what)
|
|
2006
|
-
nums = claim(_range(move.get("lines"), cand, what), what)
|
|
2007
|
-
moves.append({"nums": nums, "dest": dest, "heading": heading, "dirs": [d.rstrip("/") for d in dirs],
|
|
2008
|
-
"paths": paths})
|
|
2009
|
-
dropped = []
|
|
2010
|
-
for i, drop in enumerate(data.get("dropped", []), 1):
|
|
2011
|
-
what = "dropped[%d]" % i
|
|
2012
|
-
if not isinstance(drop, dict):
|
|
2013
|
-
raise SplitError("%s is not an object" % what)
|
|
2014
|
-
extra = sorted(set(drop) - DROP_KEYS)
|
|
2015
|
-
if extra:
|
|
2016
|
-
raise SplitError("%s: unknown key(s) %s" % (what, ", ".join(extra)))
|
|
2017
|
-
why = drop.get("why")
|
|
2018
|
-
if not isinstance(why, str) or not why.strip():
|
|
2019
|
-
raise SplitError("%s: why must say why the lines are dropped" % what)
|
|
2020
|
-
dropped.append({"nums": claim(_range(drop.get("lines"), cand, what), what), "why": why.strip()})
|
|
2021
|
-
gap = cand_nums - set(covered)
|
|
2022
|
-
if gap:
|
|
2023
|
-
raise SplitError("lines %s are in no range — every line outside the block is kept, moved or dropped"
|
|
2024
|
-
% spans(gap))
|
|
2025
|
-
kept = sum(len(cand.line(n).encode("utf-8")) + 1 for n in keep)
|
|
2026
|
-
if kept > cand.keep_budget:
|
|
2027
|
-
raise SplitError("keep is %d B, over the keep budget of %d B" % (kept, cand.keep_budget))
|
|
2028
|
-
return sorted(keep), moves, dropped
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
def fallback_split(cand):
|
|
2032
|
-
"""R8's deterministic split: every line to place goes, verbatim, to one
|
|
2033
|
-
`.ai/policies/adopted/<file-slug>.md`. No model."""
|
|
2034
|
-
return [], [{"nums": list(cand.nums), "dest": ".ai/policies/adopted/%s.md" % file_slug(cand.path),
|
|
2035
|
-
"heading": "", "dirs": [], "paths": []}], []
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
def rewritten(cand, keep):
|
|
2039
|
-
"""The instruction file after the split: the kept lines where they were,
|
|
2040
|
-
the shipped block in place of the old one (at the end when there was none)."""
|
|
2041
|
-
keep = set(keep)
|
|
2042
|
-
out, inside = [], []
|
|
2043
|
-
for n, line in enumerate(cand.lines, 1):
|
|
2044
|
-
if cand.block and n == cand.block[0]:
|
|
2045
|
-
out.append(cand.shipped)
|
|
2046
|
-
elif cand.block and cand.block[0] < n <= cand.block[1]:
|
|
2047
|
-
if n in keep:
|
|
2048
|
-
inside.append(line) # an edited block's kept lines follow the shipped block
|
|
2049
|
-
if n == cand.block[1]:
|
|
2050
|
-
out += inside
|
|
2051
|
-
elif n in keep or line == ALWAYS_POINTER:
|
|
2052
|
-
out.append(line)
|
|
2053
|
-
if cand.block is None:
|
|
2054
|
-
while out and not out[-1].strip():
|
|
2055
|
-
out.pop()
|
|
2056
|
-
out += ([""] if out else []) + [cand.shipped]
|
|
2057
|
-
return "\n".join(out).strip("\n") + "\n"
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
def section_of(cand, move):
|
|
2061
|
-
body = "\n".join(cand.line(n) for n in move["nums"]).strip("\n")
|
|
2062
|
-
heading = move["heading"] or "## Adopted from %s, lines %s" % (cand.path, spans(move["nums"]))
|
|
2063
|
-
first = body.split("\n", 1)[0]
|
|
2064
|
-
return (body if first == heading else heading + "\n\n" + body), heading
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
def plan_split(plan, adoption, cand, row_n, how, keep, moves, dropped, proposal_sha=None):
|
|
2068
|
-
"""Put one split on `plan`: the destinations first, then the rewrite of
|
|
2069
|
-
the instruction file (apply's phase 2, so no line leaves before it has
|
|
2070
|
-
somewhere to be)."""
|
|
2071
|
-
tag = "[%s] split (%s)" % (cand.tool, how)
|
|
2072
|
-
generated = set()
|
|
2073
|
-
by_dest = OrderedDict()
|
|
2074
|
-
for move in moves:
|
|
2075
|
-
by_dest.setdefault(move["dest"], []).append(move)
|
|
2076
|
-
for dest, group in by_dest.items():
|
|
2077
|
-
sections = []
|
|
2078
|
-
for move in group:
|
|
2079
|
-
text, heading = section_of(cand, move)
|
|
2080
|
-
sections.append(text)
|
|
2081
|
-
generated.add(normalise(heading))
|
|
2082
|
-
body = "\n\n".join(sections)
|
|
2083
|
-
current = plan.read(dest)
|
|
2084
|
-
adoption.split_before.setdefault(dest, current)
|
|
2085
|
-
current_text = text_of(current or b"")
|
|
2086
|
-
ranges = spans([n for m in group for n in m["nums"]])
|
|
2087
|
-
if body in current_text:
|
|
2088
|
-
adoption.split_dests.append(dest) # already there: a resumed apply
|
|
2089
|
-
continue
|
|
2090
|
-
if dest.startswith(".ai/rules/"):
|
|
2091
|
-
dirs = list(OrderedDict.fromkeys(d for m in group for d in m["dirs"]))
|
|
2092
|
-
paths = list(OrderedDict.fromkeys(p for m in group for p in m["paths"]))
|
|
2093
|
-
front = "---\ndirs: [%s]\n" % ", ".join(dirs)
|
|
2094
|
-
front += ("paths: [%s]\n" % ", ".join('"%s"' % p for p in paths)) if paths else ""
|
|
2095
|
-
front += "---\n"
|
|
2096
|
-
generated.update(normalise(l) for l in front.split("\n"))
|
|
2097
|
-
content = front + "\n" + body + "\n"
|
|
2098
|
-
if current is not None:
|
|
2099
|
-
plan.add("conflict", dest, note=tag + "; the rule exists and differs", src=cand.path, tool=cand.tool)
|
|
2100
|
-
continue
|
|
2101
|
-
else:
|
|
2102
|
-
content = (current_text.rstrip("\n") + "\n\n" if current_text.strip() else "") + body + "\n"
|
|
2103
|
-
plan.add("adopt", dest, note="%s lines %s" % (tag, ranges), content=content.encode("utf-8"),
|
|
2104
|
-
src=cand.path, tool=cand.tool)
|
|
2105
|
-
adoption.split_dests.append(dest)
|
|
2106
|
-
if dest.startswith(".ai/policies/adopted/"):
|
|
2107
|
-
adoption.destinations.append((dest, cand.tool, None, False))
|
|
2108
|
-
for drop in dropped:
|
|
2109
|
-
for n in drop["nums"]:
|
|
2110
|
-
if normalise(cand.line(n)) is not None:
|
|
2111
|
-
adoption.dropped.append({"source": cand.path, "line": n, "text": cand.line(n),
|
|
2112
|
-
"reason": drop["why"], "by": "proposal"})
|
|
2113
|
-
result = rewritten(cand, keep)
|
|
2114
|
-
generated.update(normalise(l) for l in cand.shipped.split("\n"))
|
|
2115
|
-
adoption.split_before.setdefault(cand.path, plan.read(cand.path))
|
|
2116
|
-
if result.encode("utf-8") != plan.read(cand.path):
|
|
2117
|
-
plan.add("adopt", cand.path, note="%s: %d B kept, managed block re-rendered" % (tag, len(result.encode("utf-8"))),
|
|
2118
|
-
content=result.encode("utf-8"), src=cand.path, tool=cand.tool)
|
|
2119
|
-
adoption.split_dests.append(cand.path)
|
|
2120
|
-
if all(s[0] != cand.path for s in adoption.sources):
|
|
2121
|
-
adoption.sources.append((cand.path, cand.tool, "instruction-file", row_n, False))
|
|
2122
|
-
adoption.split_done[cand.path] = {
|
|
2123
|
-
"by": how, "proposal_sha": proposal_sha, "kept_bytes": sum(len(cand.line(n).encode("utf-8")) + 1 for n in keep),
|
|
2124
|
-
"moves": len(moves), "dropped": len(dropped), "text": cand.text, "nums": cand.nums, "generated": generated}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
def check_added(plan, adoption):
|
|
2128
|
-
"""R9 for a split, the other direction of I7: every non-ignorable line of
|
|
2129
|
-
a file the split writes is a source line, a generated line (a heading,
|
|
2130
|
-
frontmatter, the shipped block) or content the file already had.
|
|
2131
|
-
Returns the offending "file:line"s."""
|
|
2132
|
-
if not adoption.split_done:
|
|
2133
|
-
return []
|
|
2134
|
-
allowed = {normalise(ALWAYS_POINTER)} # A4: the plugin's own line, generated like the block
|
|
2135
|
-
for done in adoption.split_done.values():
|
|
2136
|
-
allowed.update(normalise(l) for l in done["text"].split("\n"))
|
|
2137
|
-
allowed.update(done["generated"])
|
|
2138
|
-
bad = []
|
|
2139
|
-
for target in OrderedDict.fromkeys(adoption.split_dests):
|
|
2140
|
-
before = {normalise(l) for l in text_of(adoption.split_before.get(target) or b"").split("\n")}
|
|
2141
|
-
for i, raw in enumerate(text_of(plan.read(target) or b"").split("\n"), 1):
|
|
2142
|
-
n = normalise(raw)
|
|
2143
|
-
if n is not None and n not in allowed and n not in before:
|
|
2144
|
-
bad.append("%s:%d" % (target, i))
|
|
2145
|
-
return bad
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
def plan_splits(plan, adoption, candidates, split_mode, decisions):
|
|
2149
|
-
"""Plan every candidate by proposal or fallback; what cannot be planned
|
|
2150
|
-
stays `split?` (awaiting) or goes to `split_errors` (exit 4)."""
|
|
2151
|
-
tracked = None
|
|
2152
|
-
fallback = decisions.get("split")
|
|
2153
|
-
for cand, row_n in candidates:
|
|
2154
|
-
how = split_mode
|
|
2155
|
-
wants_fallback = fallback == "fallback" or (isinstance(fallback, dict) and fallback.get(cand.path) == "fallback")
|
|
2156
|
-
where, data = (None, None) if how == "fallback" else find_proposal(plan.root, cand)
|
|
2157
|
-
if how is None:
|
|
2158
|
-
how = "proposal" if data is not None else ("fallback" if wants_fallback else None)
|
|
2159
|
-
if how is None:
|
|
2160
|
-
where, data = find_proposal(plan.root, cand, stale=True)
|
|
2161
|
-
how = "proposal" if data is not None else None # stale: validation names the sha
|
|
2162
|
-
if how is None:
|
|
2163
|
-
adoption.split.append(cand.path)
|
|
2164
|
-
adoption.note("split?", cand.path, "%d B outside the block, keep budget %d B; needs --adopt "
|
|
2165
|
-
"--split-request or --split fallback"
|
|
2166
|
-
% (sum(len(cand.line(n).encode("utf-8")) + 1 for n in cand.nums), cand.keep_budget))
|
|
2167
|
-
continue
|
|
2168
|
-
if how == "fallback":
|
|
2169
|
-
plan_split(plan, adoption, cand, row_n, "fallback", *fallback_split(cand))
|
|
2170
|
-
continue
|
|
2171
|
-
if data is None:
|
|
2172
|
-
adoption.split_errors.append((cand.path, "--split proposal, and no split-proposal.json matches "
|
|
2173
|
-
"its sha — run --adopt --split-request"))
|
|
2174
|
-
continue
|
|
2175
|
-
if tracked is None:
|
|
2176
|
-
tracked = git_files(plan.root) or []
|
|
2177
|
-
try:
|
|
2178
|
-
keep, moves, dropped = load_proposal(data, cand, plan.root, tracked)
|
|
2179
|
-
except SplitError as exc:
|
|
2180
|
-
adoption.split_errors.append((cand.path, "%s: %s" % (where, exc)))
|
|
2181
|
-
continue
|
|
2182
|
-
with open(os.path.join(plan.root, where), "rb") as fh:
|
|
2183
|
-
proposal_sha = sha256(fh.read())
|
|
2184
|
-
plan_split(plan, adoption, cand, row_n, "proposal", keep, moves, dropped, proposal_sha)
|
|
2185
|
-
bad = check_added(plan, adoption)
|
|
2186
|
-
if bad:
|
|
2187
|
-
adoption.split_errors.append((", ".join(sorted(adoption.split_done)),
|
|
2188
|
-
"no-line-added FAIL: %d line(s) come from no source: %s"
|
|
2189
|
-
% (len(bad), ", ".join(bad[:20]))))
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
def write_requests(plan, candidates):
|
|
2193
|
-
"""`--adopt --split-request`: one I4 file per candidate with no matching
|
|
2194
|
-
proposal, into today's record. Writes nothing else. Returns the exit code."""
|
|
2195
|
-
if not candidates:
|
|
2196
|
-
print("project-update: no split candidate — nothing to request")
|
|
2197
|
-
return 0
|
|
2198
|
-
where = report_dir(plan)
|
|
2199
|
-
for cand, _row in candidates:
|
|
2200
|
-
found, _data = find_proposal(plan.root, cand)
|
|
2201
|
-
if found:
|
|
2202
|
-
print(" split %s a proposal for this sha exists, reused: %s" % (cand.path, found))
|
|
2203
|
-
continue
|
|
2204
|
-
rel = "%s/%s" % (where, request_name(cand, [c for c, _ in candidates], "request"))
|
|
2205
|
-
full = os.path.join(plan.root, rel)
|
|
2206
|
-
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
2207
|
-
with open(full, "w", encoding="utf-8") as fh:
|
|
2208
|
-
json.dump(split_request(cand, where, [c for c, _ in candidates]), fh, indent=2, ensure_ascii=False)
|
|
2209
|
-
fh.write("\n")
|
|
2210
|
-
print(" split %s request written: %s (the session writes the proposal next to it)" % (cand.path, rel))
|
|
2211
|
-
return 0
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
def print_diff(plan):
|
|
2215
|
-
"""`--adopt --diff`: one unified diff per target this run would write."""
|
|
2216
|
-
import difflib # pylint: disable=import-outside-toplevel
|
|
2217
|
-
for target in OrderedDict.fromkeys(i["target"] for i in plan.items if i["content"] is not None):
|
|
2218
|
-
path = os.path.join(plan.root, target)
|
|
2219
|
-
before = ""
|
|
2220
|
-
if os.path.isfile(path):
|
|
2221
|
-
with open(path, "rb") as fh:
|
|
2222
|
-
before = fh.read().decode("utf-8", "replace")
|
|
2223
|
-
after = text_of(plan.final.get(target, b""))
|
|
2224
|
-
sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
|
|
2225
|
-
"a/" + target, "b/" + target))
|