cctally 1.82.0 → 1.82.1
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 +12 -0
- package/README.md +45 -74
- package/bin/_cctally_cache.py +51 -0
- package/bin/_cctally_dashboard_envelope.py +70 -0
- package/bin/_cctally_dashboard_share.py +10 -1
- package/bin/_cctally_dashboard_sources.py +209 -85
- package/bin/_cctally_tui.py +71 -10
- package/bin/_lib_conversation_query.py +45 -0
- package/bin/_lib_dashboard_sources.py +51 -1
- package/bin/_lib_readme_refresh.py +401 -0
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-BKM43pxK.js} +52 -52
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +2 -1
|
@@ -297,12 +297,41 @@ def reuse_coherent_source_state(
|
|
|
297
297
|
return prior if _coherent_provider(prior) and prior.data_version == data_version else None
|
|
298
298
|
|
|
299
299
|
|
|
300
|
+
def _hero_cycle_is_stale(state: SourceDashboardState) -> bool:
|
|
301
|
+
"""Whether a provider's hero is bounded by STALE quota evidence (#350 §3.4).
|
|
302
|
+
|
|
303
|
+
``hero.cycle_freshness`` is additive and OMITTED while the cycle is fresh, so
|
|
304
|
+
this is false for every provider and every generation that predates #350.
|
|
305
|
+
"""
|
|
306
|
+
if not isinstance(state.data, Mapping):
|
|
307
|
+
return False
|
|
308
|
+
hero = state.data.get("hero")
|
|
309
|
+
return isinstance(hero, Mapping) and hero.get("cycle_freshness") == "stale"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _stale_cycle_providers(
|
|
313
|
+
claude: SourceDashboardState,
|
|
314
|
+
codex: SourceDashboardState,
|
|
315
|
+
) -> tuple[str, ...]:
|
|
316
|
+
return tuple(
|
|
317
|
+
label for label, state in (("Claude", claude), ("Codex", codex))
|
|
318
|
+
if _hero_cycle_is_stale(state)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
300
322
|
def _combined_metrics(
|
|
301
323
|
claude: SourceDashboardState,
|
|
302
324
|
codex: SourceDashboardState,
|
|
303
325
|
) -> Mapping[str, object] | None:
|
|
304
326
|
if not (_coherent_provider(claude) and _coherent_provider(codex)):
|
|
305
327
|
return None
|
|
328
|
+
# #350 spec §3.5: a stale-cycle hero is NOT combinable. Retaining it would
|
|
329
|
+
# let a sum over stale evidence be published while `compose_all_state` marks
|
|
330
|
+
# the result fresh — and the All hero carries no Snapshot row or staleness
|
|
331
|
+
# marker at all, so the staleness would be silently undisclosed. The
|
|
332
|
+
# combined NUMBER therefore behaves exactly as it does today.
|
|
333
|
+
if _stale_cycle_providers(claude, codex):
|
|
334
|
+
return None
|
|
306
335
|
for state in (claude, codex):
|
|
307
336
|
hero_capability = state.capabilities.get("hero")
|
|
308
337
|
if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
|
|
@@ -366,7 +395,24 @@ def compose_all_state(
|
|
|
366
395
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
367
396
|
combined = _combined_metrics(claude, codex)
|
|
368
397
|
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
398
|
+
# #350 spec §3.5: "All behaves exactly as today" is true only of the combined
|
|
399
|
+
# NUMBER. Under §3.4 both providers stay coherent, so All now publishes
|
|
400
|
+
# partial/fresh with no provider warning to explain it — the status chip
|
|
401
|
+
# would fall through to a generic `degraded` and the All hero fallback would
|
|
402
|
+
# claim a provider is degraded while both provider envelopes say otherwise.
|
|
403
|
+
# This All-LOCAL warning states the real reason without touching either
|
|
404
|
+
# provider envelope. It is emitted only when the providers are otherwise
|
|
405
|
+
# coherent; an incoherent provider already publishes its own reason.
|
|
406
|
+
all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
|
|
369
407
|
if providers_coherent:
|
|
408
|
+
stale_cycle_providers = _stale_cycle_providers(claude, codex)
|
|
409
|
+
if stale_cycle_providers:
|
|
410
|
+
all_local_warnings = (SourceDashboardWarning(
|
|
411
|
+
"combined_totals_withheld",
|
|
412
|
+
f"{' and '.join(stale_cycle_providers)} quota evidence is stale, "
|
|
413
|
+
"so combined totals are withheld.",
|
|
414
|
+
"hero",
|
|
415
|
+
),)
|
|
370
416
|
availability: Availability = (
|
|
371
417
|
"partial"
|
|
372
418
|
if combined is None or "partial" in (claude.availability, codex.availability)
|
|
@@ -395,7 +441,11 @@ def compose_all_state(
|
|
|
395
441
|
source="all",
|
|
396
442
|
availability=availability,
|
|
397
443
|
freshness=freshness,
|
|
398
|
-
warnings
|
|
444
|
+
# All-LOCAL warnings lead: `warningForSource` on the client falls back to
|
|
445
|
+
# the FIRST warning, so a merely-partial provider warning (e.g.
|
|
446
|
+
# `codex_metadata_incomplete`) would otherwise pre-empt the chip label
|
|
447
|
+
# and hide the real reason combined totals are withheld.
|
|
448
|
+
warnings=tuple((*all_local_warnings, *claude.warnings, *codex.warnings)),
|
|
399
449
|
data_version=data_version,
|
|
400
450
|
last_success_at=last_success_at,
|
|
401
451
|
capabilities={
|
|
@@ -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())
|