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.
@@ -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={
@@ -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())