feature-flow-cli 0.1.0__py3-none-any.whl
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.
- feature_flow/__init__.py +3 -0
- feature_flow/__main__.py +7 -0
- feature_flow/_bundle/adapters/codex/feature-flow/SKILL.md +62 -0
- feature_flow/_bundle/adapters/codex/feature-flow/agents/openai.yaml +6 -0
- feature_flow/_bundle/agents/ticket-builder.md +24 -0
- feature_flow/_bundle/agents/ticket-reviewer.md +16 -0
- feature_flow/_bundle/guides/build.md +139 -0
- feature_flow/_bundle/guides/plan.md +93 -0
- feature_flow/_bundle/guides/review.md +87 -0
- feature_flow/_bundle/guides/show.md +30 -0
- feature_flow/_bundle/guides/templates/commands.md +15 -0
- feature_flow/_bundle/guides/templates/learnings.md +7 -0
- feature_flow/_bundle/guides/templates/map.md +26 -0
- feature_flow/_bundle/guides/templates/spec.md +62 -0
- feature_flow/_bundle/guides/templates/ticket.md +29 -0
- feature_flow/_bundle/guides/templates/ui-mockup.md +43 -0
- feature_flow/_bundle/scripts/floor-guard.py +14 -0
- feature_flow/_bundle/scripts/flow-status.py +14 -0
- feature_flow/_bundle/scripts/flow-view.html +536 -0
- feature_flow/_bundle/scripts/flow-view.py +14 -0
- feature_flow/_bundle/scripts/flow.py +14 -0
- feature_flow/_bundle/scripts/gate.py +14 -0
- feature_flow/_bundle/skills/architect-review/SKILL.md +143 -0
- feature_flow/_bundle/skills/automation-design/SKILL.md +274 -0
- feature_flow/_bundle/skills/feature-flow/SKILL.md +62 -0
- feature_flow/checks.py +71 -0
- feature_flow/cli.py +47 -0
- feature_flow/command.py +39 -0
- feature_flow/conductor.py +299 -0
- feature_flow/floorguard.py +340 -0
- feature_flow/gate.py +100 -0
- feature_flow/git.py +47 -0
- feature_flow/install.py +365 -0
- feature_flow/prompts.py +52 -0
- feature_flow/state.py +75 -0
- feature_flow/status.py +231 -0
- feature_flow/tickets.py +168 -0
- feature_flow/view.py +312 -0
- feature_flow_cli-0.1.0.dist-info/METADATA +293 -0
- feature_flow_cli-0.1.0.dist-info/RECORD +44 -0
- feature_flow_cli-0.1.0.dist-info/WHEEL +5 -0
- feature_flow_cli-0.1.0.dist-info/entry_points.txt +3 -0
- feature_flow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- feature_flow_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""The state machine that tells an interactive session what to do next.
|
|
2
|
+
|
|
3
|
+
It sets the limits, retries and review rounds. It judges every phase from the repo, never from the
|
|
4
|
+
caller, and prints exactly one line per command.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from feature_flow import checks, git, prompts, state, tickets
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
VERDICT = re.compile(r"^REVIEW: (PASS|FAIL)[ \t\r\f\v]*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Stop(Exception):
|
|
19
|
+
"""A problem the session must report. Printed as `STOP <reason>`, exit 1."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoPhase(Exception):
|
|
23
|
+
"""`prompt` or `verdict` with nothing for it pending: nothing changes, exit 2."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _env_int(name, default):
|
|
27
|
+
value = os.environ.get(name, "")
|
|
28
|
+
try:
|
|
29
|
+
return int(value) if value.strip() else default
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise Stop("%s must be a whole number, not %s" % (name, value))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Conductor:
|
|
35
|
+
def __init__(self, feature, scripts):
|
|
36
|
+
self.feature = feature
|
|
37
|
+
self.scripts = Path(scripts)
|
|
38
|
+
self.root = Path(os.environ.get("FLOW_DIR") or "plans")
|
|
39
|
+
self.plan = self.root / feature
|
|
40
|
+
self.commands = self.plan / "commands.md"
|
|
41
|
+
self.max_retries = _env_int("FLOW_MAX_RETRIES", 2)
|
|
42
|
+
self.max_rounds = _env_int("FLOW_MAX_REVIEW_ROUNDS", 3)
|
|
43
|
+
self.gate_on = os.environ.get("FLOW_GATE", "on") != "off"
|
|
44
|
+
self.per_session = _env_int("FLOW_TICKETS_PER_SESSION", 4)
|
|
45
|
+
self.relay = os.environ.get("FLOW_RELAY", "0") == "1"
|
|
46
|
+
self.invoke = os.environ.get("FLOW_INVOKE") or "/feature-flow"
|
|
47
|
+
top = git.toplevel()
|
|
48
|
+
if top is None:
|
|
49
|
+
raise Stop("not inside a git worktree")
|
|
50
|
+
try:
|
|
51
|
+
folder = state.state_dir(top)
|
|
52
|
+
state.migrate(git.git_dir(), folder, feature)
|
|
53
|
+
except OSError as err:
|
|
54
|
+
raise Stop("cannot write the flow state in %s: %s. this session must be allowed to write there"
|
|
55
|
+
% (top / state.STATE_DIR, err.strerror or err))
|
|
56
|
+
self.state_file = state.state_path(folder, feature)
|
|
57
|
+
self.log_file = state.log_path(folder, feature)
|
|
58
|
+
self.findings_file = state.file_path(folder, feature, "findings")
|
|
59
|
+
self.st = state.load(self.state_file)
|
|
60
|
+
|
|
61
|
+
# ---- small helpers -------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def get(self, key, default=""):
|
|
64
|
+
return self.st.get(key, default)
|
|
65
|
+
|
|
66
|
+
def num(self, key):
|
|
67
|
+
try:
|
|
68
|
+
return int(self.st.get(key, "0") or 0)
|
|
69
|
+
except ValueError:
|
|
70
|
+
raise Stop("the state file %s is damaged: %s is not a number" % (self.state_file, key))
|
|
71
|
+
|
|
72
|
+
def save(self):
|
|
73
|
+
state.save(self.state_file, self.st)
|
|
74
|
+
|
|
75
|
+
def log(self, event):
|
|
76
|
+
state.log(self.log_file, self.get("num"), event)
|
|
77
|
+
|
|
78
|
+
def emit(self, line):
|
|
79
|
+
self.log(line.split(" ", 1)[0])
|
|
80
|
+
return line
|
|
81
|
+
|
|
82
|
+
def ticket(self):
|
|
83
|
+
return self.get("ticket")
|
|
84
|
+
|
|
85
|
+
def ticket_name(self):
|
|
86
|
+
return tickets.name(self.ticket())
|
|
87
|
+
|
|
88
|
+
# ---- the checks ----------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def smoke_command(self):
|
|
91
|
+
cmd = os.environ.get("FLOW_SMOKE", "")
|
|
92
|
+
if not cmd:
|
|
93
|
+
cmd = tickets.commands_value(self.commands, "Smoke")
|
|
94
|
+
return "" if cmd.startswith("<") else cmd
|
|
95
|
+
|
|
96
|
+
def run_smoke(self):
|
|
97
|
+
cmd = self.smoke_command()
|
|
98
|
+
if not cmd:
|
|
99
|
+
return
|
|
100
|
+
result = checks.smoke(cmd)
|
|
101
|
+
if not result.ok:
|
|
102
|
+
raise Stop("smoke test failed before %s: the base is already broken. fix it first. command: %s"
|
|
103
|
+
% (self.ticket_name(), cmd))
|
|
104
|
+
|
|
105
|
+
def run_limit(self):
|
|
106
|
+
result = checks.flow_status(self.scripts, self.feature, "--counts")
|
|
107
|
+
total = 0
|
|
108
|
+
for word in result.out.split():
|
|
109
|
+
if word.startswith("total="):
|
|
110
|
+
total = int(word[len("total="):])
|
|
111
|
+
return total * 2 * self.max_retries * (self.max_rounds + 1) + 1
|
|
112
|
+
|
|
113
|
+
def count_run(self):
|
|
114
|
+
runs = self.num("runs") + 1
|
|
115
|
+
limit = self.num("limit")
|
|
116
|
+
if runs > limit:
|
|
117
|
+
raise Stop("run limit of %d sessions reached, stopping" % limit)
|
|
118
|
+
self.st["runs"] = runs
|
|
119
|
+
|
|
120
|
+
# ---- phases --------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def hand_out_build(self):
|
|
123
|
+
self.count_run()
|
|
124
|
+
self.st["phase"] = "build"
|
|
125
|
+
self.st["attempt"] = self.num("attempt") + 1
|
|
126
|
+
self.st["relay_handoff"] = "1" if self.relay else ""
|
|
127
|
+
self.save()
|
|
128
|
+
return self.emit("BUILD %s %s %s" % (self.ticket(), self.get("num"), self.get("base")))
|
|
129
|
+
|
|
130
|
+
def hand_out_review(self):
|
|
131
|
+
self.count_run()
|
|
132
|
+
self.st["phase"] = "review"
|
|
133
|
+
self.st["review_attempt"] = self.num("review_attempt") + 1
|
|
134
|
+
self.st["review_sha"] = git.head()
|
|
135
|
+
self.st["relay_handoff"] = "1" if self.relay else ""
|
|
136
|
+
self.save()
|
|
137
|
+
return self.emit("REVIEW %s %s %s" % (self.ticket(), self.get("num"), self.get("base")))
|
|
138
|
+
|
|
139
|
+
def send_back(self, source, findings):
|
|
140
|
+
"""Write the findings into the ticket, reopen it, commit, and build again."""
|
|
141
|
+
round_no = self.num("round") + 1
|
|
142
|
+
if round_no > self.max_rounds:
|
|
143
|
+
raise Stop("%s still fails the %s after %d round(s)" % (self.ticket_name(), source, self.max_rounds))
|
|
144
|
+
self.st["round"] = round_no
|
|
145
|
+
tickets.append_findings(self.ticket(), round_no, source, findings)
|
|
146
|
+
tickets.set_open(self.ticket())
|
|
147
|
+
if not git.commit_file(self.ticket(), "chore(%s): %s review findings, round %d"
|
|
148
|
+
% (self.feature, self.get("num"), round_no)):
|
|
149
|
+
raise Stop("cannot commit the %s findings to %s. this session must be allowed to run git commit"
|
|
150
|
+
% (source, self.ticket_name()))
|
|
151
|
+
self.st["attempt"] = 0
|
|
152
|
+
self.st["review_attempt"] = 0
|
|
153
|
+
return self.hand_out_build()
|
|
154
|
+
|
|
155
|
+
def pick_ticket(self):
|
|
156
|
+
"""Hand out the next ready ticket, or finish."""
|
|
157
|
+
check = checks.flow_status(self.scripts, self.feature, "--check")
|
|
158
|
+
if not check.ok:
|
|
159
|
+
raise Stop("ticket check failed, fix the tickets first")
|
|
160
|
+
if not git.is_clean():
|
|
161
|
+
raise Stop("working tree is not clean, commit or stash first")
|
|
162
|
+
nxt = checks.flow_status(self.scripts, self.feature, "--next")
|
|
163
|
+
if nxt.code == 10:
|
|
164
|
+
done = self.num("done")
|
|
165
|
+
self.st["phase"] = ""
|
|
166
|
+
self.st["owner"] = ""
|
|
167
|
+
self.save()
|
|
168
|
+
return self.emit("DONE %s is complete: %d ticket(s) resolved in this run" % (self.feature, done))
|
|
169
|
+
if nxt.code == 11:
|
|
170
|
+
raise Stop("stuck: unfinished tickets remain but none is ready. run: python3 scripts/flow-status.py %s"
|
|
171
|
+
% self.feature)
|
|
172
|
+
if nxt.code != 0:
|
|
173
|
+
raise Stop("flow-status.py failed with exit code %d" % nxt.code)
|
|
174
|
+
if self.per_session > 0 and self.num("session_done") >= self.per_session:
|
|
175
|
+
return self.handoff()
|
|
176
|
+
path = nxt.out.strip().splitlines()[-1]
|
|
177
|
+
self.st.update({"ticket": path, "num": tickets.number(path), "base": git.head(), "phase": "",
|
|
178
|
+
"attempt": 0, "review_attempt": 0, "round": 0, "review_sha": ""})
|
|
179
|
+
self.run_smoke()
|
|
180
|
+
return self.hand_out_build()
|
|
181
|
+
|
|
182
|
+
def judge_build(self):
|
|
183
|
+
status = tickets.status(self.ticket())
|
|
184
|
+
if status not in tickets.DONE:
|
|
185
|
+
if status in tickets.RESET:
|
|
186
|
+
tickets.set_open(self.ticket())
|
|
187
|
+
if self.num("attempt") >= self.max_retries:
|
|
188
|
+
raise Stop("%s is still unresolved after %d attempt(s)" % (self.ticket_name(), self.max_retries))
|
|
189
|
+
return self.hand_out_build()
|
|
190
|
+
if not git.is_clean():
|
|
191
|
+
raise Stop("working tree is dirty after %s, it should have been committed" % self.ticket_name())
|
|
192
|
+
if self.gate_on:
|
|
193
|
+
result = checks.gate(self.scripts, self.feature)
|
|
194
|
+
self.log("GATE-PASS" if result.ok else "GATE-FAIL")
|
|
195
|
+
if not result.ok:
|
|
196
|
+
return self.send_back("gate", result.out)
|
|
197
|
+
result = checks.floor_guard(self.scripts, self.feature, self.get("num"), self.get("base"))
|
|
198
|
+
self.log("GUARD-PASS" if result.ok else "GUARD-FAIL")
|
|
199
|
+
if not result.ok:
|
|
200
|
+
return self.send_back("floor guard", result.out)
|
|
201
|
+
return self.hand_out_review()
|
|
202
|
+
|
|
203
|
+
# ---- commands ------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def handoff(self):
|
|
206
|
+
"""Release the owner; the user continues in a new session with the printed line."""
|
|
207
|
+
self.st["owner"] = ""
|
|
208
|
+
self.st["relay_handoff"] = ""
|
|
209
|
+
self.save()
|
|
210
|
+
return self.emit("HANDOFF %s %s" % (self.invoke, self.feature))
|
|
211
|
+
|
|
212
|
+
def check_owner(self):
|
|
213
|
+
"""While a session owns the feature, every call must carry its token. Checked before any change."""
|
|
214
|
+
owner = self.get("owner")
|
|
215
|
+
if owner and os.environ.get("FLOW_SESSION", "") != owner:
|
|
216
|
+
raise Stop("%s is owned by another session (%s). pass its token as FLOW_SESSION, or start this session "
|
|
217
|
+
"with FLOW_TAKEOVER=1 once you are sure the other one is closed" % (self.feature, owner))
|
|
218
|
+
|
|
219
|
+
def start(self):
|
|
220
|
+
if not self.plan.is_dir():
|
|
221
|
+
return self.emit("PLAN")
|
|
222
|
+
owner = self.get("owner")
|
|
223
|
+
takeover = bool(owner)
|
|
224
|
+
if owner and os.environ.get("FLOW_TAKEOVER", "0") != "1":
|
|
225
|
+
raise Stop("%s is owned by session %s. if that session is closed or dead, run start with FLOW_TAKEOVER=1"
|
|
226
|
+
% (self.feature, owner))
|
|
227
|
+
token = secrets.token_hex(8)
|
|
228
|
+
self.st["owner"] = token
|
|
229
|
+
self.st["session_done"] = 0
|
|
230
|
+
self.save()
|
|
231
|
+
self.log("TAKEOVER" if takeover else "START")
|
|
232
|
+
return "OK %s" % token
|
|
233
|
+
|
|
234
|
+
def next(self):
|
|
235
|
+
self.check_owner()
|
|
236
|
+
if not self.plan.is_dir():
|
|
237
|
+
raise Stop("no plan folder %s" % self.plan)
|
|
238
|
+
if self.get("relay_handoff") == "1":
|
|
239
|
+
return self.handoff()
|
|
240
|
+
if "limit" not in self.st:
|
|
241
|
+
self.st["limit"] = self.run_limit()
|
|
242
|
+
self.st.setdefault("runs", 0)
|
|
243
|
+
self.st.setdefault("done", 0)
|
|
244
|
+
phase = self.get("phase")
|
|
245
|
+
if phase == "build":
|
|
246
|
+
return self.judge_build()
|
|
247
|
+
if phase == "review":
|
|
248
|
+
return self.judge_review()
|
|
249
|
+
return self.pick_ticket()
|
|
250
|
+
|
|
251
|
+
def judge_review(self):
|
|
252
|
+
if not git.tracked_clean() or git.head() != self.get("review_sha"):
|
|
253
|
+
raise Stop("the reviewer changed tracked files, which a reviewer must never do")
|
|
254
|
+
verdict = self.get("verdict")
|
|
255
|
+
self.st["verdict"] = ""
|
|
256
|
+
if verdict == "pass":
|
|
257
|
+
self.st["phase"] = ""
|
|
258
|
+
self.st["done"] = self.num("done") + 1
|
|
259
|
+
self.st["session_done"] = self.num("session_done") + 1
|
|
260
|
+
self.save()
|
|
261
|
+
return self.pick_ticket()
|
|
262
|
+
if verdict == "fail":
|
|
263
|
+
findings = self.findings_file.read_text(encoding="utf-8") if self.findings_file.is_file() else ""
|
|
264
|
+
return self.send_back("independent review", findings)
|
|
265
|
+
if self.num("review_attempt") >= self.max_retries:
|
|
266
|
+
raise Stop("no review verdict for %s after %d attempt(s)" % (self.ticket_name(), self.max_retries))
|
|
267
|
+
return self.hand_out_review()
|
|
268
|
+
|
|
269
|
+
def prompt(self):
|
|
270
|
+
"""The full prompt for the BUILD or REVIEW that `next` handed out. Exit 2 (NoPhase) when none is."""
|
|
271
|
+
self.check_owner()
|
|
272
|
+
phase = self.get("phase")
|
|
273
|
+
if phase not in ("build", "review"):
|
|
274
|
+
raise NoPhase("no BUILD or REVIEW is pending for %s" % self.feature)
|
|
275
|
+
try:
|
|
276
|
+
return prompts.build(phase, self.scripts, self.feature, self.ticket(), self.get("num"), self.get("base"))
|
|
277
|
+
except FileNotFoundError as err:
|
|
278
|
+
raise Stop(str(err))
|
|
279
|
+
|
|
280
|
+
def verdict(self, reply):
|
|
281
|
+
"""Record the reviewer's reply. Exit 2 (NoPhase) unless a review is pending."""
|
|
282
|
+
self.check_owner()
|
|
283
|
+
if self.get("phase") != "review":
|
|
284
|
+
raise NoPhase("no review is pending for %s" % self.feature)
|
|
285
|
+
try:
|
|
286
|
+
text = Path(reply).read_text(encoding="utf-8", errors="replace")
|
|
287
|
+
except OSError as err:
|
|
288
|
+
raise Stop("cannot read the review reply %s: %s" % (reply, err.strerror))
|
|
289
|
+
found = ""
|
|
290
|
+
for line in text.splitlines():
|
|
291
|
+
match = VERDICT.match(line)
|
|
292
|
+
if match:
|
|
293
|
+
found = match.group(1).lower()
|
|
294
|
+
if found == "fail":
|
|
295
|
+
self.findings_file.write_text("\n".join(text.splitlines()[:120]) + "\n", encoding="utf-8")
|
|
296
|
+
self.st["verdict"] = found or "none"
|
|
297
|
+
self.save()
|
|
298
|
+
self.log("VERDICT-%s" % (found or "none").upper())
|
|
299
|
+
return "OK" if found else "RETRY no review verdict"
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""The floor guard, ported from the bash script with the same output and exit codes.
|
|
2
|
+
|
|
3
|
+
usage: python3 scripts/floor-guard.py <feature> <NN> [base-commit]
|
|
4
|
+
looks at the diff from base-commit (default HEAD~1) to HEAD for ways of weakening the bar
|
|
5
|
+
instead of meeting it: skipped tests, silenced checks, empty catches, deleted tests,
|
|
6
|
+
lowered thresholds, edited lint or test config.
|
|
7
|
+
it also protects the plan from the worker: only the ticket's own Status line and Answer,
|
|
8
|
+
appended lines in map.md and learnings.md may change, and nothing else under the plans folder.
|
|
9
|
+
a ticket can allow categories with a line "Floor: allow skip, suppress" (skip, suppress,
|
|
10
|
+
empty-catch, test-delete, threshold, config, ticket-edit, commands-edit). the line is
|
|
11
|
+
read from the ticket as it was at base-commit, so a worker cannot excuse itself.
|
|
12
|
+
run it from the repo root. exit 0 clean, 1 findings, 2 usage error.
|
|
13
|
+
|
|
14
|
+
The bash version ran its pattern matching in awk (mawk on Ubuntu, a byte-oriented awk), so the
|
|
15
|
+
diff is handled here as bytes too: the patterns, the 100 byte cut and the lower-casing behave
|
|
16
|
+
like mawk's. Text is decoded with surrogateescape so any byte comes back out unchanged.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
USAGE = "usage: python3 scripts/floor-guard.py <feature> <NN> [base-commit]"
|
|
25
|
+
|
|
26
|
+
SKIP = re.compile(rb"@pytest\.mark\.(skip|xfail)|pytest\.skip\(|(^|[^A-Za-z_])(it|test|describe)\.(skip|todo)\("
|
|
27
|
+
rb"|(^|[^A-Za-z_])(xit|xdescribe|xtest)\(|@Disabled|t\.Skip\(|#\[ignore\]|unittest\.skip")
|
|
28
|
+
SUPPRESS = re.compile(rb"# noqa|# type: ignore|# pylint: disable|# pragma: no cover|# fmt: off|# ruff: noqa"
|
|
29
|
+
rb"|eslint-disable|@ts-ignore|@ts-expect-error|// nolint|#\[allow\(|--no-verify")
|
|
30
|
+
EMPTY_CATCH = re.compile(rb"except[^:]*:[ \t]*pass[ \t]*$|catch[ \t]*(\([^)]*\))?[ \t]*\{[ \t]*\}")
|
|
31
|
+
THRESHOLD = re.compile(rb"fail_under|fail-under|coverageThreshold|cov-fail-under")
|
|
32
|
+
DELETED_TEST = re.compile(r"(^|/)(test_[^/]*\.py|[^/]*_test\.[a-z]+|[^/]*\.(test|spec)\.[a-z]+|tests?/|__tests__/)")
|
|
33
|
+
CONFIG = re.compile(r"(^|/)(\.eslintrc[^/]*|eslint\.config\.[a-z]+|ruff\.toml|\.ruff\.toml|mypy\.ini|pytest\.ini"
|
|
34
|
+
r"|tox\.ini|tsconfig[^/]*\.json|jest\.config\.[a-z]+|vitest\.config\.[a-z]+"
|
|
35
|
+
r"|\.pre-commit-config\.yaml|\.github/workflows/[^/]*|\.azure/.*)$")
|
|
36
|
+
ASSERT_REMOVED = re.compile(rb"^-.*(assert|expect\()")
|
|
37
|
+
ASSERT_ADDED = re.compile(rb"^\+.*(assert|expect\()")
|
|
38
|
+
FIELDS = re.compile(rb"[ \t\n]+")
|
|
39
|
+
SPACE_CHARS = " \t\n\v\f\r"
|
|
40
|
+
HEX = b"0123456789abcdefABCDEF"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(data):
|
|
44
|
+
return data.decode("utf-8", "surrogateescape")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bytes(text):
|
|
48
|
+
return text.encode("utf-8", "surrogateescape")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _records(data):
|
|
52
|
+
"""awk records: split on newline, a last line without one still counts."""
|
|
53
|
+
if not data:
|
|
54
|
+
return []
|
|
55
|
+
lines = data.split(b"\n")
|
|
56
|
+
if lines[-1] == b"":
|
|
57
|
+
lines.pop()
|
|
58
|
+
return lines
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip_newlines(data):
|
|
62
|
+
"""What $(...) does to a command's output."""
|
|
63
|
+
return data.rstrip(b"\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ascii_lower(data):
|
|
67
|
+
return bytes(c + 32 if 65 <= c <= 90 else c for c in data)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _awk_assign(value):
|
|
71
|
+
"""Escape processing mawk applies to `awk -v name=value`."""
|
|
72
|
+
simple = {ord('"'): b'"', ord("\\"): b"\\", ord("a"): b"\a", ord("b"): b"\b", ord("f"): b"\f",
|
|
73
|
+
ord("n"): b"\n", ord("r"): b"\r", ord("t"): b"\t", ord("v"): b"\v"}
|
|
74
|
+
out = bytearray()
|
|
75
|
+
i = 0
|
|
76
|
+
while i < len(value):
|
|
77
|
+
c = value[i]
|
|
78
|
+
if c != 0x5C or i + 1 >= len(value):
|
|
79
|
+
out.append(c)
|
|
80
|
+
i += 1
|
|
81
|
+
continue
|
|
82
|
+
nxt = value[i + 1]
|
|
83
|
+
if nxt in simple:
|
|
84
|
+
out += simple[nxt]
|
|
85
|
+
i += 2
|
|
86
|
+
elif 0x30 <= nxt <= 0x37:
|
|
87
|
+
j = i + 1
|
|
88
|
+
while j < len(value) and j < i + 4 and 0x30 <= value[j] <= 0x37:
|
|
89
|
+
j += 1
|
|
90
|
+
out.append(int(value[i + 1:j], 8) & 0xFF)
|
|
91
|
+
i = j
|
|
92
|
+
elif nxt == ord("x") and value[i + 2:i + 3] and value[i + 2:i + 3] in HEX:
|
|
93
|
+
j = i + 2
|
|
94
|
+
while j < len(value) and j < i + 4 and value[j:j + 1] in HEX:
|
|
95
|
+
j += 1
|
|
96
|
+
out.append(int(value[i + 2:j], 16))
|
|
97
|
+
i = j
|
|
98
|
+
else:
|
|
99
|
+
out.append(c)
|
|
100
|
+
i += 1
|
|
101
|
+
return bytes(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def allow_line(source):
|
|
105
|
+
"""The categories named on the first `Floor:` line in the first 20 lines, lower case."""
|
|
106
|
+
for n, line in enumerate(_records(source), 1):
|
|
107
|
+
if n > 20:
|
|
108
|
+
break
|
|
109
|
+
if line.startswith(b"Floor:"):
|
|
110
|
+
v = re.sub(rb"^floor:[ \t]*allow[ \t]*", b"", _ascii_lower(line), count=1)
|
|
111
|
+
return v.replace(b",", b" ")
|
|
112
|
+
return b""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def diff_findings(diff, allow):
|
|
116
|
+
"""The awk pass over added lines: skip, suppress, empty-catch and threshold."""
|
|
117
|
+
allow = b" " + _awk_assign(allow) + b" "
|
|
118
|
+
out = []
|
|
119
|
+
file = b""
|
|
120
|
+
|
|
121
|
+
def report(cat, text):
|
|
122
|
+
if (b" " + cat + b" ") in allow:
|
|
123
|
+
return
|
|
124
|
+
text = re.sub(rb"^[ \t]+", b"", text, count=1)
|
|
125
|
+
out.append(cat + b": " + file + b": " + text[:100] + b"\n")
|
|
126
|
+
|
|
127
|
+
for record in _records(diff):
|
|
128
|
+
if record.startswith(b"+++ "):
|
|
129
|
+
fields = FIELDS.split(record.strip(b" \t\n"))
|
|
130
|
+
file = fields[1] if len(fields) > 1 else b""
|
|
131
|
+
if file.startswith(b"b/"):
|
|
132
|
+
file = file[2:]
|
|
133
|
+
continue
|
|
134
|
+
if record.startswith(b"+"):
|
|
135
|
+
line = record[1:]
|
|
136
|
+
if SKIP.search(line):
|
|
137
|
+
report(b"skip", line)
|
|
138
|
+
if SUPPRESS.search(line):
|
|
139
|
+
report(b"suppress", line)
|
|
140
|
+
if EMPTY_CATCH.search(line):
|
|
141
|
+
report(b"empty-catch", line)
|
|
142
|
+
if THRESHOLD.search(line):
|
|
143
|
+
report(b"threshold", line)
|
|
144
|
+
return b"".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def frozen(data):
|
|
148
|
+
"""The ticket without its first Status line and from its Answer or Review findings on."""
|
|
149
|
+
out = []
|
|
150
|
+
seen = False
|
|
151
|
+
for n, line in enumerate(_records(data), 1):
|
|
152
|
+
if n <= 20 and not seen and line.startswith(b"Status:"):
|
|
153
|
+
seen = True
|
|
154
|
+
continue
|
|
155
|
+
if re.match(rb"## (Answer|Review findings)", line):
|
|
156
|
+
break
|
|
157
|
+
out.append(line + b"\n")
|
|
158
|
+
return b"".join(out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def count_removed_real_lines(diff):
|
|
162
|
+
n = 0
|
|
163
|
+
for line in _records(diff):
|
|
164
|
+
if line.startswith(b"---"):
|
|
165
|
+
continue
|
|
166
|
+
if line.startswith(b"-"):
|
|
167
|
+
text = line[1:].strip(b" \t")
|
|
168
|
+
if text != b"" and not re.match(rb"(- )?<", text):
|
|
169
|
+
n += 1
|
|
170
|
+
return n
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def count_matching(diff, pattern):
|
|
174
|
+
return sum(1 for line in _records(diff) if pattern.search(line))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _find_ticket(dir_, num):
|
|
178
|
+
"""The first match of the glob "$DIR"/"$NUM"-*.md, or None."""
|
|
179
|
+
prefix = dir_ + "/" + num + "-"
|
|
180
|
+
folder, start = os.path.split(prefix)
|
|
181
|
+
try:
|
|
182
|
+
names = os.listdir(folder or ".")
|
|
183
|
+
except OSError:
|
|
184
|
+
return None
|
|
185
|
+
hits = sorted(name for name in names
|
|
186
|
+
if name.startswith(start) and name.endswith(".md") and len(name) >= len(start) + 3
|
|
187
|
+
and (start.startswith(".") or not name.startswith(".")))
|
|
188
|
+
return prefix + hits[0][len(start):] if hits else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _Guard:
|
|
192
|
+
def __init__(self, err):
|
|
193
|
+
self.err = err
|
|
194
|
+
|
|
195
|
+
def git(self, *args, quiet=False):
|
|
196
|
+
result = subprocess.run(["git"] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
197
|
+
if result.stderr and not quiet:
|
|
198
|
+
self.err(_text(result.stderr))
|
|
199
|
+
return result.returncode, result.stdout
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def run(argv, out, err, environ=None):
|
|
203
|
+
"""Run the guard. out and err take text; returns the exit code."""
|
|
204
|
+
environ = os.environ if environ is None else environ
|
|
205
|
+
feature = argv[0] if len(argv) > 0 else ""
|
|
206
|
+
num = argv[1] if len(argv) > 1 else ""
|
|
207
|
+
base = argv[2] if len(argv) > 2 and argv[2] else "HEAD~1"
|
|
208
|
+
if not feature or not num:
|
|
209
|
+
err(USAGE + "\n")
|
|
210
|
+
return 2
|
|
211
|
+
|
|
212
|
+
root = environ.get("FLOW_DIR") or "plans"
|
|
213
|
+
dir_ = "%s/%s/%s" % (root, feature, environ.get("FLOW_TICKETS") or "tasks")
|
|
214
|
+
ticket = _find_ticket(dir_, num)
|
|
215
|
+
if ticket is None or not os.path.exists(ticket):
|
|
216
|
+
err("no ticket %s in %s\n" % (num, dir_))
|
|
217
|
+
return 2
|
|
218
|
+
|
|
219
|
+
g = _Guard(err)
|
|
220
|
+
if g.git("cat-file", "-e", "%s:./%s" % (base, ticket), quiet=True)[0] == 0:
|
|
221
|
+
source = _strip_newlines(g.git("show", "%s:./%s" % (base, ticket), quiet=True)[1])
|
|
222
|
+
else:
|
|
223
|
+
try:
|
|
224
|
+
with open(ticket, "rb") as f:
|
|
225
|
+
source = _strip_newlines(f.read())
|
|
226
|
+
except OSError as e:
|
|
227
|
+
err("cat: %s: %s\n" % (ticket, e.strerror))
|
|
228
|
+
return 1
|
|
229
|
+
allow = allow_line(source)
|
|
230
|
+
allow_words = " %s " % _text(allow)
|
|
231
|
+
|
|
232
|
+
def allows(cat):
|
|
233
|
+
return (" %s " % cat) in allow_words
|
|
234
|
+
|
|
235
|
+
outside = ["--", ".", ":(exclude)%s" % root]
|
|
236
|
+
rc, diff = g.git("diff", "--no-color", "--unified=0", base, "HEAD", *outside)
|
|
237
|
+
if rc != 0:
|
|
238
|
+
return rc
|
|
239
|
+
findings = _text(_strip_newlines(diff_findings(diff, allow)))
|
|
240
|
+
|
|
241
|
+
deleted = _git_lines(g, DELETED_TEST, "diff", "--name-only", "--diff-filter=D", base, "HEAD", *outside)
|
|
242
|
+
if deleted and not allows("test-delete"):
|
|
243
|
+
findings += "\n" + "\n".join("test-delete: " + p for p in deleted)
|
|
244
|
+
|
|
245
|
+
touched = _git_lines(g, CONFIG, "diff", "--name-only", base, "HEAD", *outside)
|
|
246
|
+
if touched and not allows("config"):
|
|
247
|
+
findings += "\n" + "\n".join("config: " + p for p in touched)
|
|
248
|
+
|
|
249
|
+
def rel(path):
|
|
250
|
+
p = _records(g.git("ls-files", "--full-name", "--", path, quiet=True)[1])
|
|
251
|
+
if p and p[0]:
|
|
252
|
+
return _text(p[0])
|
|
253
|
+
return path[2:] if path.startswith("./") else path
|
|
254
|
+
|
|
255
|
+
def show(rev, path):
|
|
256
|
+
return g.git("show", "%s:./%s" % (rev, path), quiet=True)[1]
|
|
257
|
+
|
|
258
|
+
t_rel = rel(ticket)
|
|
259
|
+
m_rel = rel("%s/%s/map.md" % (root, feature))
|
|
260
|
+
c_rel = rel("%s/%s/commands.md" % (root, feature))
|
|
261
|
+
l_rel = rel("%s/%s/learnings.md" % (root, feature))
|
|
262
|
+
|
|
263
|
+
added = []
|
|
264
|
+
|
|
265
|
+
def add_finding(cat, path, text):
|
|
266
|
+
added.append("%s: %s: %s" % (cat, path, text))
|
|
267
|
+
|
|
268
|
+
for st, path in _name_status(g.git("diff", "--name-status", "--no-renames", base, "HEAD", "--", root)[1]):
|
|
269
|
+
if path == t_rel:
|
|
270
|
+
if allows("ticket-edit"):
|
|
271
|
+
continue
|
|
272
|
+
if st != "M":
|
|
273
|
+
add_finding("ticket-edit", path, "the ticket was added or deleted")
|
|
274
|
+
elif frozen(show(base, path)) != frozen(show("HEAD", path)):
|
|
275
|
+
add_finding("ticket-edit", path, "text outside the Status line and the Answer was changed")
|
|
276
|
+
elif path in (m_rel, l_rel):
|
|
277
|
+
if allows("ticket-edit"):
|
|
278
|
+
continue
|
|
279
|
+
if st == "D":
|
|
280
|
+
add_finding("ticket-edit", path, "the file was deleted")
|
|
281
|
+
elif st == "M" and count_removed_real_lines(
|
|
282
|
+
g.git("diff", "--no-color", "--unified=0", base, "HEAD", "--", path)[1]) > 0:
|
|
283
|
+
add_finding("ticket-edit", path, "existing lines were removed or rewritten (append only)")
|
|
284
|
+
elif path == c_rel:
|
|
285
|
+
if not allows("commands-edit"):
|
|
286
|
+
add_finding("commands-edit", path, "the frozen commands file was changed")
|
|
287
|
+
else:
|
|
288
|
+
if not allows("ticket-edit"):
|
|
289
|
+
add_finding("ticket-edit", path, "only your own ticket, map.md and learnings.md may change")
|
|
290
|
+
for line in added:
|
|
291
|
+
findings += "\n" + line
|
|
292
|
+
|
|
293
|
+
findings = "\n".join(line for line in findings.split("\n") if line.strip(SPACE_CHARS))
|
|
294
|
+
|
|
295
|
+
diff = g.git("diff", "--no-color", "--unified=0", base, "HEAD", *outside)[1]
|
|
296
|
+
removed_asserts = count_matching(diff, ASSERT_REMOVED)
|
|
297
|
+
diff = g.git("diff", "--no-color", "--unified=0", base, "HEAD", *outside)[1]
|
|
298
|
+
added_asserts = count_matching(diff, ASSERT_ADDED)
|
|
299
|
+
if removed_asserts > added_asserts:
|
|
300
|
+
out("warning: %d assertion line(s) removed, %d added. check that no test got weaker.\n"
|
|
301
|
+
% (removed_asserts, added_asserts))
|
|
302
|
+
|
|
303
|
+
if findings:
|
|
304
|
+
out("floor guard: the diff weakens the bar instead of meeting it\n")
|
|
305
|
+
out(findings + "\n")
|
|
306
|
+
out("if a finding is intended, the ticket needs a line like: Floor: allow <category>\n")
|
|
307
|
+
return 1
|
|
308
|
+
out("floor guard: clean\n")
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _git_lines(g, pattern, *args):
|
|
313
|
+
"""git ... | grep -E pattern, as a list of lines."""
|
|
314
|
+
return [line for line in (_text(r) for r in _records(g.git(*args)[1])) if pattern.search(line)]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _name_status(data):
|
|
318
|
+
"""`while IFS=$'\\t' read -r st path`: the first field and the rest, outer tabs trimmed."""
|
|
319
|
+
rows = []
|
|
320
|
+
for record in _records(data):
|
|
321
|
+
line = _text(record).strip("\t")
|
|
322
|
+
st, _, path = line.partition("\t")
|
|
323
|
+
path = path.lstrip("\t")
|
|
324
|
+
if path:
|
|
325
|
+
rows.append((st, path))
|
|
326
|
+
return rows
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main(argv=None):
|
|
330
|
+
"""The command line: writes the exact bytes to stdout and stderr."""
|
|
331
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
332
|
+
|
|
333
|
+
def writer(stream):
|
|
334
|
+
def write(text):
|
|
335
|
+
stream.buffer.write(_bytes(text))
|
|
336
|
+
stream.buffer.flush()
|
|
337
|
+
return write
|
|
338
|
+
|
|
339
|
+
sys.stdout.flush()
|
|
340
|
+
return run(argv, writer(sys.stdout), writer(sys.stderr))
|