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.
Files changed (44) hide show
  1. feature_flow/__init__.py +3 -0
  2. feature_flow/__main__.py +7 -0
  3. feature_flow/_bundle/adapters/codex/feature-flow/SKILL.md +62 -0
  4. feature_flow/_bundle/adapters/codex/feature-flow/agents/openai.yaml +6 -0
  5. feature_flow/_bundle/agents/ticket-builder.md +24 -0
  6. feature_flow/_bundle/agents/ticket-reviewer.md +16 -0
  7. feature_flow/_bundle/guides/build.md +139 -0
  8. feature_flow/_bundle/guides/plan.md +93 -0
  9. feature_flow/_bundle/guides/review.md +87 -0
  10. feature_flow/_bundle/guides/show.md +30 -0
  11. feature_flow/_bundle/guides/templates/commands.md +15 -0
  12. feature_flow/_bundle/guides/templates/learnings.md +7 -0
  13. feature_flow/_bundle/guides/templates/map.md +26 -0
  14. feature_flow/_bundle/guides/templates/spec.md +62 -0
  15. feature_flow/_bundle/guides/templates/ticket.md +29 -0
  16. feature_flow/_bundle/guides/templates/ui-mockup.md +43 -0
  17. feature_flow/_bundle/scripts/floor-guard.py +14 -0
  18. feature_flow/_bundle/scripts/flow-status.py +14 -0
  19. feature_flow/_bundle/scripts/flow-view.html +536 -0
  20. feature_flow/_bundle/scripts/flow-view.py +14 -0
  21. feature_flow/_bundle/scripts/flow.py +14 -0
  22. feature_flow/_bundle/scripts/gate.py +14 -0
  23. feature_flow/_bundle/skills/architect-review/SKILL.md +143 -0
  24. feature_flow/_bundle/skills/automation-design/SKILL.md +274 -0
  25. feature_flow/_bundle/skills/feature-flow/SKILL.md +62 -0
  26. feature_flow/checks.py +71 -0
  27. feature_flow/cli.py +47 -0
  28. feature_flow/command.py +39 -0
  29. feature_flow/conductor.py +299 -0
  30. feature_flow/floorguard.py +340 -0
  31. feature_flow/gate.py +100 -0
  32. feature_flow/git.py +47 -0
  33. feature_flow/install.py +365 -0
  34. feature_flow/prompts.py +52 -0
  35. feature_flow/state.py +75 -0
  36. feature_flow/status.py +231 -0
  37. feature_flow/tickets.py +168 -0
  38. feature_flow/view.py +312 -0
  39. feature_flow_cli-0.1.0.dist-info/METADATA +293 -0
  40. feature_flow_cli-0.1.0.dist-info/RECORD +44 -0
  41. feature_flow_cli-0.1.0.dist-info/WHEEL +5 -0
  42. feature_flow_cli-0.1.0.dist-info/entry_points.txt +3 -0
  43. feature_flow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. 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))