cctally 1.82.0 → 1.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure kernel for the public README auto-refresh (issue #354).
|
|
3
|
+
|
|
4
|
+
Four stdlib-only responsibilities, each a pure function so the private
|
|
5
|
+
release driver, a public pytest, and a `--check` CLI can all share one
|
|
6
|
+
implementation:
|
|
7
|
+
|
|
8
|
+
1. ``extract_highlights`` — pull up to N lint-clean highlight bullets from a
|
|
9
|
+
specific ``## [X.Y.Z] - YYYY-MM-DD`` CHANGELOG section (exact version match,
|
|
10
|
+
Added > Changed > Fixed priority, multiline flattening, terminal issue-ref
|
|
11
|
+
stripping, fenced-code skipping).
|
|
12
|
+
2. ``normalize_highlight`` — deterministically rewrite a bullet so it satisfies
|
|
13
|
+
the copy lint by construction (em/en dashes with surrounding spaces become
|
|
14
|
+
``, ``; doubled separators collapse; leading/trailing separators trim).
|
|
15
|
+
3. ``splice_marker_block`` — replace only the region between the HTML-comment
|
|
16
|
+
markers, byte-preserving everything outside (CRLF included), failing closed
|
|
17
|
+
on missing/duplicated/malformed markers.
|
|
18
|
+
4. ``lint_copy`` — scan README prose (outside fenced code) for em dash, en
|
|
19
|
+
dash, a defined emoji set, and mid-line spaced-hyphen dash substitutes.
|
|
20
|
+
|
|
21
|
+
This module is PUBLIC (listed in `.mirror-allowlist` + `package.json` files[]),
|
|
22
|
+
because its pytest lands on the public mirror and a public test must not import
|
|
23
|
+
a private module. It contains nothing sensitive — Markdown parsing and linting.
|
|
24
|
+
|
|
25
|
+
CLI: ``python3 bin/_lib_readme_refresh.py --check README.md`` runs the copy lint
|
|
26
|
+
plus marker well-formedness and exits 0 (clean) / 1 (issues) / 2 (usage).
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
MARKER_BEGIN = "<!-- cctally:latest-stable:begin -->"
|
|
34
|
+
MARKER_END = "<!-- cctally:latest-stable:end -->"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChangelogSectionMissing(ValueError):
|
|
38
|
+
"""Raised when the requested version has no exact CHANGELOG section."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MarkerError(ValueError):
|
|
42
|
+
"""Raised when the latest-stable markers are missing/duplicated/malformed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------
|
|
46
|
+
# Fence handling (shared by extraction + lint)
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
# GFM code fences: a line whose content (after up to 3 leading spaces) begins
|
|
49
|
+
# with a run of >= 3 backticks or >= 3 tildes. The closing fence must use the
|
|
50
|
+
# same character, be at least as long as the opener, and (unlike the opener)
|
|
51
|
+
# carry no info string.
|
|
52
|
+
_FENCE_RE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})([^\n]*)$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _UnclosedFence(Exception):
|
|
56
|
+
def __init__(self, line_no: int, excerpt: str) -> None:
|
|
57
|
+
super().__init__(f"unclosed fence opened on line {line_no}")
|
|
58
|
+
self.line_no = line_no
|
|
59
|
+
self.excerpt = excerpt
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fence_marker(line: str):
|
|
63
|
+
"""Return ``(char, length, info)`` if *line* is a fence marker, else None."""
|
|
64
|
+
m = _FENCE_RE.match(line)
|
|
65
|
+
if not m:
|
|
66
|
+
return None
|
|
67
|
+
run = m.group(1)
|
|
68
|
+
return (run[0], len(run), m.group(2).strip())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_content_lines(text: str):
|
|
72
|
+
"""Yield ``(line_no, line)`` for lines OUTSIDE fenced code blocks.
|
|
73
|
+
|
|
74
|
+
Raises ``_UnclosedFence`` (at the end of the walk) if a fence is opened but
|
|
75
|
+
never closed. Operates on ``str.split("\\n")`` so a CRLF file keeps its
|
|
76
|
+
trailing ``\\r`` on each line — harmless for the char scans below.
|
|
77
|
+
"""
|
|
78
|
+
in_fence = False
|
|
79
|
+
fence_char = ""
|
|
80
|
+
fence_len = 0
|
|
81
|
+
fence_open_line = 0
|
|
82
|
+
fence_open_text = ""
|
|
83
|
+
for idx, line in enumerate(text.split("\n"), start=1):
|
|
84
|
+
marker = _fence_marker(line)
|
|
85
|
+
if in_fence:
|
|
86
|
+
if (
|
|
87
|
+
marker is not None
|
|
88
|
+
and marker[0] == fence_char
|
|
89
|
+
and marker[1] >= fence_len
|
|
90
|
+
and marker[2] == ""
|
|
91
|
+
):
|
|
92
|
+
in_fence = False
|
|
93
|
+
continue
|
|
94
|
+
if marker is not None:
|
|
95
|
+
in_fence = True
|
|
96
|
+
fence_char, fence_len = marker[0], marker[1]
|
|
97
|
+
fence_open_line = idx
|
|
98
|
+
fence_open_text = line.strip()
|
|
99
|
+
continue
|
|
100
|
+
yield idx, line
|
|
101
|
+
if in_fence:
|
|
102
|
+
raise _UnclosedFence(fence_open_line, fence_open_text)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
# Copy lint
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
_EM_DASH = "—"
|
|
109
|
+
_EN_DASH = "–"
|
|
110
|
+
_BULLET_PREFIX_RE = re.compile(r"^\s*[-*] ")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _has_emoji(s: str) -> bool:
|
|
114
|
+
for ch in s:
|
|
115
|
+
cp = ord(ch)
|
|
116
|
+
if (
|
|
117
|
+
0x1F000 <= cp <= 0x1FBFF
|
|
118
|
+
or 0x2600 <= cp <= 0x27BF
|
|
119
|
+
or 0x2B00 <= cp <= 0x2BFF
|
|
120
|
+
or cp == 0xFE0F
|
|
121
|
+
or cp == 0x200D
|
|
122
|
+
):
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def lint_copy(readme_text: str) -> "list[tuple[int, str, str]]":
|
|
128
|
+
"""Return copy-lint issues as ``(1-based line_no, code, excerpt)`` tuples.
|
|
129
|
+
|
|
130
|
+
Empty list means clean. Codes: ``em-dash``, ``en-dash``, ``emoji``,
|
|
131
|
+
``spaced-hyphen``. Scans only outside fenced code blocks; an unclosed fence
|
|
132
|
+
collapses to a single ``unclosed-fence`` issue.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
content = list(_iter_content_lines(readme_text))
|
|
136
|
+
except _UnclosedFence as uf:
|
|
137
|
+
return [(uf.line_no, "unclosed-fence", uf.excerpt)]
|
|
138
|
+
|
|
139
|
+
issues: "list[tuple[int, str, str]]" = []
|
|
140
|
+
for line_no, line in content:
|
|
141
|
+
excerpt = line.strip()
|
|
142
|
+
if _EM_DASH in line:
|
|
143
|
+
issues.append((line_no, "em-dash", excerpt))
|
|
144
|
+
if _EN_DASH in line:
|
|
145
|
+
issues.append((line_no, "en-dash", excerpt))
|
|
146
|
+
if _has_emoji(line):
|
|
147
|
+
issues.append((line_no, "emoji", excerpt))
|
|
148
|
+
# Line-leading "- "/"* " bullets are fine; only a REMAINING mid-line
|
|
149
|
+
# " - " is a dash substitute.
|
|
150
|
+
remainder = _BULLET_PREFIX_RE.sub("", line, count=1)
|
|
151
|
+
if " - " in remainder:
|
|
152
|
+
issues.append((line_no, "spaced-hyphen", excerpt))
|
|
153
|
+
return issues
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
# Highlight normalization
|
|
158
|
+
# --------------------------------------------------------------------------
|
|
159
|
+
def normalize_highlight(bullet: str) -> str:
|
|
160
|
+
"""Rewrite a bullet so it satisfies the copy lint deterministically."""
|
|
161
|
+
b = re.sub(r"\s*[—–]\s*", ", ", bullet)
|
|
162
|
+
b = re.sub(r"(, )+", ", ", b)
|
|
163
|
+
b = b.strip()
|
|
164
|
+
b = b.strip(", ")
|
|
165
|
+
return b.strip()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
# CHANGELOG extraction
|
|
170
|
+
# --------------------------------------------------------------------------
|
|
171
|
+
_ISSUE_REF_RE = re.compile(r"\s*\(#\d+[^)]*\)\s*$")
|
|
172
|
+
_SUBSECTIONS = (("### Added", "added"), ("### Changed", "changed"), ("### Fixed", "fixed"))
|
|
173
|
+
_SUBSECTION_KEYS = {name: key for name, key in _SUBSECTIONS}
|
|
174
|
+
_BULLET_RE = re.compile(r"^[-*] ")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _section_heading_re(version: str) -> "re.Pattern[str]":
|
|
178
|
+
return re.compile(
|
|
179
|
+
r"^## \[" + re.escape(version) + r"\] - (\d{4}-\d{2}-\d{2})\s*$"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _section_body_lines(changelog_text: str, version: str) -> "list[str]":
|
|
184
|
+
lines = changelog_text.split("\n")
|
|
185
|
+
heading_re = _section_heading_re(version)
|
|
186
|
+
start = None
|
|
187
|
+
for i, line in enumerate(lines):
|
|
188
|
+
if heading_re.match(line):
|
|
189
|
+
start = i
|
|
190
|
+
break
|
|
191
|
+
if start is None:
|
|
192
|
+
raise ChangelogSectionMissing(version)
|
|
193
|
+
end = len(lines)
|
|
194
|
+
for j in range(start + 1, len(lines)):
|
|
195
|
+
if lines[j].startswith("## "):
|
|
196
|
+
end = j
|
|
197
|
+
break
|
|
198
|
+
return lines[start + 1:end]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def changelog_release_date(changelog_text: str, version: str) -> str:
|
|
202
|
+
"""Return the ``YYYY-MM-DD`` release date for *version*'s section."""
|
|
203
|
+
heading_re = _section_heading_re(version)
|
|
204
|
+
for line in changelog_text.split("\n"):
|
|
205
|
+
m = heading_re.match(line)
|
|
206
|
+
if m:
|
|
207
|
+
return m.group(1)
|
|
208
|
+
raise ChangelogSectionMissing(version)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _collect_raw_bullets(body_lines: "list[str]") -> "dict[str, list[str]]":
|
|
212
|
+
"""Group raw (un-normalized) bullets by subsection, flattening wraps.
|
|
213
|
+
|
|
214
|
+
Raises ``ValueError`` if a fence is opened inside the section and never
|
|
215
|
+
closed before the section ends.
|
|
216
|
+
"""
|
|
217
|
+
buckets: "dict[str, list[str]]" = {"added": [], "changed": [], "fixed": []}
|
|
218
|
+
subsection: "str | None" = None
|
|
219
|
+
current: "str | None" = None
|
|
220
|
+
in_fence = False
|
|
221
|
+
fence_char = ""
|
|
222
|
+
fence_len = 0
|
|
223
|
+
|
|
224
|
+
def finalize() -> None:
|
|
225
|
+
nonlocal current
|
|
226
|
+
if current is not None and subsection in buckets:
|
|
227
|
+
buckets[subsection].append(current.rstrip())
|
|
228
|
+
current = None
|
|
229
|
+
|
|
230
|
+
for line in body_lines:
|
|
231
|
+
marker = _fence_marker(line)
|
|
232
|
+
if in_fence:
|
|
233
|
+
if (
|
|
234
|
+
marker is not None
|
|
235
|
+
and marker[0] == fence_char
|
|
236
|
+
and marker[1] >= fence_len
|
|
237
|
+
and marker[2] == ""
|
|
238
|
+
):
|
|
239
|
+
in_fence = False
|
|
240
|
+
continue
|
|
241
|
+
if marker is not None:
|
|
242
|
+
finalize()
|
|
243
|
+
in_fence = True
|
|
244
|
+
fence_char, fence_len = marker[0], marker[1]
|
|
245
|
+
continue
|
|
246
|
+
stripped = line.rstrip()
|
|
247
|
+
if stripped.startswith("## "):
|
|
248
|
+
finalize()
|
|
249
|
+
subsection = None
|
|
250
|
+
continue
|
|
251
|
+
if stripped.startswith("### "):
|
|
252
|
+
finalize()
|
|
253
|
+
subsection = _SUBSECTION_KEYS.get(stripped)
|
|
254
|
+
continue
|
|
255
|
+
if _BULLET_RE.match(line):
|
|
256
|
+
finalize()
|
|
257
|
+
current = line[2:].strip() if subsection in buckets else None
|
|
258
|
+
continue
|
|
259
|
+
if not stripped:
|
|
260
|
+
finalize()
|
|
261
|
+
continue
|
|
262
|
+
# Continuation of the current bullet.
|
|
263
|
+
if current is not None:
|
|
264
|
+
current = current + " " + line.strip()
|
|
265
|
+
if in_fence:
|
|
266
|
+
raise ValueError("unclosed code fence inside CHANGELOG section")
|
|
267
|
+
finalize()
|
|
268
|
+
return buckets
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def extract_highlights(
|
|
272
|
+
changelog_text: str, version: str, *, limit: int = 3
|
|
273
|
+
) -> "list[str]":
|
|
274
|
+
"""Return up to ``limit`` normalized, lint-clean highlight bullets.
|
|
275
|
+
|
|
276
|
+
Exact heading match (never prefix), Added > Changed > Fixed priority,
|
|
277
|
+
multiline flattening, one terminal issue-ref stripped, then normalized.
|
|
278
|
+
A bullet that still fails the copy lint after normalization is dropped and
|
|
279
|
+
the next candidate is taken. Raises ``ChangelogSectionMissing`` if the
|
|
280
|
+
version section is absent.
|
|
281
|
+
"""
|
|
282
|
+
body = _section_body_lines(changelog_text, version)
|
|
283
|
+
buckets = _collect_raw_bullets(body)
|
|
284
|
+
candidates = buckets["added"] + buckets["changed"] + buckets["fixed"]
|
|
285
|
+
kept: "list[str]" = []
|
|
286
|
+
for raw in candidates:
|
|
287
|
+
if len(kept) >= limit:
|
|
288
|
+
break
|
|
289
|
+
stripped = _ISSUE_REF_RE.sub("", raw)
|
|
290
|
+
normalized = normalize_highlight(stripped)
|
|
291
|
+
if not normalized:
|
|
292
|
+
continue
|
|
293
|
+
if lint_copy("- " + normalized):
|
|
294
|
+
continue # drop-bullet fallback
|
|
295
|
+
kept.append(normalized)
|
|
296
|
+
return kept
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def render_latest_stable_block(
|
|
300
|
+
version: str, date: str, bullets: "list[str]"
|
|
301
|
+
) -> str:
|
|
302
|
+
"""Render the auto-maintained latest-stable block (no trailing newline
|
|
303
|
+
after the last bullet; version+date only when *bullets* is empty)."""
|
|
304
|
+
base = f"**Latest stable: v{version}** ({date})\n"
|
|
305
|
+
if bullets:
|
|
306
|
+
return base + "\n" + "\n".join(f"- {b}" for b in bullets)
|
|
307
|
+
return base
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# --------------------------------------------------------------------------
|
|
311
|
+
# Marker splice
|
|
312
|
+
# --------------------------------------------------------------------------
|
|
313
|
+
def _marker_alone_on_line(text: str, idx: int, marker: str) -> bool:
|
|
314
|
+
before = text[:idx]
|
|
315
|
+
if before and not before.endswith("\n"):
|
|
316
|
+
return False
|
|
317
|
+
tail_start = idx + len(marker)
|
|
318
|
+
nl = text.find("\n", tail_start)
|
|
319
|
+
tail = text[tail_start:] if nl == -1 else text[tail_start:nl]
|
|
320
|
+
return tail.strip() == ""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _find_marker_pair(text: str) -> "tuple[int, int]":
|
|
324
|
+
if text.count(MARKER_BEGIN) != 1:
|
|
325
|
+
raise MarkerError(
|
|
326
|
+
f"expected exactly one {MARKER_BEGIN!r}, found "
|
|
327
|
+
f"{text.count(MARKER_BEGIN)}"
|
|
328
|
+
)
|
|
329
|
+
if text.count(MARKER_END) != 1:
|
|
330
|
+
raise MarkerError(
|
|
331
|
+
f"expected exactly one {MARKER_END!r}, found {text.count(MARKER_END)}"
|
|
332
|
+
)
|
|
333
|
+
begin_idx = text.index(MARKER_BEGIN)
|
|
334
|
+
end_idx = text.index(MARKER_END)
|
|
335
|
+
if begin_idx >= end_idx:
|
|
336
|
+
raise MarkerError("begin marker does not precede end marker")
|
|
337
|
+
if not _marker_alone_on_line(text, begin_idx, MARKER_BEGIN):
|
|
338
|
+
raise MarkerError("begin marker is not alone on its line")
|
|
339
|
+
if not _marker_alone_on_line(text, end_idx, MARKER_END):
|
|
340
|
+
raise MarkerError("end marker is not alone on its line")
|
|
341
|
+
return begin_idx, end_idx
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def splice_marker_block(readme_text: str, block: str) -> str:
|
|
345
|
+
"""Replace the interior between the markers with ``\\n`` + block + ``\\n``.
|
|
346
|
+
|
|
347
|
+
Every byte outside the region is preserved (CRLF included). Fails closed on
|
|
348
|
+
missing, duplicated, or malformed markers.
|
|
349
|
+
"""
|
|
350
|
+
begin_idx, end_idx = _find_marker_pair(readme_text)
|
|
351
|
+
region_start = begin_idx + len(MARKER_BEGIN)
|
|
352
|
+
return (
|
|
353
|
+
readme_text[:region_start]
|
|
354
|
+
+ "\n"
|
|
355
|
+
+ block
|
|
356
|
+
+ "\n"
|
|
357
|
+
+ readme_text[end_idx:]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --------------------------------------------------------------------------
|
|
362
|
+
# CLI (--check)
|
|
363
|
+
# --------------------------------------------------------------------------
|
|
364
|
+
def _marker_wellformed_issues(text: str) -> "list[tuple[int, str, str]]":
|
|
365
|
+
try:
|
|
366
|
+
_find_marker_pair(text)
|
|
367
|
+
except MarkerError as exc:
|
|
368
|
+
line_no = 1
|
|
369
|
+
if MARKER_BEGIN in text:
|
|
370
|
+
line_no = text[: text.index(MARKER_BEGIN)].count("\n") + 1
|
|
371
|
+
return [(line_no, "markers", str(exc))]
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _check(path: str) -> int:
|
|
376
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
377
|
+
text = fh.read()
|
|
378
|
+
issues = lint_copy(text) + _marker_wellformed_issues(text)
|
|
379
|
+
for line_no, code, excerpt in issues:
|
|
380
|
+
print(f"{path}:{line_no}: {code}: {excerpt}", file=sys.stderr)
|
|
381
|
+
return 1 if issues else 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
385
|
+
import argparse
|
|
386
|
+
|
|
387
|
+
parser = argparse.ArgumentParser(
|
|
388
|
+
description="Copy-lint + marker well-formedness check for README.md.",
|
|
389
|
+
)
|
|
390
|
+
parser.add_argument(
|
|
391
|
+
"--check",
|
|
392
|
+
metavar="FILE",
|
|
393
|
+
required=True,
|
|
394
|
+
help="Markdown file to lint (copy rules + latest-stable markers).",
|
|
395
|
+
)
|
|
396
|
+
args = parser.parse_args(argv)
|
|
397
|
+
return _check(args.check)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
sys.exit(main())
|