corrkit 0.5.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.
- accounts.py +317 -0
- audit_docs.py +251 -0
- cloudflare/__init__.py +0 -0
- collab/__init__.py +81 -0
- collab/add.py +373 -0
- collab/find_unanswered.py +86 -0
- collab/remove.py +78 -0
- collab/rename.py +101 -0
- collab/reset.py +168 -0
- collab/sync.py +172 -0
- collab/templates/notify.yml +18 -0
- collab/validate_draft.py +111 -0
- contact/__init__.py +47 -0
- contact/add.py +117 -0
- corrkit-0.5.0.dist-info/METADATA +410 -0
- corrkit-0.5.0.dist-info/RECORD +29 -0
- corrkit-0.5.0.dist-info/WHEEL +4 -0
- corrkit-0.5.0.dist-info/entry_points.txt +14 -0
- corrkit-0.5.0.dist-info/licenses/LICENSE +190 -0
- corrkit.py +108 -0
- draft/__init__.py +0 -0
- draft/push.py +234 -0
- help.py +71 -0
- sync/__init__.py +0 -0
- sync/auth.py +49 -0
- sync/folders.py +63 -0
- sync/imap.py +641 -0
- sync/types.py +43 -0
- watch.py +182 -0
accounts.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Account configuration — parse accounts.toml with provider presets."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import msgspec
|
|
12
|
+
|
|
13
|
+
CONFIG_PATH = Path("accounts.toml")
|
|
14
|
+
|
|
15
|
+
PROVIDER_PRESETS: dict[str, dict[str, object]] = {
|
|
16
|
+
"gmail": {
|
|
17
|
+
"imap_host": "imap.gmail.com",
|
|
18
|
+
"imap_port": 993,
|
|
19
|
+
"imap_starttls": False,
|
|
20
|
+
"smtp_host": "smtp.gmail.com",
|
|
21
|
+
"smtp_port": 465,
|
|
22
|
+
"drafts_folder": "[Gmail]/Drafts",
|
|
23
|
+
},
|
|
24
|
+
"protonmail-bridge": {
|
|
25
|
+
"imap_host": "127.0.0.1",
|
|
26
|
+
"imap_port": 1143,
|
|
27
|
+
"imap_starttls": True,
|
|
28
|
+
"smtp_host": "127.0.0.1",
|
|
29
|
+
"smtp_port": 1025,
|
|
30
|
+
"drafts_folder": "Drafts",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_NON_ACCOUNT_KEYS = frozenset({"watch", "owner"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OwnerConfig(msgspec.Struct):
|
|
39
|
+
github_user: str
|
|
40
|
+
name: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WatchConfig(msgspec.Struct):
|
|
44
|
+
poll_interval: int = 300
|
|
45
|
+
notify: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Account(msgspec.Struct):
|
|
49
|
+
provider: str = "imap"
|
|
50
|
+
user: str = ""
|
|
51
|
+
password: str = ""
|
|
52
|
+
password_cmd: str = ""
|
|
53
|
+
labels: list[str] = []
|
|
54
|
+
imap_host: str = ""
|
|
55
|
+
imap_port: int = 993
|
|
56
|
+
imap_starttls: bool = False
|
|
57
|
+
smtp_host: str = ""
|
|
58
|
+
smtp_port: int = 465
|
|
59
|
+
drafts_folder: str = "Drafts"
|
|
60
|
+
sync_days: int = 3650
|
|
61
|
+
default: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _apply_preset(account: Account) -> Account:
|
|
65
|
+
"""Merge provider preset defaults — account values win over preset."""
|
|
66
|
+
preset = PROVIDER_PRESETS.get(account.provider)
|
|
67
|
+
if preset is None:
|
|
68
|
+
return account
|
|
69
|
+
# Only apply preset values for fields still at their Struct defaults
|
|
70
|
+
defaults = Account()
|
|
71
|
+
updates: dict[str, object] = {}
|
|
72
|
+
for key, preset_val in preset.items():
|
|
73
|
+
if getattr(account, key) == getattr(defaults, key):
|
|
74
|
+
updates[key] = preset_val
|
|
75
|
+
if updates:
|
|
76
|
+
return msgspec.structs.replace(account, **updates)
|
|
77
|
+
return account
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_password(account: Account) -> str:
|
|
81
|
+
"""Return password: inline value if set, else run password_cmd."""
|
|
82
|
+
if account.password:
|
|
83
|
+
return account.password
|
|
84
|
+
if account.password_cmd:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
account.password_cmd,
|
|
87
|
+
shell=True,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
return result.stdout.strip()
|
|
93
|
+
raise ValueError(f"Account {account.user!r} has no password or password_cmd")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_accounts(path: Path | None = None) -> dict[str, Account]:
|
|
97
|
+
"""Parse accounts.toml → {name: Account} mapping."""
|
|
98
|
+
if path is None:
|
|
99
|
+
path = CONFIG_PATH
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return {}
|
|
102
|
+
with open(path, "rb") as f:
|
|
103
|
+
raw = tomllib.load(f)
|
|
104
|
+
accounts_section = raw.get("accounts", raw)
|
|
105
|
+
result: dict[str, Account] = {}
|
|
106
|
+
for name, data in accounts_section.items():
|
|
107
|
+
if name in _NON_ACCOUNT_KEYS:
|
|
108
|
+
continue
|
|
109
|
+
account = msgspec.convert(data, Account)
|
|
110
|
+
account = _apply_preset(account)
|
|
111
|
+
result[name] = account
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _legacy_account_from_env() -> Account:
|
|
116
|
+
"""Build a synthetic Account from legacy .env GMAIL_* vars."""
|
|
117
|
+
from dotenv import load_dotenv
|
|
118
|
+
|
|
119
|
+
load_dotenv()
|
|
120
|
+
|
|
121
|
+
user = os.environ.get("GMAIL_USER_EMAIL", "")
|
|
122
|
+
password = os.environ.get("GMAIL_APP_PASSWORD", "")
|
|
123
|
+
labels_str = os.environ.get("GMAIL_SYNC_LABELS", "")
|
|
124
|
+
sync_days = int(os.environ.get("GMAIL_SYNC_DAYS", "3650"))
|
|
125
|
+
|
|
126
|
+
if not user:
|
|
127
|
+
raise SystemExit("No accounts.toml found and GMAIL_USER_EMAIL not set in .env")
|
|
128
|
+
|
|
129
|
+
return Account(
|
|
130
|
+
provider="gmail",
|
|
131
|
+
user=user,
|
|
132
|
+
password=password.replace(" ", ""),
|
|
133
|
+
labels=[s.strip() for s in labels_str.split(",") if s.strip()],
|
|
134
|
+
imap_host="imap.gmail.com",
|
|
135
|
+
imap_port=993,
|
|
136
|
+
smtp_host="smtp.gmail.com",
|
|
137
|
+
smtp_port=465,
|
|
138
|
+
drafts_folder="[Gmail]/Drafts",
|
|
139
|
+
sync_days=sync_days,
|
|
140
|
+
default=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_accounts_or_env(path: Path | None = None) -> dict[str, Account]:
|
|
145
|
+
"""Load accounts.toml, falling back to .env GMAIL_* vars."""
|
|
146
|
+
accounts = load_accounts(path)
|
|
147
|
+
if accounts:
|
|
148
|
+
return accounts
|
|
149
|
+
return {"_legacy": _legacy_account_from_env()}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_owner(path: Path | None = None) -> OwnerConfig:
|
|
153
|
+
"""Load [owner] section from accounts.toml."""
|
|
154
|
+
if path is None:
|
|
155
|
+
path = CONFIG_PATH
|
|
156
|
+
if not path.exists():
|
|
157
|
+
raise SystemExit(
|
|
158
|
+
f"accounts.toml not found at {path}.\n"
|
|
159
|
+
"Add an [owner] section with github_user."
|
|
160
|
+
)
|
|
161
|
+
with open(path, "rb") as f:
|
|
162
|
+
raw = tomllib.load(f)
|
|
163
|
+
owner_data = raw.get("owner")
|
|
164
|
+
if owner_data is None:
|
|
165
|
+
raise SystemExit(
|
|
166
|
+
"Missing [owner] section in accounts.toml.\n"
|
|
167
|
+
'Add: [owner]\ngithub_user = "your-github-username"'
|
|
168
|
+
)
|
|
169
|
+
return msgspec.convert(owner_data, OwnerConfig)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_default_account(accounts: dict[str, Account]) -> tuple[str, Account]:
|
|
173
|
+
"""Return (name, account) for the default account."""
|
|
174
|
+
for name, acct in accounts.items():
|
|
175
|
+
if acct.default:
|
|
176
|
+
return name, acct
|
|
177
|
+
# Fall back to first account
|
|
178
|
+
name = next(iter(accounts))
|
|
179
|
+
return name, accounts[name]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_account_for_email(
|
|
183
|
+
accounts: dict[str, Account], email_addr: str
|
|
184
|
+
) -> tuple[str, Account] | None:
|
|
185
|
+
"""Lookup account by email address."""
|
|
186
|
+
email_lower = email_addr.lower()
|
|
187
|
+
for name, acct in accounts.items():
|
|
188
|
+
if acct.user.lower() == email_lower:
|
|
189
|
+
return name, acct
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def add_label_to_account(
|
|
194
|
+
account_name: str,
|
|
195
|
+
label: str,
|
|
196
|
+
path: Path | None = None,
|
|
197
|
+
) -> bool:
|
|
198
|
+
"""Add a label to an account's labels list in accounts.toml.
|
|
199
|
+
|
|
200
|
+
Does a text-level edit to preserve comments and formatting.
|
|
201
|
+
Returns True if the label was added, False if already present.
|
|
202
|
+
"""
|
|
203
|
+
if path is None:
|
|
204
|
+
path = CONFIG_PATH
|
|
205
|
+
if not path.exists():
|
|
206
|
+
print(
|
|
207
|
+
f"accounts.toml not found at {path}",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
# Verify account exists and label isn't already there
|
|
213
|
+
accounts = load_accounts(path)
|
|
214
|
+
if account_name not in accounts:
|
|
215
|
+
print(
|
|
216
|
+
f"Unknown account: {account_name}\nAvailable: {', '.join(accounts.keys())}",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
if label in accounts[account_name].labels:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
# Text-level edit: find the labels line for this account section
|
|
224
|
+
text = path.read_text(encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
# Find the account section header
|
|
227
|
+
section_re = re.compile(
|
|
228
|
+
rf"^\[accounts\.{re.escape(account_name)}\]",
|
|
229
|
+
re.MULTILINE,
|
|
230
|
+
)
|
|
231
|
+
section_match = section_re.search(text)
|
|
232
|
+
if not section_match:
|
|
233
|
+
# Try flat format (no [accounts.] prefix)
|
|
234
|
+
section_re = re.compile(rf"^\[{re.escape(account_name)}\]", re.MULTILINE)
|
|
235
|
+
section_match = section_re.search(text)
|
|
236
|
+
if not section_match:
|
|
237
|
+
print(
|
|
238
|
+
f"Could not find [{account_name}] section in {path}",
|
|
239
|
+
file=sys.stderr,
|
|
240
|
+
)
|
|
241
|
+
sys.exit(1)
|
|
242
|
+
|
|
243
|
+
# Find the labels = [...] line after the section header
|
|
244
|
+
labels_re = re.compile(
|
|
245
|
+
r"^(labels\s*=\s*\[)(.*?)(\])",
|
|
246
|
+
re.MULTILINE,
|
|
247
|
+
)
|
|
248
|
+
# Search from the section start
|
|
249
|
+
labels_match = labels_re.search(text, section_match.end())
|
|
250
|
+
if not labels_match:
|
|
251
|
+
print(
|
|
252
|
+
f"Could not find labels line for account {account_name}",
|
|
253
|
+
file=sys.stderr,
|
|
254
|
+
)
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
# Check it's within this section (before next section header)
|
|
258
|
+
next_section = re.search(r"^\[", text[section_match.end() :], re.MULTILINE)
|
|
259
|
+
beyond_section = (
|
|
260
|
+
next_section
|
|
261
|
+
and labels_match.start() > section_match.end() + next_section.start()
|
|
262
|
+
)
|
|
263
|
+
if beyond_section:
|
|
264
|
+
print(
|
|
265
|
+
f"Could not find labels line for account {account_name}",
|
|
266
|
+
file=sys.stderr,
|
|
267
|
+
)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
# Append the label
|
|
271
|
+
existing = labels_match.group(2).strip()
|
|
272
|
+
if existing:
|
|
273
|
+
new_labels = f'{existing}, "{label}"'
|
|
274
|
+
else:
|
|
275
|
+
new_labels = f'"{label}"'
|
|
276
|
+
|
|
277
|
+
new_text = (
|
|
278
|
+
text[: labels_match.start()]
|
|
279
|
+
+ f"labels = [{new_labels}]"
|
|
280
|
+
+ text[labels_match.end() :]
|
|
281
|
+
)
|
|
282
|
+
path.write_text(new_text, encoding="utf-8")
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def add_label_main() -> None:
|
|
287
|
+
"""CLI: corrkit add-label LABEL --account ACCOUNT"""
|
|
288
|
+
parser = argparse.ArgumentParser(
|
|
289
|
+
description="Add a label to an account's sync config"
|
|
290
|
+
)
|
|
291
|
+
parser.add_argument("label", help="Label to add")
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"--account",
|
|
294
|
+
required=True,
|
|
295
|
+
help="Account name in accounts.toml",
|
|
296
|
+
)
|
|
297
|
+
args = parser.parse_args()
|
|
298
|
+
|
|
299
|
+
added = add_label_to_account(args.account, args.label)
|
|
300
|
+
if added:
|
|
301
|
+
print(f"Added '{args.label}' to account '{args.account}' in accounts.toml")
|
|
302
|
+
else:
|
|
303
|
+
print(f"Label '{args.label}' already in account '{args.account}'")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_watch_config(path: Path | None = None) -> WatchConfig:
|
|
307
|
+
"""Load [watch] section from accounts.toml. Returns defaults if missing."""
|
|
308
|
+
if path is None:
|
|
309
|
+
path = CONFIG_PATH
|
|
310
|
+
if not path.exists():
|
|
311
|
+
return WatchConfig()
|
|
312
|
+
with open(path, "rb") as f:
|
|
313
|
+
raw = tomllib.load(f)
|
|
314
|
+
watch_data = raw.get("watch")
|
|
315
|
+
if watch_data is None:
|
|
316
|
+
return WatchConfig()
|
|
317
|
+
return msgspec.convert(watch_data, WatchConfig)
|
audit_docs.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Audit instruction files against the codebase.
|
|
2
|
+
|
|
3
|
+
Checks:
|
|
4
|
+
- Referenced paths exist on disk (from project structure tree)
|
|
5
|
+
- uv run scripts are registered or point to existing files
|
|
6
|
+
- Type conventions (msgspec vs dataclasses)
|
|
7
|
+
- Combined instruction file line budget
|
|
8
|
+
- Staleness (docs older than source)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_root() -> Path:
|
|
21
|
+
"""Find the project root by walking up from CWD looking for pyproject.toml."""
|
|
22
|
+
cwd = Path.cwd()
|
|
23
|
+
for parent in [cwd, *cwd.parents]:
|
|
24
|
+
if (parent / "pyproject.toml").exists():
|
|
25
|
+
return parent
|
|
26
|
+
print("Error: could not find pyproject.toml", file=sys.stderr)
|
|
27
|
+
sys.exit(2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ROOT = _find_root()
|
|
31
|
+
LINE_BUDGET = 1000
|
|
32
|
+
SKIP_PATHS = {".env"} # Gitignored files expected to be absent
|
|
33
|
+
TOOL_COMMANDS = {"ruff", "ty", "pytest", "poe"} # External tools invoked via uv run
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Issue(NamedTuple):
|
|
37
|
+
file: str
|
|
38
|
+
line: int
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_instruction_files() -> list[Path]:
|
|
43
|
+
patterns = ["AGENTS.md", "README.md", ".claude/**/SKILL.md", "src/**/AGENTS.md"]
|
|
44
|
+
found: set[Path] = set()
|
|
45
|
+
for p in patterns:
|
|
46
|
+
found.update(ROOT.glob(p))
|
|
47
|
+
return sorted(found)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_scripts() -> dict[str, str]:
|
|
51
|
+
with open(ROOT / "pyproject.toml", "rb") as f:
|
|
52
|
+
data = tomllib.load(f)
|
|
53
|
+
return data.get("project", {}).get("scripts", {})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- Extractors ---
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_tree_paths(content: str) -> list[tuple[int, str]]:
|
|
60
|
+
"""Parse file paths from the Project Structure tree block."""
|
|
61
|
+
results: list[tuple[int, str]] = []
|
|
62
|
+
lines = content.splitlines()
|
|
63
|
+
in_section = False
|
|
64
|
+
in_block = False
|
|
65
|
+
stack: list[tuple[int, str]] = [] # (indent, dirname_with_slash)
|
|
66
|
+
|
|
67
|
+
for i, line in enumerate(lines, 1):
|
|
68
|
+
if line.startswith("## Project Structure"):
|
|
69
|
+
in_section = True
|
|
70
|
+
continue
|
|
71
|
+
if in_section and not in_block:
|
|
72
|
+
if line.strip().startswith("```"):
|
|
73
|
+
in_block = True
|
|
74
|
+
continue
|
|
75
|
+
if line.startswith("## "):
|
|
76
|
+
break
|
|
77
|
+
continue
|
|
78
|
+
if not in_block:
|
|
79
|
+
continue
|
|
80
|
+
if line.strip().startswith("```"):
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
stripped = line.rstrip()
|
|
84
|
+
if not stripped.strip():
|
|
85
|
+
continue
|
|
86
|
+
indent = len(stripped) - len(stripped.lstrip())
|
|
87
|
+
name = stripped.strip().split("#")[0].strip()
|
|
88
|
+
if not name:
|
|
89
|
+
continue
|
|
90
|
+
# Strip symlink arrow notation (e.g. "correspondence -> ~/path")
|
|
91
|
+
# Treat as directory since it's a symlink to a directory
|
|
92
|
+
if " -> " in name:
|
|
93
|
+
name = name.split(" -> ")[0].strip() + "/"
|
|
94
|
+
|
|
95
|
+
# Pop deeper/equal entries from stack
|
|
96
|
+
while stack and stack[-1][0] >= indent:
|
|
97
|
+
stack.pop()
|
|
98
|
+
|
|
99
|
+
if name.endswith("/"):
|
|
100
|
+
stack.append((indent, name))
|
|
101
|
+
else:
|
|
102
|
+
parts = [d for _, d in stack] + [name]
|
|
103
|
+
full = "".join(parts)
|
|
104
|
+
if full.startswith("correspondence-kit/"):
|
|
105
|
+
full = full[len("correspondence-kit/") :]
|
|
106
|
+
results.append((i, full))
|
|
107
|
+
|
|
108
|
+
return results
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_uv_commands(content: str) -> list[tuple[int, str]]:
|
|
112
|
+
"""Extract targets from `uv run <target>` in all contexts."""
|
|
113
|
+
results: list[tuple[int, str]] = []
|
|
114
|
+
pat = re.compile(r"uv run ([\w./-]+)")
|
|
115
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
116
|
+
for m in pat.finditer(line):
|
|
117
|
+
results.append((i, m.group(1)))
|
|
118
|
+
return results
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- Checks ---
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_tree_paths(rel: str, content: str) -> list[Issue]:
|
|
125
|
+
issues: list[Issue] = []
|
|
126
|
+
for line_no, path in extract_tree_paths(content):
|
|
127
|
+
if re.search(r"\[.*?]", path):
|
|
128
|
+
continue
|
|
129
|
+
if path in SKIP_PATHS:
|
|
130
|
+
continue
|
|
131
|
+
if not (ROOT / path).exists():
|
|
132
|
+
issues.append(
|
|
133
|
+
Issue(rel, line_no, f"Referenced path does not exist: {path}")
|
|
134
|
+
)
|
|
135
|
+
return issues
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_scripts(rel: str, content: str, registered: dict[str, str]) -> list[Issue]:
|
|
139
|
+
issues: list[Issue] = []
|
|
140
|
+
for line_no, target in extract_uv_commands(content):
|
|
141
|
+
if "/" in target or target.endswith(".py"):
|
|
142
|
+
if not (ROOT / target).exists():
|
|
143
|
+
issues.append(
|
|
144
|
+
Issue(rel, line_no, f"Script file does not exist: {target}")
|
|
145
|
+
)
|
|
146
|
+
elif target not in TOOL_COMMANDS and target not in registered:
|
|
147
|
+
issues.append(
|
|
148
|
+
Issue(
|
|
149
|
+
rel, line_no, f"Script not registered in pyproject.toml: {target}"
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return issues
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def check_type_conventions(rel: str, content: str) -> list[Issue]:
|
|
156
|
+
issues: list[Issue] = []
|
|
157
|
+
if "msgspec" not in content:
|
|
158
|
+
return issues
|
|
159
|
+
for py in sorted(ROOT.glob("src/**/*.py")):
|
|
160
|
+
if py.name == "audit_docs.py":
|
|
161
|
+
continue # skip self — contains the check string as a literal
|
|
162
|
+
text = py.read_text()
|
|
163
|
+
if "from dataclasses import" in text or "import dataclasses" in text:
|
|
164
|
+
py_rel = py.relative_to(ROOT)
|
|
165
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
166
|
+
if "msgspec" in line and (
|
|
167
|
+
"dataclass" in line.lower() or "struct" in line.lower()
|
|
168
|
+
):
|
|
169
|
+
issues.append(
|
|
170
|
+
Issue(
|
|
171
|
+
rel,
|
|
172
|
+
i,
|
|
173
|
+
f'Docs say "msgspec" but {py_rel} uses dataclasses',
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
break
|
|
177
|
+
break # one report per doc file is enough
|
|
178
|
+
return issues
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def check_line_budget(
|
|
182
|
+
files: list[Path],
|
|
183
|
+
) -> tuple[list[Issue], dict[str, int], int]:
|
|
184
|
+
counts: dict[str, int] = {}
|
|
185
|
+
total = 0
|
|
186
|
+
for f in files:
|
|
187
|
+
n = len(f.read_text().splitlines())
|
|
188
|
+
counts[str(f.relative_to(ROOT))] = n
|
|
189
|
+
total += n
|
|
190
|
+
issues: list[Issue] = []
|
|
191
|
+
if total > LINE_BUDGET:
|
|
192
|
+
issues.append(
|
|
193
|
+
Issue("(all)", 0, f"Over line budget: {total} lines (max {LINE_BUDGET})")
|
|
194
|
+
)
|
|
195
|
+
return issues, counts, total
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_staleness(files: list[Path]) -> list[Issue]:
|
|
199
|
+
src_files = list(ROOT.glob("src/**/*.py"))
|
|
200
|
+
if not src_files:
|
|
201
|
+
return []
|
|
202
|
+
newest_src = max(src_files, key=lambda f: f.stat().st_mtime)
|
|
203
|
+
newest_mtime = newest_src.stat().st_mtime
|
|
204
|
+
issues: list[Issue] = []
|
|
205
|
+
for doc in files:
|
|
206
|
+
if doc.stat().st_mtime < newest_mtime:
|
|
207
|
+
issues.append(
|
|
208
|
+
Issue(
|
|
209
|
+
str(doc.relative_to(ROOT)),
|
|
210
|
+
0,
|
|
211
|
+
f"Older than {newest_src.relative_to(ROOT)} — may be stale",
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return issues
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main() -> None:
|
|
218
|
+
print("Auditing docs...\n")
|
|
219
|
+
files = find_instruction_files()
|
|
220
|
+
scripts = load_scripts()
|
|
221
|
+
issues: list[Issue] = []
|
|
222
|
+
|
|
223
|
+
for doc in files:
|
|
224
|
+
rel = str(doc.relative_to(ROOT))
|
|
225
|
+
content = doc.read_text()
|
|
226
|
+
issues.extend(check_tree_paths(rel, content))
|
|
227
|
+
issues.extend(check_scripts(rel, content, scripts))
|
|
228
|
+
issues.extend(check_type_conventions(rel, content))
|
|
229
|
+
|
|
230
|
+
budget_issues, counts, total = check_line_budget(files)
|
|
231
|
+
issues.extend(budget_issues)
|
|
232
|
+
issues.extend(check_staleness(files))
|
|
233
|
+
|
|
234
|
+
for issue in issues:
|
|
235
|
+
loc = f" {issue.file}"
|
|
236
|
+
if issue.line:
|
|
237
|
+
loc += f":{issue.line}"
|
|
238
|
+
print(f"{loc:<35} ✗ {issue.message}")
|
|
239
|
+
|
|
240
|
+
mark = "✓" if total <= LINE_BUDGET else "✗"
|
|
241
|
+
print(f"\nCombined instruction files: {total} lines (budget: {LINE_BUDGET}) {mark}")
|
|
242
|
+
for name, n in sorted(counts.items()):
|
|
243
|
+
print(f" {name}: {n}")
|
|
244
|
+
|
|
245
|
+
n = len(issues)
|
|
246
|
+
print(f"\nFound {n} issue(s)" if n else "\nNo issues found ✓")
|
|
247
|
+
sys.exit(1 if n else 0)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
cloudflare/__init__.py
ADDED
|
File without changes
|
collab/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Collaborator configuration — parse collaborators.toml."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import msgspec
|
|
7
|
+
|
|
8
|
+
CONFIG_PATH = Path("collaborators.toml")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Collaborator(msgspec.Struct):
|
|
12
|
+
labels: list[str]
|
|
13
|
+
repo: str = ""
|
|
14
|
+
github_user: str = ""
|
|
15
|
+
name: str = ""
|
|
16
|
+
account: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collab_dir(collab: Collaborator) -> Path:
|
|
20
|
+
"""Return the local collab directory (correspondence/for/{gh_user}/)."""
|
|
21
|
+
return Path("correspondence") / "for" / collab.github_user.lower()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _auto_repo(owner_gh: str, collab_gh: str) -> str:
|
|
25
|
+
"""Derive the default repo name: {owner}/to-{collab}."""
|
|
26
|
+
return f"{owner_gh}/to-{collab_gh.lower()}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_collaborators(path: Path | None = None) -> dict[str, Collaborator]:
|
|
30
|
+
"""Load collaborators.toml and return {github_user: Collaborator} mapping.
|
|
31
|
+
|
|
32
|
+
The TOML section key is the collaborator's GitHub username.
|
|
33
|
+
``repo`` is auto-derived from owner config if not explicitly set.
|
|
34
|
+
"""
|
|
35
|
+
if path is None:
|
|
36
|
+
path = CONFIG_PATH
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return {}
|
|
39
|
+
with open(path, "rb") as f:
|
|
40
|
+
raw = tomllib.load(f)
|
|
41
|
+
# Load owner for repo auto-derivation
|
|
42
|
+
owner_gh = ""
|
|
43
|
+
try:
|
|
44
|
+
from accounts import load_owner
|
|
45
|
+
|
|
46
|
+
owner = load_owner()
|
|
47
|
+
owner_gh = owner.github_user
|
|
48
|
+
except (SystemExit, ImportError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
result: dict[str, Collaborator] = {}
|
|
52
|
+
for gh_user, data in raw.items():
|
|
53
|
+
collab = msgspec.convert(data, Collaborator)
|
|
54
|
+
# github_user is always the TOML key
|
|
55
|
+
collab = msgspec.structs.replace(collab, github_user=gh_user)
|
|
56
|
+
# Auto-derive repo if not explicit
|
|
57
|
+
if not collab.repo and owner_gh:
|
|
58
|
+
collab = msgspec.structs.replace(collab, repo=_auto_repo(owner_gh, gh_user))
|
|
59
|
+
result[gh_user] = collab
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_collaborators(
|
|
64
|
+
collabs: dict[str, Collaborator], path: Path | None = None
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Write collaborators back to TOML. Simple serializer — no third-party dep."""
|
|
67
|
+
if path is None:
|
|
68
|
+
path = CONFIG_PATH
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
for gh_user, c in sorted(collabs.items()):
|
|
71
|
+
lines.append(f"[{gh_user}]")
|
|
72
|
+
labels = ", ".join(f'"{lbl}"' for lbl in c.labels)
|
|
73
|
+
lines.append(f"labels = [{labels}]")
|
|
74
|
+
if c.name:
|
|
75
|
+
lines.append(f'name = "{c.name}"')
|
|
76
|
+
if c.repo:
|
|
77
|
+
lines.append(f'repo = "{c.repo}"')
|
|
78
|
+
if c.account:
|
|
79
|
+
lines.append(f'account = "{c.account}"')
|
|
80
|
+
lines.append("")
|
|
81
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|