@caddis/cli 0.4.53 → 0.4.55
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/bundles/antigravity-plugin/guard_agy.py +184 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +5 -1
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +29 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +127 -10
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +5 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +56 -3
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +39 -6
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -4
- package/bundles/antigravity-plugin/skills/_registry.md +2 -0
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +4 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
- package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +101 -1
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +3 -2
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +20 -6
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +2 -2
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +9 -5
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +3 -2
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
import json
|
|
33
33
|
import os
|
|
34
34
|
import re
|
|
35
|
+
import shlex
|
|
35
36
|
import sys
|
|
36
37
|
|
|
37
38
|
# Functional identity, mirrored from scripts/claudster_config.py.
|
|
@@ -87,6 +88,187 @@ _CATASTROPHIC_TARGET = re.compile(
|
|
|
87
88
|
_FORK_BOMB = re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
# `git add` of everything. A tokenizer, not a regex: three regex rounds each opened a new gap, and one backtracked
|
|
92
|
+
# exponentially. Linear in the command length. Copied from hooks/guard.py; test_guard_agy.py checks they agree.
|
|
93
|
+
_GA_SEPARATORS = {";", "&", "&&", "|", "||", "(", ")", "{", "}", "$", "!", "\n"}
|
|
94
|
+
_GA_PUNCT = set(";&|(){}$!\n") # a token made only of these is a separator (`$(`, `&&`, `);`, `|!`)
|
|
95
|
+
_GA_KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until"} # the next word starts a command
|
|
96
|
+
_GA_WRAPPERS = {"sudo", "command", "env", "exec", "nohup", "time", "timeout", "nice", "xargs", "call", "wsl",
|
|
97
|
+
"busybox"}
|
|
98
|
+
_GA_SHELLS = {"bash", "sh", "zsh", "dash", "pwsh", "powershell", "cmd"}
|
|
99
|
+
_GA_JOINING_SHELLS = {"cmd", "pwsh", "powershell"} # they run ALL the words after the flag; bash -c runs one
|
|
100
|
+
_GA_SHELL_RUN_FLAGS = {"-c", "/c", "/k", "-command", "-commandwithargs"}
|
|
101
|
+
_GA_GIT_VALUE_OPTS = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
|
|
102
|
+
_GA_EVERYTHING = {"-A", "--all", ".", "./", ".\\", ":/", "*"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ga_sep(token: str) -> bool:
|
|
106
|
+
"""A command separator: one of _GA_SEPARATORS, or a token made only of their characters (`$(`, `|!`)."""
|
|
107
|
+
return token in _GA_SEPARATORS or (bool(token) and set(token) <= _GA_PUNCT)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ga_name(token: str) -> str:
|
|
111
|
+
"""`/usr/bin/git`, `"C:\\Program Files\\Git\\cmd\\git.exe"`, `GIT` -> `git`."""
|
|
112
|
+
base = re.split(r"[\\/]", token)[-1].lower()
|
|
113
|
+
return base[:-4] if base.endswith(".exe") else base
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ga_tokens(text: str) -> list[str]:
|
|
117
|
+
"""Shell tokens, quotes removed (also mid-token: --work-tree="my dir"), backslashes kept (Windows paths).
|
|
118
|
+
; & | ( ) { } $ ! and newlines become separator tokens."""
|
|
119
|
+
def lex(quotes: str) -> list[str]:
|
|
120
|
+
lexer = shlex.shlex(text.replace("`", "\n"), posix=True, punctuation_chars=";&|(){}$!\n")
|
|
121
|
+
lexer.whitespace, lexer.whitespace_split, lexer.escape, lexer.quotes = " \t\r", True, "", quotes
|
|
122
|
+
return list(lexer)
|
|
123
|
+
try:
|
|
124
|
+
return lex("'\"")
|
|
125
|
+
except ValueError: # unbalanced quotes: read it again without quote handling
|
|
126
|
+
return lex("")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def git_add_everything(command: str, depth: int = 0) -> bool:
|
|
130
|
+
"""True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`.
|
|
131
|
+
|
|
132
|
+
`git` must start a command (after a separator, a shell keyword such as `do`, VAR=value, or a wrapper such as
|
|
133
|
+
sudo, env, xargs, nice -n 10), with any path, case or .exe. `bash -c "..."`, `pwsh -Command ...` and
|
|
134
|
+
`cmd /c ...` are read inside. Only git's global options may stand between `git` and `add`, so
|
|
135
|
+
`git commit -m "add ."` and `printf "git add ."` pass. Callers pass heredoc-free text and must treat an
|
|
136
|
+
exception as "no match": this check must never stop the guard's other rules.
|
|
137
|
+
"""
|
|
138
|
+
if depth > 3:
|
|
139
|
+
return False
|
|
140
|
+
text = re.sub(r"\\\r?\n|`\r?\n", " ", command) # line continuations (bash \, PowerShell `)
|
|
141
|
+
tokens = _ga_tokens(text)
|
|
142
|
+
i, start = 0, True
|
|
143
|
+
while i < len(tokens):
|
|
144
|
+
tok = tokens[i]
|
|
145
|
+
if _ga_sep(tok):
|
|
146
|
+
i, start = i + 1, True
|
|
147
|
+
continue
|
|
148
|
+
if not start:
|
|
149
|
+
i += 1
|
|
150
|
+
continue
|
|
151
|
+
if tok in _GA_KEYWORDS or re.match(r"^\w+=", tok): # `do git ...`, VAR=value before the command
|
|
152
|
+
i += 1
|
|
153
|
+
continue
|
|
154
|
+
name = _ga_name(tok)
|
|
155
|
+
if name in _GA_WRAPPERS: # skip the wrapper's own flags and values up to the real command
|
|
156
|
+
i += 1
|
|
157
|
+
while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in ({"git"} | _GA_SHELLS):
|
|
158
|
+
i += 1
|
|
159
|
+
continue
|
|
160
|
+
start = False
|
|
161
|
+
if name in _GA_SHELLS:
|
|
162
|
+
j = i + 1
|
|
163
|
+
while j < len(tokens) and not _ga_sep(tokens[j]):
|
|
164
|
+
if tokens[j].lower() in _GA_SHELL_RUN_FLAGS:
|
|
165
|
+
rest = tokens[j + 1:]
|
|
166
|
+
end = next((k for k, t in enumerate(rest) if _ga_sep(t)), len(rest))
|
|
167
|
+
if end == 0: # `bash -c` with nothing after it
|
|
168
|
+
break
|
|
169
|
+
inner = " ".join(rest[:end]) if name in _GA_JOINING_SHELLS else rest[0]
|
|
170
|
+
if git_add_everything(inner, depth + 1):
|
|
171
|
+
return True
|
|
172
|
+
break
|
|
173
|
+
j += 1
|
|
174
|
+
i = j
|
|
175
|
+
continue
|
|
176
|
+
if name != "git":
|
|
177
|
+
i += 1
|
|
178
|
+
continue
|
|
179
|
+
j = i + 1
|
|
180
|
+
while j < len(tokens) and tokens[j].startswith("-") and not _ga_sep(tokens[j]):
|
|
181
|
+
j += 2 if tokens[j] in _GA_GIT_VALUE_OPTS else 1
|
|
182
|
+
if j >= len(tokens) or tokens[j] != "add":
|
|
183
|
+
i = j
|
|
184
|
+
continue
|
|
185
|
+
after_dashdash = False
|
|
186
|
+
for arg in tokens[j + 1:]:
|
|
187
|
+
if _ga_sep(arg):
|
|
188
|
+
break
|
|
189
|
+
if arg == "--":
|
|
190
|
+
after_dashdash = True
|
|
191
|
+
continue
|
|
192
|
+
if after_dashdash and arg.startswith("-"): # `git add -- -A` names a file called -A
|
|
193
|
+
continue
|
|
194
|
+
if arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg):
|
|
195
|
+
return True
|
|
196
|
+
i = j + 1
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
|
|
201
|
+
_HEREDOC_MARKER = re.compile(
|
|
202
|
+
r"<<(-?)[ \t]*(?:'([A-Za-z_][A-Za-z_0-9]*)'|\"([A-Za-z_][A-Za-z_0-9]*)\"|([A-Za-z_][A-Za-z_0-9]*))"
|
|
203
|
+
)
|
|
204
|
+
_HEREDOC_INTERPRETER = re.compile(
|
|
205
|
+
r"^\s*(?:\S*/)?(?:bash|sh|zsh|dash|python|python3|py|node|ruby|perl|pwsh|powershell)\b",
|
|
206
|
+
re.I,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _unquoted_at(line: str, position: int) -> bool:
|
|
211
|
+
"""Whether a token at position is outside single and double quotes on this line."""
|
|
212
|
+
quote = ""
|
|
213
|
+
escaped = False
|
|
214
|
+
for char in line[:position]:
|
|
215
|
+
if escaped:
|
|
216
|
+
escaped = False
|
|
217
|
+
elif char == "\\" and quote != "'":
|
|
218
|
+
escaped = True
|
|
219
|
+
elif quote:
|
|
220
|
+
if char == quote:
|
|
221
|
+
quote = ""
|
|
222
|
+
elif char in "'\"":
|
|
223
|
+
quote = char
|
|
224
|
+
return not quote and not escaped
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _without_heredoc_bodies(command: str) -> str:
|
|
228
|
+
"""Skip complete data-only heredocs; preserve executable and incomplete bodies."""
|
|
229
|
+
lines = command.splitlines(keepends=True)
|
|
230
|
+
kept: list[str] = []
|
|
231
|
+
i = 0
|
|
232
|
+
while i < len(lines):
|
|
233
|
+
line = lines[i]
|
|
234
|
+
markers = []
|
|
235
|
+
executable = False
|
|
236
|
+
for marker in _HEREDOC_MARKER.finditer(line):
|
|
237
|
+
pos = marker.start()
|
|
238
|
+
if (pos and not (line[pos - 1].isspace() or line[pos - 1] in ";&|")
|
|
239
|
+
or not _unquoted_at(line, pos)):
|
|
240
|
+
continue
|
|
241
|
+
markers.append((next(group for group in marker.groups()[1:] if group is not None),
|
|
242
|
+
marker.group(1) == "-"))
|
|
243
|
+
owner = re.split(r"[;&|]", line[:pos])[-1]
|
|
244
|
+
executable |= bool(_HEREDOC_INTERPRETER.match(owner))
|
|
245
|
+
if not markers:
|
|
246
|
+
kept.append(line)
|
|
247
|
+
i += 1
|
|
248
|
+
continue
|
|
249
|
+
end = i
|
|
250
|
+
for tag, strip_tabs in markers:
|
|
251
|
+
end += 1
|
|
252
|
+
while end < len(lines):
|
|
253
|
+
candidate = lines[end].rstrip("\r\n")
|
|
254
|
+
if (candidate.lstrip("\t") if strip_tabs else candidate) == tag:
|
|
255
|
+
break
|
|
256
|
+
end += 1
|
|
257
|
+
if end == len(lines):
|
|
258
|
+
return command
|
|
259
|
+
kept.extend(lines[i:end + 1] if executable else [line])
|
|
260
|
+
i = end + 1
|
|
261
|
+
return "".join(kept)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _git_add_everything_safe(text: str) -> bool:
|
|
265
|
+
"""git_add_everything, but an error is "no match": one rule must never switch off the rest of the guard."""
|
|
266
|
+
try:
|
|
267
|
+
return git_add_everything(text)
|
|
268
|
+
except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
90
272
|
def _strip_quotes(s: str) -> str:
|
|
91
273
|
"""Drop quotes so quoted flags/targets still match (rm '-rf' \"/\" → rm -rf /)."""
|
|
92
274
|
return s.replace('"', "").replace("'", "")
|
|
@@ -191,6 +373,8 @@ def classify_bash(command: str) -> tuple[str, str]:
|
|
|
191
373
|
return "deny", "fork bomb"
|
|
192
374
|
if re.search(r"\bdd\b.*\bof=/dev/", nl) or re.search(r"\bmkfs", nl) or re.search(r">\s*/dev/sd[a-z]", nl):
|
|
193
375
|
return "deny", "writes directly to a disk device"
|
|
376
|
+
if _git_add_everything_safe(_without_heredoc_bodies(c)):
|
|
377
|
+
return "deny", "git add of everything (-A, --all, . or :/): stage named files only"
|
|
194
378
|
|
|
195
379
|
# ── ask: destructive-but-legit ──
|
|
196
380
|
if rm_rf:
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Growth control: size budgets and archive rules for handoff-grown files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import caddis_exit
|
|
13
|
+
except ModuleNotFoundError as exc:
|
|
14
|
+
if exc.name != "caddis_exit":
|
|
15
|
+
raise
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
18
|
+
import caddis_exit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Budget(NamedTuple):
|
|
22
|
+
glob: str
|
|
23
|
+
unit: str
|
|
24
|
+
limit: int
|
|
25
|
+
rule: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Over(NamedTuple):
|
|
29
|
+
path: str
|
|
30
|
+
unit: str
|
|
31
|
+
value: int
|
|
32
|
+
limit: int
|
|
33
|
+
rule: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BUDGETS: tuple[Budget, ...] = (
|
|
37
|
+
Budget(
|
|
38
|
+
".caddis/relay/*.md",
|
|
39
|
+
"chars",
|
|
40
|
+
4000,
|
|
41
|
+
"rewrite: current state only, git keeps history",
|
|
42
|
+
),
|
|
43
|
+
Budget(
|
|
44
|
+
".caddis/relay/*.tasks.md",
|
|
45
|
+
"table rows",
|
|
46
|
+
40,
|
|
47
|
+
"done rows are never written (Phase 1)",
|
|
48
|
+
),
|
|
49
|
+
Budget(
|
|
50
|
+
".caddis/relay.md",
|
|
51
|
+
"lines",
|
|
52
|
+
60,
|
|
53
|
+
"rendered by render-index; drop relays with status closed",
|
|
54
|
+
),
|
|
55
|
+
Budget(
|
|
56
|
+
".caddis/open-work.md",
|
|
57
|
+
"rows with Status done",
|
|
58
|
+
10,
|
|
59
|
+
"move done rows to .caddis/done/open-work-archive.md with a one-line pointer",
|
|
60
|
+
),
|
|
61
|
+
Budget(
|
|
62
|
+
"DECISIONS.md, .caddis/DECISIONS.md, .caddis/decisions/DECISIONS.md", # the last is the fleet's real path
|
|
63
|
+
"History table rows",
|
|
64
|
+
30,
|
|
65
|
+
"move older rows to DECISIONS-archive.md beside it",
|
|
66
|
+
),
|
|
67
|
+
Budget(
|
|
68
|
+
".caddis/usage-log.jsonl",
|
|
69
|
+
"bytes",
|
|
70
|
+
5000000,
|
|
71
|
+
"rotate to usage-log-<YYYYMM>.jsonl",
|
|
72
|
+
),
|
|
73
|
+
Budget(
|
|
74
|
+
".caddis/session-state/*.md",
|
|
75
|
+
"files",
|
|
76
|
+
8,
|
|
77
|
+
"session_state.py prune (not in a shared checkout)",
|
|
78
|
+
),
|
|
79
|
+
Budget(
|
|
80
|
+
".caddis/kb/*.md",
|
|
81
|
+
"lines per file",
|
|
82
|
+
300,
|
|
83
|
+
"split, or move closed parts to .caddis/kb/done/",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _ParsedTable(NamedTuple):
|
|
89
|
+
heading: str | None
|
|
90
|
+
columns: list[str]
|
|
91
|
+
data_rows: list[list[str]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _split_cells(line: str) -> list[str]:
|
|
95
|
+
s = line.strip()
|
|
96
|
+
if not s:
|
|
97
|
+
return []
|
|
98
|
+
parts = re.split(r"(?<!\\)\|", s)
|
|
99
|
+
if parts and parts[0] == "":
|
|
100
|
+
parts.pop(0)
|
|
101
|
+
if parts and parts[-1] == "":
|
|
102
|
+
parts.pop(-1)
|
|
103
|
+
return [p.strip() for p in parts]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_separator(line: str) -> bool:
|
|
107
|
+
cells = _split_cells(line)
|
|
108
|
+
if not cells:
|
|
109
|
+
return False
|
|
110
|
+
for cell in cells:
|
|
111
|
+
c = cell.strip()
|
|
112
|
+
if not c or any(ch not in "-: " for ch in c) or "-" not in c:
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_tables(text: str) -> tuple[list[_ParsedTable], int]:
|
|
118
|
+
lines = text.splitlines()
|
|
119
|
+
tables: list[_ParsedTable] = []
|
|
120
|
+
loose_rows = 0
|
|
121
|
+
n = len(lines)
|
|
122
|
+
i = 0
|
|
123
|
+
while i < n:
|
|
124
|
+
if lines[i].lstrip().startswith("|"):
|
|
125
|
+
run_start = i
|
|
126
|
+
while i < n and lines[i].lstrip().startswith("|"):
|
|
127
|
+
i += 1
|
|
128
|
+
run = lines[run_start:i]
|
|
129
|
+
if len(run) >= 2 and _is_separator(run[1]):
|
|
130
|
+
heading: str | None = None
|
|
131
|
+
for k in range(run_start - 1, -1, -1):
|
|
132
|
+
s = lines[k].strip()
|
|
133
|
+
if s.startswith("#"):
|
|
134
|
+
heading = s.lstrip("#").strip()
|
|
135
|
+
break
|
|
136
|
+
columns = _split_cells(run[0])
|
|
137
|
+
data_rows = [_split_cells(r) for r in run[2:] if not _is_separator(r)]
|
|
138
|
+
tables.append(_ParsedTable(heading=heading, columns=columns, data_rows=data_rows))
|
|
139
|
+
else:
|
|
140
|
+
for r in run:
|
|
141
|
+
if not _is_separator(r) and not r.lstrip().startswith(("| Old #", "| # |")):
|
|
142
|
+
loose_rows += 1
|
|
143
|
+
else:
|
|
144
|
+
i += 1
|
|
145
|
+
return tables, loose_rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _count_table_rows(text: str) -> int:
|
|
149
|
+
tables, loose_rows = _parse_tables(text)
|
|
150
|
+
return sum(len(t.data_rows) for t in tables) + loose_rows
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _norm_status(cell: str) -> str:
|
|
154
|
+
return cell.replace("**", "").replace("__", "").replace("`", "").strip().lower()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _count_done_rows(text: str) -> int:
|
|
158
|
+
tables, _ = _parse_tables(text)
|
|
159
|
+
done_re = re.compile(r"^done\b")
|
|
160
|
+
count = 0
|
|
161
|
+
if tables:
|
|
162
|
+
for t in tables:
|
|
163
|
+
col_map = {c.lower(): idx for idx, c in enumerate(t.columns)}
|
|
164
|
+
if "status" in col_map:
|
|
165
|
+
s_idx = col_map["status"]
|
|
166
|
+
for row in t.data_rows:
|
|
167
|
+
if len(row) > s_idx and done_re.search(_norm_status(row[s_idx])):
|
|
168
|
+
count += 1
|
|
169
|
+
else:
|
|
170
|
+
for row in t.data_rows:
|
|
171
|
+
if any(_norm_status(c) == "done" for c in row):
|
|
172
|
+
count += 1
|
|
173
|
+
return count
|
|
174
|
+
|
|
175
|
+
for line in text.splitlines():
|
|
176
|
+
if line.lstrip().startswith("|") and not _is_separator(line):
|
|
177
|
+
cells = _split_cells(line)
|
|
178
|
+
if any(_norm_status(c) == "done" for c in cells):
|
|
179
|
+
count += 1
|
|
180
|
+
return count
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _count_history_rows(text: str) -> int:
|
|
184
|
+
tables, loose_rows = _parse_tables(text)
|
|
185
|
+
history_tables = [t for t in tables if t.heading and "history" in t.heading.lower()]
|
|
186
|
+
if history_tables:
|
|
187
|
+
return sum(len(t.data_rows) for t in history_tables)
|
|
188
|
+
return sum(len(t.data_rows) for t in tables) + loose_rows
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _measure_file(path: Path, unit: str) -> int:
|
|
192
|
+
if unit == "bytes":
|
|
193
|
+
return path.stat().st_size
|
|
194
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
195
|
+
if unit == "chars":
|
|
196
|
+
return len(text)
|
|
197
|
+
if unit == "table rows":
|
|
198
|
+
return _count_table_rows(text)
|
|
199
|
+
if unit in ("lines", "lines per file"):
|
|
200
|
+
return len(text.splitlines())
|
|
201
|
+
if unit == "rows with Status done":
|
|
202
|
+
return _count_done_rows(text)
|
|
203
|
+
if unit == "History table rows":
|
|
204
|
+
return _count_history_rows(text)
|
|
205
|
+
raise ValueError(f"unknown budget unit: {unit}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check(root: Path) -> list[Over]:
|
|
209
|
+
"""Return every file under ``root`` that exceeds its configured size budget."""
|
|
210
|
+
root = Path(root)
|
|
211
|
+
overs: list[Over] = []
|
|
212
|
+
|
|
213
|
+
for budget in BUDGETS:
|
|
214
|
+
if budget.unit == "files":
|
|
215
|
+
files = [p for p in sorted(root.glob(budget.glob)) if p.is_file()]
|
|
216
|
+
value = len(files)
|
|
217
|
+
if value > budget.limit:
|
|
218
|
+
overs.append(Over(budget.glob, budget.unit, value, budget.limit, budget.rule))
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
patterns = [g.strip() for g in budget.glob.split(",")]
|
|
222
|
+
for pattern in patterns:
|
|
223
|
+
if "*" in pattern:
|
|
224
|
+
candidates = [p for p in sorted(root.glob(pattern)) if p.is_file()]
|
|
225
|
+
else:
|
|
226
|
+
candidate = root / pattern
|
|
227
|
+
candidates = [candidate] if candidate.is_file() else []
|
|
228
|
+
|
|
229
|
+
for p in candidates:
|
|
230
|
+
if pattern == ".caddis/relay/*.md" and p.name.endswith(".tasks.md"):
|
|
231
|
+
continue
|
|
232
|
+
rel = p.relative_to(root).as_posix()
|
|
233
|
+
value = _measure_file(p, budget.unit)
|
|
234
|
+
if value > budget.limit:
|
|
235
|
+
overs.append(Over(rel, budget.unit, value, budget.limit, budget.rule))
|
|
236
|
+
|
|
237
|
+
return overs
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: list[str] | None = None) -> int:
|
|
241
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
242
|
+
try:
|
|
243
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
247
|
+
try:
|
|
248
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
parser = argparse.ArgumentParser(
|
|
253
|
+
prog="caddis_budget.py",
|
|
254
|
+
description="Check handoff-grown files against their size budgets.",
|
|
255
|
+
)
|
|
256
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
257
|
+
p_check = sub.add_parser("check", help="Check files under --root against BUDGETS.")
|
|
258
|
+
p_check.add_argument("--root", type=Path, default=Path("."), help="Repository root.")
|
|
259
|
+
p_check.add_argument(
|
|
260
|
+
"--report",
|
|
261
|
+
action="store_true",
|
|
262
|
+
help="Report files over budget but always exit 0.",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
args = parser.parse_args(argv)
|
|
266
|
+
try:
|
|
267
|
+
overs = check(args.root)
|
|
268
|
+
except OSError as exc: # a locked or unreadable file (Windows): --report still never fails the caller
|
|
269
|
+
print(f"[BUDGET] could not read every file: {exc}")
|
|
270
|
+
return caddis_exit.CLEAN if args.report else caddis_exit.NOT_RUN
|
|
271
|
+
if not overs:
|
|
272
|
+
print("[BUDGET] all within budget")
|
|
273
|
+
return caddis_exit.CLEAN
|
|
274
|
+
|
|
275
|
+
for over in overs:
|
|
276
|
+
print(f"OVER {over.path}: {over.value} {over.unit} > {over.limit}; fix: {over.rule}")
|
|
277
|
+
if args.report:
|
|
278
|
+
return caddis_exit.CLEAN
|
|
279
|
+
return caddis_exit.BLOCKED
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
raise SystemExit(main())
|
|
@@ -59,7 +59,11 @@ def get_porcelain_lines(repo: Path) -> tuple[int, list[str]]:
|
|
|
59
59
|
return 0, entries
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# A plain line, or a list item and/or bold: `- **Shared checkout: yes** (...)` (watch-sight's form until 16 Oct).
|
|
63
|
+
# After `yes` only the line end or a reason may follow: `(`, a dash, `.`, `:`, `,`, `;` or a `#` comment. A word
|
|
64
|
+
# after `yes` is prose ("Shared checkout: yes means ..."), not a declaration.
|
|
65
|
+
SHARED_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?(?:\*\*)?Shared checkout:[ \t]*yes(?:\*\*)?[ \t]*(?:$|[(\-–—.:,;#])",
|
|
66
|
+
re.IGNORECASE | re.MULTILINE)
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
def entry_paths(entry: str) -> list[str]:
|