wicked-garden 12.34.0 → 12.35.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.
@@ -2,7 +2,7 @@
2
2
  "_comment": "Issue #328: Component declarations for wicked-garden plugin. Claude Code plugin.json does not yet support inline component declarations beyond path overrides. This manifest documents all components for tooling, validation, and future schema support. When plugin.json gains a 'components' field, merge this data into plugin.json. The plugin is skills-only: former commands/ became actions of consolidated per-domain skills, and former agents/ became standalone context:fork worker skills (enumerated under fork_skills). Regenerate derived blocks with scripts/ci/sync_components.py.",
3
3
  "domains": ["agentic", "crew", "data", "engineering", "jam", "mem", "persona", "platform", "product", "qe", "search", "smaht"],
4
4
  "summary": {
5
- "skills": 142,
5
+ "skills": 143,
6
6
  "fork_skills": 78,
7
7
  "hooks": 13,
8
8
  "specialists": 7
@@ -25,6 +25,7 @@
25
25
  "domain-coverage": ["domain-coverage"],
26
26
  "domain-extractor": ["domain-extractor"],
27
27
  "domain-modeler": ["domain-modeler"],
28
+ "draft": ["draft"],
28
29
  "engineering": ["architecture", "backend", "debugging", "docs", "engineering", "frontend", "integration", "large-scale-migration", "patch", "system-design", "unit-test-quality"],
29
30
  "engineering-api-documentarian": ["engineering-api-documentarian"],
30
31
  "engineering-conformance-reviewer": ["engineering-conformance-reviewer"],
@@ -1 +1 @@
1
- {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.34.0"}], "version": "12.34.0"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.35.0"}], "version": "12.35.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.34.0",
3
+ "version": "12.35.0",
4
4
  "description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 the wicked-estate code graph + injected bus/dispatch/capability/archetype edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory + knowledge (the mem domain over wicked-estate), real multi-model second opinions (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (the in-catalog qe domain). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault, a self-contained infra peer wicked-garden installs directly; wicked-bus is an opt-in layer \u2014 install what you'll use, the toolkit works without the rest.",
5
5
  "wicked_vault_version": "^0.5.0",
6
6
  "wicked_ledger_version": "^0.4.0",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.35.0] — 2026-09-13
6
+
7
+ ### Added
8
+ - **`wicked-garden-draft` — the quality floor for document deliverables: contrast ≥ 4.5:1, an honoured page budget, cited claims and no placeholders (F-RECON-007/008/009).** The 2026-09 recon ran a governed brochure draft end to end through the studio and the deliverable that landed was unreadable, over-long and partly invented: the hero fact strip, footer requirements and KPI label rendered `rgba(255,255,255,0.14)`-class text on a `#1a1a26` card (≈1.5:1 — invisible in the PNG and the PDF; F-RECON-007), a "two pages" brief printed as **four** A4 pages because two `.page` wrappers each overflowed their sheet (F-RECON-008), and the copy carried an invented "live instance" URL claim, a command-center mock whose figures were called illustrative only inside an HTML comment, and a visible `[PLACEHOLDER: Customer validation]` box (F-RECON-009) — while every gate auto-allowed and the run's deliverable floor proved only that the file existed. Neither garden nor crew carried a document-drafting skill (crew's `interactive-draft` phases have `skill_ref: null`; the contract was inlined prose adapted from wicked-interactive's assist skill), so nothing re-derived any of it. **New top-level skill `skills/draft/`** (user-invocable; `refs/design-floor.md`, `refs/claims.md`, `refs/self-check.md`): design guidance for the three floors — READABLE (every text/background pair ≥ 4.5:1, 3:1 for large text, judged on the COMPOSITED colours; print text ≥ 7pt — thin 6–7pt type never reaches full pixel coverage, which is why the 4.9:1 fact strip still read as grey), SIZED TO THE BRIEF (the RENDERED page count meets the budget the brief names, default = exactly as asked, any format cap disclosed, wrappers are not pages), GROUNDED (every number/URL cites a repo path via `data-source="path:line"` plus a visible Sources strip, mocks carry `data-illustrative` AND a visible label, no placeholder ever ships — gaps go to the notes) — **and one mandatory self-check** the author runs on the saved file before "done", with a `PASS` / `FAIL` / `UNVERIFIED` verdict that is pasted into the reply, never paraphrased. **Runtime (`scripts/draft/`, stdlib-only, reachable as `wicked-garden run scripts/draft/…`)**: `contrast_check.py` cascades the document's own CSS (specificity, source order, inline, `!important`, `var()` with inheritance — including `:root` tokens when the instrumented deliverable has no `<html>` element — `@media print|screen`, alpha compositing over the real background, `opacity`, every gradient stop) and judges every text-bearing element; `page_count.py` reads the rendered count from an exported PDF (page-tree `/Count`, object streams inflated) or a headless-Chrome print (`--render`; `$WICKED_CHROME`), and otherwise reports the structural estimate as **UNVERIFIED (exit 3)** — never a pass; `claims_scan.py` scans the VISIBLE text for placeholders, uncited numbers, uncited/unsourced URLs (bare domains included), hidden or missing mock labels and, with `--repo`, dangling `data-source` paths and URLs no source mentions; `self_check.py` runs all three and exits 0/1/3. Against the recon's landed v1 (kept as `tests/draft/fixtures/recon-brochure-v1.html`) the checks reproduce the findings exactly — footer `.cta-reqs` and `.mockup-kpi-label` at 1.53:1, the 6.8pt fact strip under the size floor, the accent violet at 3.7:1 on cards, 4 PDF pages vs budget 2, the placeholder, the `3 active · 1 needs you` hidden-label mock, the uncited 2 s / 3 s latencies — and pass the same document once the tokens are lifted and the claims cited (`tests/draft/`, 60+ tests: colour maths, cascade, media modes, PDF page trees incl. object streams, a fake-Chrome render path, the citation forms, CLI exit codes). Cross-CLI per the 12.33 convention (skill-relative refs, the exact `## Runtime` block, no Claude-only mechanics; `tests/portability_rules.garden.json` gains quiet/trip corpus entries for the new launcher lines). The core help overview, `components.json` and the site's honest counts move to 143 skills (`draft` folded into the product & UX group). **Hardened after the independent review (same wave):** shrink-to-fit is judged, not rewarded — `zoom`, `transform: scale()`/`matrix()` and `scale:` on any ancestor multiply into the EFFECTIVE text size the 7pt floor is applied to (the reviewer's `body{zoom:.62}` on a corrected brochure went from a full `PASS` at ≈4.3pt to `FAIL`), an unevaluable transform makes the size UNVERIFIED, and the skill text forbids scaling a page to meet a budget; unsupported colour functions (`oklch()`, `lab()`, `color-mix()`, `light-dark()`…) and image-only backgrounds are reported as `[unknown]` UNVERIFIED pairs (exit 3) instead of silently inheriting or being judged "on white"; `@media not print|screen` is evaluated correctly (it was inverted); inline `!important` sits at the top of the cascade; `--mode both` reports a pair once (`print+screen`); colour-declaring rules with selectors the matcher skips (`:nth-child`, `:is`…) leave a note instead of vanishing; the CLIs never crash on a cp1252 console (Windows piped stdout — `errors="replace"`); `page_count.py` fails (exit 1) before it reports unverified when even the estimate exceeds the budget, ignores a forced break on the last element, refuses a `--pdf` path that does not exist (no silent render), and prints offline (`--host-resolver-rules=MAP * ~NOTFOUND`); `claims_scan.py` no longer flags prose dates, numbered headings, `Step N`/`Page N of M` and lone badge numerals; the skill spells out the two "done" states (`PASS`, or `UNVERIFIED` with a disclosure naming which check, why on this seat, and what a reviewer verifies — the check prints the `DISCLOSE` lines) and that `FAIL` is never done; `refs/design-floor.md` ratios recomputed with the module (`#94a3b8` on white is 2.6:1, not 3.2). **Follow-ups (other repos):** wicked-crew's `interactive-draft` / `-edit` / `-chat` phases should set `skill_ref: 'wicked-garden-draft'` and pass the budget/snapshot to the self-check, and the deliverable floor could run `self_check.py` as a deterministic tool phase; wicked-interactive's assist skill Step 5 can point at the same floor.
9
+
5
10
  ## [12.34.0] — 2026-09-11
6
11
 
7
12
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.34.0",
3
+ "version": "12.35.0",
4
4
  "type": "module",
5
5
  "description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
6
6
  "keywords": [
@@ -0,0 +1,13 @@
1
+ """wicked-garden `draft` runtime — the self-checks behind the `wicked-garden-draft` skill.
2
+
3
+ Stdlib-only. Every module is both importable (pure functions the tests drive) and a CLI the
4
+ skill invokes through the launcher (`wicked-garden run scripts/draft/<x>.py …`):
5
+
6
+ * ``contrast_check`` — WCAG contrast (and a minimum print size) for every text-bearing element,
7
+ resolved from the document's own CSS (custom properties, alpha, media).
8
+ * ``page_count`` — rendered page count of a PDF (or a Chrome print of the HTML) against the
9
+ brief's page budget; a structural estimate when nothing can render.
10
+ * ``claims_scan`` — placeholders, uncited numbers, hidden mock labels, dangling
11
+ ``data-source`` paths and unsourced URLs in the visible text.
12
+ * ``self_check`` — the one command the worker runs before "done": all three, one verdict.
13
+ """
@@ -0,0 +1,188 @@
1
+ """A tiny DOM for the draft self-checks — stdlib ``html.parser`` only.
2
+
3
+ The checks need three things from a self-contained HTML deliverable: the element tree (with
4
+ attributes), the ``<style>`` text in document order, and "which elements carry visible text".
5
+ That is all this module does; it is deliberately not a browser. Void elements are closed
6
+ immediately; unbalanced closing tags are tolerated (the nearest open ancestor with that tag is
7
+ closed), so a slightly malformed draft still yields a usable tree instead of an exception.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from html.parser import HTMLParser
15
+ from typing import Iterator
16
+
17
+ VOID_TAGS = frozenset(
18
+ "area base br col embed hr img input link meta param source track wbr".split()
19
+ )
20
+ # Elements whose text is never rendered to the reader.
21
+ NON_RENDERED_TAGS = frozenset("script style template noscript title head meta link".split())
22
+ _WS = re.compile(r"\s+")
23
+
24
+
25
+ @dataclass(eq=False) # identity semantics — a structural __eq__ would recurse through children
26
+ class Node:
27
+ tag: str # "" for the document root, "#text" for text, "#comment" for comments
28
+ attrs: dict[str, str] = field(default_factory=dict)
29
+ children: list["Node"] = field(default_factory=list)
30
+ parent: "Node | None" = None
31
+ text: str = "" # #text / #comment payload
32
+
33
+ # ── tree helpers ────────────────────────────────────────────────────────────────────────
34
+ def ancestors(self) -> Iterator["Node"]:
35
+ node = self.parent
36
+ while node is not None and node.tag != "":
37
+ yield node
38
+ node = node.parent
39
+
40
+ def element_children(self) -> list["Node"]:
41
+ return [c for c in self.children if not c.tag.startswith("#")]
42
+
43
+ def classes(self) -> list[str]:
44
+ return self.attrs.get("class", "").split()
45
+
46
+ def direct_text(self) -> str:
47
+ """The element's own text (not descendants'), whitespace-collapsed."""
48
+ parts = [c.text for c in self.children if c.tag == "#text"]
49
+ return _WS.sub(" ", "".join(parts)).strip()
50
+
51
+ def all_text(self) -> str:
52
+ """The visible text of this element and its descendants, whitespace-collapsed."""
53
+ out: list[str] = []
54
+ for node in self.walk():
55
+ if node.tag == "#text":
56
+ out.append(node.text)
57
+ return _WS.sub(" ", " ".join(out)).strip()
58
+
59
+ def walk(self) -> Iterator["Node"]:
60
+ yield self
61
+ for child in self.children:
62
+ yield from child.walk()
63
+
64
+ def path(self) -> str:
65
+ """A readable locator: ``section.page.page-1 > div.fact-strip > span``."""
66
+ chain: list[str] = []
67
+ node: Node | None = self
68
+ while node is not None and node.tag != "":
69
+ if node.tag.startswith("#"):
70
+ node = node.parent
71
+ continue
72
+ label = node.tag
73
+ if "id" in node.attrs and node.attrs["id"]:
74
+ label += "#" + node.attrs["id"]
75
+ label += "".join("." + c for c in node.classes()[:3])
76
+ chain.append(label)
77
+ node = node.parent
78
+ return " > ".join(reversed(chain))
79
+
80
+ def preceding_comments(self) -> list[str]:
81
+ """Comments immediately before this element among its siblings (whitespace between
82
+ is fine) — where a draft tends to hide an "illustrative" label."""
83
+ if self.parent is None:
84
+ return []
85
+ out: list[str] = []
86
+ siblings = self.parent.children
87
+ idx = siblings.index(self)
88
+ for sib in reversed(siblings[:idx]):
89
+ if sib.tag == "#comment":
90
+ out.append(sib.text)
91
+ elif sib.tag == "#text" and not sib.text.strip():
92
+ continue
93
+ else:
94
+ break
95
+ return out
96
+
97
+
98
+ class _Builder(HTMLParser):
99
+ def __init__(self) -> None:
100
+ super().__init__(convert_charrefs=True)
101
+ self.root = Node(tag="")
102
+ self.cursor = self.root
103
+ self.styles: list[str] = []
104
+ self._in_style = False
105
+
106
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
107
+ tag = tag.lower()
108
+ node = Node(tag=tag, attrs={k.lower(): (v or "") for k, v in attrs}, parent=self.cursor)
109
+ self.cursor.children.append(node)
110
+ if tag in VOID_TAGS:
111
+ return
112
+ self.cursor = node
113
+ self._in_style = tag == "style"
114
+
115
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
116
+ tag = tag.lower()
117
+ node = Node(tag=tag, attrs={k.lower(): (v or "") for k, v in attrs}, parent=self.cursor)
118
+ self.cursor.children.append(node)
119
+
120
+ def handle_endtag(self, tag: str) -> None:
121
+ tag = tag.lower()
122
+ if tag in VOID_TAGS:
123
+ return
124
+ node: Node | None = self.cursor
125
+ while node is not None and node.tag != "":
126
+ if node.tag == tag:
127
+ self.cursor = node.parent or self.root
128
+ self._in_style = False
129
+ return
130
+ node = node.parent
131
+ # stray closing tag — ignore
132
+
133
+ def handle_data(self, data: str) -> None:
134
+ if self.cursor.tag == "style":
135
+ self.styles.append(data)
136
+ self.cursor.children.append(Node(tag="#text", text=data, parent=self.cursor))
137
+
138
+ def handle_comment(self, data: str) -> None:
139
+ self.cursor.children.append(Node(tag="#comment", text=data, parent=self.cursor))
140
+
141
+
142
+ @dataclass
143
+ class Document:
144
+ root: Node
145
+ styles: list[str]
146
+
147
+ def elements(self) -> Iterator[Node]:
148
+ for node in self.root.walk():
149
+ if node.tag and not node.tag.startswith("#"):
150
+ yield node
151
+
152
+ def find_all(self, tag: str) -> list[Node]:
153
+ return [n for n in self.elements() if n.tag == tag]
154
+
155
+ def inline_style_text(self) -> str:
156
+ return "\n".join(self.styles)
157
+
158
+
159
+ def parse(html: str) -> Document:
160
+ builder = _Builder()
161
+ builder.feed(html)
162
+ builder.close()
163
+ return Document(root=builder.root, styles=builder.styles)
164
+
165
+
166
+ def is_rendered(node: Node) -> bool:
167
+ """False when the element or an ancestor is never shown to the reader (head/script/style,
168
+ the ``hidden`` attribute, ``aria-hidden`` is NOT enough — sighted readers still see it)."""
169
+ chain = [node, *node.ancestors()]
170
+ for n in chain:
171
+ if n.tag in NON_RENDERED_TAGS:
172
+ return False
173
+ if "hidden" in n.attrs:
174
+ return False
175
+ return True
176
+
177
+
178
+ def text_elements(doc: Document) -> Iterator[Node]:
179
+ """Rendered elements carrying their OWN non-blank text (the unit a contrast or claims check
180
+ judges — a ``<span>`` inside a ``<p>`` is its own element with its own colour)."""
181
+ for node in doc.elements():
182
+ if node.tag == "br":
183
+ continue
184
+ if not node.direct_text():
185
+ continue
186
+ if not is_rendered(node):
187
+ continue
188
+ yield node
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+ """claims_scan.py — claims discipline for a document deliverable: no placeholders, every number
3
+ and URL traceable to a source, every mock visibly labelled.
4
+
5
+ F-RECON-009: a governed brochure shipped a visible ``[PLACEHOLDER: Customer validation]`` box, a
6
+ "live instance" URL claim the sources contradict, and a command-center mock whose KPIs were called
7
+ "illustrative" only inside an HTML comment. Grounding prose in the prompt did not prevent any of
8
+ it; a scan of the *visible text* before "done" would have caught all three.
9
+
10
+ What counts as CITED (the convention the `wicked-garden-draft` skill asks the author to follow):
11
+ * the element — or an ancestor — carries ``data-source="<repo-relative path>[:line]"``
12
+ (several sources: separate with spaces or commas);
13
+ * or the same text block names a repo path inline, e.g. ``(README.md:87)``;
14
+ * or the block carries a footnote marker (``[3]``, ``<sup>``) AND the document has a sources
15
+ block (``data-sources``, or a heading/element whose text starts with "Sources") that lists
16
+ repo paths.
17
+ A MOCK/ILLUSTRATION is fine when its container carries ``data-illustrative`` AND a reader can see
18
+ the label (the word "illustrative", "example", "mock", "sample" or "hypothetical" inside that
19
+ container). A label that lives only in an HTML comment is the F-RECON-009 defect and is reported.
20
+
21
+ Findings (all fail the floor): ``placeholder`` · ``uncited-number`` · ``mock-label-hidden`` ·
22
+ ``mock-unlabelled`` · ``dangling-source`` (``data-source`` path missing under ``--repo``) ·
23
+ ``unsourced-url`` (a URL in the visible text that no file under ``--repo`` mentions) ·
24
+ ``uncited-url`` (a URL with no ``--repo`` to check against and no ``data-source``).
25
+ Exit: 0 clean, 1 findings, 2 usage / unreadable input. Stdlib only.
26
+
27
+ CLI:
28
+ claims_scan.py <file.html> [--repo <dir> …] [--json]
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ if __package__ in (None, ""):
42
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
43
+ from draft._dom import Document, Node, is_rendered, parse # noqa: E402
44
+
45
+ PLACEHOLDER_RE = re.compile(
46
+ r"\[\s*PLACEHOLDER\b|\bPLACEHOLDER\s*:|\bTODO\b|\bTBD\b|\bFIXME\b|\bTK\b(?![\w-])|"
47
+ r"\blorem\s+ipsum\b|\bLorem\b|\{\{[^}]*\}\}|\[\s*INSERT\b|<\s*insert\b|\[\s*(?:add|your|company|client)\s+[^\]]{0,40}(?:here|name)\s*\]",
48
+ re.IGNORECASE)
49
+ # a repo path: at least one slash or a known doc/code extension, optional :line
50
+ PATH_RE = re.compile(
51
+ r"(?<![\w/@:.-])((?:[\w.-]+/)*[\w.-]+\.(?:md|mdx|txt|rst|adoc|json|ya?ml|toml|ts|tsx|js|mjs|cjs|py|rs|go|java|kt|cs|rb|php|sh|html|css|sql|proto|csv|ipynb|lock)(?::\d+(?:-\d+)?)?)(?![\w/])",
52
+ re.IGNORECASE)
53
+ # a URL with a scheme, or a bare domain on a common TLD (`ws.wickedagile.com`) — a file name's
54
+ # extension (README.md, server.ts) is not a TLD in this list, so paths never match.
55
+ URL_RE = re.compile(
56
+ r"https?://[^\s<>\"')\]]+|"
57
+ r"(?<![\w@/.-])(?:[a-z0-9-]+\.)+(?:com|org|net|io|dev|ai|app|co|cloud|sh|tech|info|biz|eu|uk|de|fr|us|ca|au)\b"
58
+ r"(?:/[^\s<>\"')\]]*)?",
59
+ re.IGNORECASE)
60
+ NUMBER_RE = re.compile(r"(?<![\w.,:/#-])(\d+(?:[.,]\d+)*)(?![\w/:-])")
61
+ FOOTNOTE_RE = re.compile(r"\[\d{1,3}\]|[†‡§¶]|\^\d{1,3}")
62
+ LABEL_RE = re.compile(r"\b(illustrative|illustration|example|mock(?:-?up)?|sample|hypothetical|not (?:real|measured)|for illustration)\b", re.IGNORECASE)
63
+ COPYRIGHT_YEAR_RE = re.compile(r"(?:©|\(c\)|copyright)\s*\d{4}", re.IGNORECASE)
64
+ ORDINAL_RE = re.compile(r"^0\d$") # "01", "02" — section numbering, not a claim
65
+ # L2 — number tokens that are structure, not claims: prose dates ("September 12, 2026", "Sep 2026",
66
+ # "2026 roadmap" → the year next to a month word), numbered headings ("1. Readable"), "Step 1",
67
+ # page footers ("Page 1 of 2", "1 / 2"), and a lone 1–2 digit number that is an element's whole text
68
+ _MONTH = r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?"
69
+ STRUCTURE_RES = [
70
+ re.compile(rf"\b{_MONTH}\s+\d{{1,2}}(?:st|nd|rd|th)?,?\s+\d{{4}}\b", re.IGNORECASE), # September 12, 2026
71
+ re.compile(rf"\b\d{{1,2}}(?:st|nd|rd|th)?\s+{_MONTH},?\s+\d{{4}}\b", re.IGNORECASE), # 12 Sep 2026
72
+ re.compile(rf"\b{_MONTH}\s+\d{{4}}\b", re.IGNORECASE), # Sep 2026
73
+ re.compile(r"\b(?:19|20)\d{2}\s+(?:roadmap|edition|release|plan|report|review|q[1-4])\b", re.IGNORECASE),
74
+ re.compile(r"\b(?:q[1-4]|h[12]|fy)\s*(?:19|20)?\d{2}\b", re.IGNORECASE), # Q3 2026 / FY26
75
+ re.compile(r"^\s*\d{1,3}[.)]\s+(?=\S)"), # "1. Readable"
76
+ re.compile(r"\b(?:step|phase|part|chapter|section|figure|fig\.|table|slide|page|pg\.)\s+\d{1,3}(?:\s+of\s+\d{1,3})?\b", re.IGNORECASE),
77
+ re.compile(r"^\s*\d{1,3}\s*/\s*\d{1,3}\s*$"), # "1 / 2" footer
78
+ ]
79
+ LONE_ORDINAL_RE = re.compile(r"^\d{1,2}$")
80
+ SOURCE_ATTRS = ("data-source", "data-sources", "data-src-path", "data-cite")
81
+ SKIP_DIRS = {".git", "node_modules", ".venv", "target", "dist", "build", "__pycache__", ".next"}
82
+
83
+
84
+ @dataclass
85
+ class Finding:
86
+ kind: str
87
+ path: str
88
+ text: str
89
+ detail: str
90
+
91
+ def as_dict(self) -> dict:
92
+ return {"kind": self.kind, "path": self.path, "text": self.text, "detail": self.detail}
93
+
94
+
95
+ def _sources_on(node: Node) -> list[str]:
96
+ out: list[str] = []
97
+ for n in [node, *node.ancestors()]:
98
+ for attr in SOURCE_ATTRS:
99
+ v = n.attrs.get(attr, "").strip()
100
+ if v:
101
+ out.extend(re.split(r"[\s,;]+", v))
102
+ return [s for s in out if s]
103
+
104
+
105
+ def _illustrative_container(node: Node) -> Node | None:
106
+ for n in [node, *node.ancestors()]:
107
+ if "data-illustrative" in n.attrs or "data-mock" in n.attrs:
108
+ return n
109
+ return None
110
+
111
+
112
+ def _has_sources_block(doc: Document) -> bool:
113
+ for el in doc.elements():
114
+ if "data-sources" in el.attrs and PATH_RE.search(el.all_text()):
115
+ return True
116
+ txt = el.direct_text()
117
+ if txt and re.match(r"^(sources?|references?|grounding)\b", txt, re.IGNORECASE):
118
+ container = el.parent if el.parent is not None else el
119
+ if PATH_RE.search(container.all_text()):
120
+ return True
121
+ return False
122
+
123
+
124
+ def _claim_blocks(doc: Document) -> list[Node]:
125
+ """Block-level text units: the nearest element with own text whose parent is not itself a
126
+ text-only inline wrapper — practically: every rendered element with own text, but a span's
127
+ numbers are judged with its parent block's text so an inline citation counts."""
128
+ return [el for el in doc.elements() if el.direct_text() and is_rendered(el) and el.tag != "br"]
129
+
130
+
131
+ _INLINE_TAGS = frozenset("span b strong i em u small sup sub code a abbr mark time s del ins q cite kbd var".split())
132
+
133
+
134
+ def _block_of(node: Node) -> Node:
135
+ n = node
136
+ while n.parent is not None and n.parent.tag != "" and n.tag in _INLINE_TAGS:
137
+ n = n.parent
138
+ return n
139
+
140
+
141
+ def _nearby_comments(el: Node, block: Node) -> list[str]:
142
+ """HTML comments a reader never sees but an author may have used as the only label of a
143
+ mock: comments anywhere inside the block (or its parent) and comments immediately preceding
144
+ the element or any of its ancestors up to three levels."""
145
+ out: list[str] = [n.text for n in block.walk() if n.tag == "#comment"]
146
+ node: Node | None = el
147
+ for _ in range(4):
148
+ if node is None or node.tag == "":
149
+ break
150
+ out.extend(node.preceding_comments())
151
+ node = node.parent
152
+ return out
153
+
154
+
155
+ def _repo_files(repos: list[str]) -> list[Path]:
156
+ files: list[Path] = []
157
+ for repo in repos:
158
+ root = Path(repo)
159
+ if not root.is_dir():
160
+ continue
161
+ for dirpath, dirnames, filenames in os.walk(root):
162
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
163
+ for fn in filenames:
164
+ p = Path(dirpath) / fn
165
+ try:
166
+ if p.stat().st_size <= 2_000_000:
167
+ files.append(p)
168
+ except OSError:
169
+ continue
170
+ return files
171
+
172
+
173
+ def _path_exists_in_repos(rel: str, repos: list[str]) -> bool:
174
+ rel_path = rel.split(":")[0]
175
+ for repo in repos:
176
+ if (Path(repo) / rel_path).exists():
177
+ return True
178
+ return False
179
+
180
+
181
+ def _url_in_repos(url: str, files: list[Path]) -> bool:
182
+ needle = url.rstrip("/.,;").lower()
183
+ bare = re.sub(r"^https?://(www\.)?", "", needle)
184
+ for f in files:
185
+ try:
186
+ text = f.read_text(encoding="utf-8", errors="ignore").lower()
187
+ except OSError:
188
+ continue
189
+ if needle in text or bare in text:
190
+ return True
191
+ return False
192
+
193
+
194
+ def scan_document(html: str, repos: list[str] | None = None) -> tuple[list[Finding], dict]:
195
+ repos = [r for r in (repos or []) if Path(r).is_dir()]
196
+ doc = parse(html)
197
+ findings: list[Finding] = []
198
+ seen: set[tuple] = set()
199
+ has_sources = _has_sources_block(doc)
200
+ repo_files = _repo_files(repos) if repos else []
201
+ stats = {"blocks": 0, "numbers": 0, "urls": 0, "cited_numbers": 0, "sources_block": has_sources,
202
+ "repos": repos}
203
+
204
+ def add(kind: str, node: Node, text: str, detail: str) -> None:
205
+ key = (kind, node.path(), text[:60])
206
+ if key in seen:
207
+ return
208
+ seen.add(key)
209
+ findings.append(Finding(kind, node.path(), text[:100], detail))
210
+
211
+ checked_sources: set[str] = set()
212
+ for el in _claim_blocks(doc):
213
+ stats["blocks"] += 1
214
+ own = el.direct_text()
215
+ if PLACEHOLDER_RE.search(own):
216
+ add("placeholder", el, own, "placeholder text is visible in the deliverable — move the gap to the notes/reply, never into the document")
217
+ block = _block_of(el)
218
+ block_text = block.all_text()
219
+ sources = _sources_on(el)
220
+ for s in sources:
221
+ if repos and s not in checked_sources:
222
+ checked_sources.add(s)
223
+ if not _path_exists_in_repos(s, repos):
224
+ add("dangling-source", el, s, "data-source names a path that does not exist under --repo")
225
+ inline_paths = PATH_RE.findall(block_text)
226
+ cited = bool(sources) or bool(inline_paths) or (has_sources and bool(FOOTNOTE_RE.search(block_text)))
227
+ illustrative = _illustrative_container(el)
228
+ labelled = illustrative is not None and bool(LABEL_RE.search(illustrative.all_text()))
229
+
230
+ # URLs
231
+ for url in URL_RE.findall(own):
232
+ stats["urls"] += 1
233
+ if repos:
234
+ if not _url_in_repos(url, repo_files):
235
+ add("unsourced-url", el, url, "no file under --repo mentions this URL — an invented or misremembered address")
236
+ elif not cited:
237
+ add("uncited-url", el, url, "URL with no data-source and no repo to check it against")
238
+
239
+ # numbers
240
+ text_wo_paths = PATH_RE.sub(" ", URL_RE.sub(" ", own))
241
+ text_wo_paths = COPYRIGHT_YEAR_RE.sub(" ", text_wo_paths)
242
+ for structure in STRUCTURE_RES:
243
+ text_wo_paths = structure.sub(" ", text_wo_paths)
244
+ if LONE_ORDINAL_RE.match(own.strip()):
245
+ text_wo_paths = "" # a badge / list marker whose whole text is one small number
246
+ numbers = [n for n in NUMBER_RE.findall(text_wo_paths) if not ORDINAL_RE.match(n)]
247
+ if not numbers:
248
+ continue
249
+ stats["numbers"] += len(numbers)
250
+ if cited:
251
+ stats["cited_numbers"] += len(numbers)
252
+ continue
253
+ if illustrative is not None:
254
+ if labelled:
255
+ continue
256
+ add("mock-unlabelled", el, own, "data-illustrative container has no visible label — a reader cannot tell the figures are not real")
257
+ continue
258
+ if any(LABEL_RE.search(c) for c in _nearby_comments(el, block)):
259
+ add("mock-label-hidden", el, own,
260
+ "figures are called illustrative only in an HTML comment — label the mock visibly and mark its container data-illustrative")
261
+ else:
262
+ add("uncited-number", el, own,
263
+ f"number(s) {', '.join(numbers[:5])} with no data-source, inline repo path or footnote — cite the file (path:line) or remove the figure")
264
+ return findings, stats
265
+
266
+
267
+ def run(path: str, repos: list[str] | None = None) -> dict:
268
+ html = Path(path).read_text(encoding="utf-8", errors="replace")
269
+ findings, stats = scan_document(html, repos)
270
+ by_kind: dict[str, int] = {}
271
+ for f in findings:
272
+ by_kind[f.kind] = by_kind.get(f.kind, 0) + 1
273
+ return {
274
+ "check": "claims",
275
+ "file": path,
276
+ "ok": not findings,
277
+ "stats": stats,
278
+ "by_kind": by_kind,
279
+ "findings": [f.as_dict() for f in findings],
280
+ "summary": (
281
+ f"claims: {stats['blocks']} text blocks, {stats['numbers']} numbers ({stats['cited_numbers']} cited), "
282
+ f"{stats['urls']} URLs — "
283
+ + ("clean" if not findings else ", ".join(f"{v} {k}" for k, v in sorted(by_kind.items())))
284
+ ),
285
+ }
286
+
287
+
288
+ def safe_console() -> None:
289
+ """M1 — never crash on a code-page console (Windows piped stdout is cp1252 on Python < 3.15)."""
290
+ for stream in (sys.stdout, sys.stderr):
291
+ reconfigure = getattr(stream, "reconfigure", None)
292
+ if reconfigure is not None:
293
+ try:
294
+ reconfigure(errors="replace")
295
+ except (ValueError, OSError):
296
+ pass
297
+
298
+
299
+ def format_finding(f: dict) -> str:
300
+ return f" [{f['kind']}] {f['path']} \"{f['text']}\" — {f['detail']}"
301
+
302
+
303
+ def main(argv: list[str] | None = None) -> int:
304
+ safe_console()
305
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
306
+ ap.add_argument("html", help="the self-contained HTML deliverable")
307
+ ap.add_argument("--repo", action="append", default=[],
308
+ help="a repository snapshot the claims must trace to (repeatable)")
309
+ ap.add_argument("--json", action="store_true")
310
+ args = ap.parse_args(argv)
311
+ try:
312
+ report = run(args.html, args.repo)
313
+ except OSError as exc:
314
+ print(json.dumps({"check": "claims", "ok": False, "error": str(exc)}))
315
+ return 2
316
+ if args.json:
317
+ print(json.dumps(report, indent=2))
318
+ else:
319
+ print(report["summary"])
320
+ for f in report["findings"]:
321
+ print(format_finding(f))
322
+ return 0 if report["ok"] else 1
323
+
324
+
325
+ if __name__ == "__main__":
326
+ sys.exit(main())