cctally 1.81.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.
@@ -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=tuple((*claude.warnings, *codex.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={
@@ -53,7 +53,7 @@ def _chip_for_model(name: str) -> str:
53
53
  # Date the embedded pricing snapshots below were last verified against
54
54
  # vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
55
55
  # is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
56
- PRICING_SNAPSHOT_DATE = "2026-07-19"
56
+ PRICING_SNAPSHOT_DATE = "2026-07-24"
57
57
  PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
58
58
 
59
59
  # Canonical machine-readable pricing source (Claude values + Codex values).
@@ -99,7 +99,7 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
99
99
 
100
100
  # Anthropic API pricing snapshot:
101
101
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
102
- # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
102
+ # - Captured: 2026-07-24 (see PRICING_SNAPSHOT_DATE)
103
103
  # - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
104
104
  # update in PRs touching this table.
105
105
  # 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
@@ -114,6 +114,22 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
114
114
  # value_drift on all four cost fields. Suppressed via PRICING_DRIFT_ALLOWLIST
115
115
  # above (the non-vacuity guard forces removal once LiteLLM reverts to the
116
116
  # standard rate after 2026-08-31).
117
+ # 2026-07-24: added claude-opus-5 ($5/$25 per MTok — identical to Opus
118
+ # 4.5/4.6/4.7/4.8; $6.25 5-minute cache write and $0.50 cache read at the
119
+ # standard 1.25x/0.1x multipliers). 1M context at standard pricing, so NO
120
+ # above-200k tier. Dateless pinned id with no dated twin (matches Anthropic's
121
+ # published ID/alias table). LiteLLM has no opus-5 entry yet, so this table is
122
+ # simply ahead of it — `ahead_of_litellm` is never a drift finding, so no
123
+ # PRICING_DRIFT_ALLOWLIST entry is needed.
124
+ #
125
+ # Known gap — Claude *fast mode* is not modelled. Anthropic bills fast mode at a
126
+ # premium ($10/$50 per MTok on Opus 5 and Opus 4.8; $30/$150 on Opus 4.7), and
127
+ # Claude Code records the tier per assistant entry at `message.usage.speed`
128
+ # ("standard" | "fast"), so it IS derivable from the JSONL we already ingest —
129
+ # but this table is one rate per model and `_calculate_entry_cost` ignores speed,
130
+ # so a fast-mode entry is priced at the standard rate (2x undercount on Opus 5).
131
+ # The Codex side already carries the shape for this
132
+ # (`_calculate_codex_entry_cost(..., speed=...)` + its fast-tier multiplier).
117
133
  CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
118
134
  "claude-3-5-haiku-20241022": {
119
135
  "input_cost_per_token": 8e-07,
@@ -269,6 +285,12 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
269
285
  "cache_creation_input_token_cost": 6.25e-06,
270
286
  "cache_read_input_token_cost": 5e-07,
271
287
  },
288
+ "claude-opus-5": {
289
+ "input_cost_per_token": 5e-06,
290
+ "output_cost_per_token": 2.5e-05,
291
+ "cache_creation_input_token_cost": 6.25e-06,
292
+ "cache_read_input_token_cost": 5e-07,
293
+ },
272
294
  "claude-sonnet-4-20250514": {
273
295
  "input_cost_per_token": 3e-06,
274
296
  "output_cost_per_token": 1.5e-05,
@@ -321,7 +343,9 @@ _unknown_model_warnings: set[str] = set()
321
343
  #
322
344
  # Codex (OpenAI) API pricing snapshot:
323
345
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
324
- # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
346
+ # - Captured: 2026-07-19 the last full Codex sync. PRICING_SNAPSHOT_DATE moved
347
+ # ahead to 2026-07-24 for the Claude-side opus-5 sync; these Codex values were
348
+ # NOT re-verified that day.
325
349
  # - As of the 2026-07-19 sync this carries every openai-provider
326
350
  # gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
327
351
  # nothing missing. Models absent from this table still fall back to `gpt-5`
@@ -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())