@ranimontagna/agent-toolkit 0.3.5 → 0.3.7

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +8 -8
  3. package/dist/src/installers/superpowers.js +23 -7
  4. package/dist/src/installers/superpowers.js.map +1 -1
  5. package/package.json +5 -5
  6. package/skills/backend/fastify-best-practices/NOTICE.md +1 -1
  7. package/skills/backend/fastify-best-practices/rules/authentication.md +13 -0
  8. package/skills/backend/fastify-best-practices/rules/configuration.md +9 -0
  9. package/skills/backend/fastify-best-practices/rules/content-type.md +16 -0
  10. package/skills/backend/fastify-best-practices/rules/cors-security.md +18 -0
  11. package/skills/backend/fastify-best-practices/rules/database.md +12 -0
  12. package/skills/backend/fastify-best-practices/rules/decorators.md +14 -0
  13. package/skills/backend/fastify-best-practices/rules/deployment.md +16 -0
  14. package/skills/backend/fastify-best-practices/rules/error-handling.md +16 -0
  15. package/skills/backend/fastify-best-practices/rules/hooks.md +20 -0
  16. package/skills/backend/fastify-best-practices/rules/http-proxy.md +11 -0
  17. package/skills/backend/fastify-best-practices/rules/logging.md +18 -0
  18. package/skills/backend/fastify-best-practices/rules/performance.md +20 -0
  19. package/skills/backend/fastify-best-practices/rules/plugins.md +14 -0
  20. package/skills/backend/fastify-best-practices/rules/routes.md +18 -0
  21. package/skills/backend/fastify-best-practices/rules/schemas.md +20 -0
  22. package/skills/backend/fastify-best-practices/rules/serialization.md +19 -0
  23. package/skills/backend/fastify-best-practices/rules/testing.md +17 -0
  24. package/skills/backend/fastify-best-practices/rules/typescript.md +15 -0
  25. package/skills/backend/fastify-best-practices/rules/websockets.md +15 -0
  26. package/skills/backend/go/golang-patterns/NOTICE.md +1 -1
  27. package/skills/backend/go/golang-patterns/SKILL.md +2 -1
  28. package/skills/devops/docker-patterns/NOTICE.md +1 -1
  29. package/skills/devops/docker-patterns/SKILL.md +3 -3
  30. package/skills/frontend/design/revenue-centric-design/NOTICE.md +1 -1
  31. package/skills/frontend/design/revenue-centric-design/SKILL.md +6 -4
  32. package/skills/frontend/design/revenue-centric-design/assets/1985681720104903142__1.jpg +0 -0
  33. package/skills/frontend/design/revenue-centric-design/assets/1999584679247134761__1.jpg +0 -0
  34. package/skills/frontend/design/revenue-centric-design/assets/2000930776636940333__1.jpg +0 -0
  35. package/skills/frontend/design/revenue-centric-design/assets/2018693884449009956__3.jpg +0 -0
  36. package/skills/frontend/design/revenue-centric-design/assets/2081714430811730162__1.jpg +0 -0
  37. package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__1.jpg +0 -0
  38. package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__2.jpg +0 -0
  39. package/skills/frontend/design/revenue-centric-design/assets/2092692547244007852__q__3.jpg +0 -0
  40. package/skills/frontend/design/revenue-centric-design/references/checkout-and-forms.md +39 -0
  41. package/skills/frontend/design/revenue-centric-design/references/churn-and-retention.md +25 -0
  42. package/skills/frontend/design/revenue-centric-design/references/conversion-and-landing-pages.md +52 -1
  43. package/skills/frontend/design/revenue-centric-design/references/dashboards-and-data-viz.md +23 -0
  44. package/skills/frontend/design/revenue-centric-design/references/metrics-and-experimentation.md +13 -0
  45. package/skills/frontend/design/revenue-centric-design/references/onboarding-and-activation.md +7 -1
  46. package/skills/frontend/design/revenue-centric-design/references/pricing-and-monetization.md +35 -8
  47. package/skills/frontend/design/revenue-centric-design/references/product-strategy-and-features.md +37 -0
  48. package/skills/frontend/design/revenue-centric-design/references/revenue-centric-design.md +2 -2
  49. package/skills/frontend/design/ui-ux-pro-max/NOTICE.md +6 -4
  50. package/skills/frontend/design/ui-ux-pro-max/SKILL.md +103 -573
  51. package/skills/frontend/design/ui-ux-pro-max/data/app-interface.csv +29 -27
  52. package/skills/frontend/design/ui-ux-pro-max/data/catalog-summary.json +78 -0
  53. package/skills/frontend/design/ui-ux-pro-max/data/charts.csv +26 -26
  54. package/skills/frontend/design/ui-ux-pro-max/data/colors.csv +191 -160
  55. package/skills/frontend/design/ui-ux-pro-max/data/data-provenance.json +1348 -0
  56. package/skills/frontend/design/ui-ux-pro-max/data/google-font-licenses.json +20185 -0
  57. package/skills/frontend/design/ui-ux-pro-max/data/google-fonts.csv +1934 -1923
  58. package/skills/frontend/design/ui-ux-pro-max/data/icons.csv +106 -106
  59. package/skills/frontend/design/ui-ux-pro-max/data/landing.csv +35 -35
  60. package/skills/frontend/design/ui-ux-pro-max/data/motion.csv +18 -0
  61. package/skills/frontend/design/ui-ux-pro-max/data/phosphor-icons-upstream.json +32582 -0
  62. package/skills/frontend/design/ui-ux-pro-max/data/products.csv +192 -161
  63. package/skills/frontend/design/ui-ux-pro-max/data/react-performance.csv +37 -37
  64. package/skills/frontend/design/ui-ux-pro-max/data/stacks/angular.csv +51 -51
  65. package/skills/frontend/design/ui-ux-pro-max/data/stacks/astro.csv +54 -54
  66. package/skills/frontend/design/ui-ux-pro-max/data/stacks/avalonia.csv +57 -0
  67. package/skills/frontend/design/ui-ux-pro-max/data/stacks/flutter.csv +53 -53
  68. package/skills/frontend/design/ui-ux-pro-max/data/stacks/html-tailwind.csv +60 -56
  69. package/skills/frontend/design/ui-ux-pro-max/data/stacks/javafx.csv +76 -0
  70. package/skills/frontend/design/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -53
  71. package/skills/frontend/design/ui-ux-pro-max/data/stacks/laravel.csv +51 -51
  72. package/skills/frontend/design/ui-ux-pro-max/data/stacks/nextjs.csv +62 -53
  73. package/skills/frontend/design/ui-ux-pro-max/data/stacks/nuxt-ui.csv +77 -51
  74. package/skills/frontend/design/ui-ux-pro-max/data/stacks/nuxtjs.csv +68 -59
  75. package/skills/frontend/design/ui-ux-pro-max/data/stacks/react-native.csv +52 -52
  76. package/skills/frontend/design/ui-ux-pro-max/data/stacks/react.csv +67 -54
  77. package/skills/frontend/design/ui-ux-pro-max/data/stacks/shadcn.csv +69 -61
  78. package/skills/frontend/design/ui-ux-pro-max/data/stacks/svelte.csv +56 -54
  79. package/skills/frontend/design/ui-ux-pro-max/data/stacks/swiftui.csv +51 -51
  80. package/skills/frontend/design/ui-ux-pro-max/data/stacks/threejs.csv +54 -54
  81. package/skills/frontend/design/ui-ux-pro-max/data/stacks/uno.csv +60 -0
  82. package/skills/frontend/design/ui-ux-pro-max/data/stacks/uwp.csv +56 -0
  83. package/skills/frontend/design/ui-ux-pro-max/data/stacks/vue.csv +50 -50
  84. package/skills/frontend/design/ui-ux-pro-max/data/stacks/winui.csv +60 -0
  85. package/skills/frontend/design/ui-ux-pro-max/data/stacks/wpf.csv +57 -0
  86. package/skills/frontend/design/ui-ux-pro-max/data/styles.csv +89 -85
  87. package/skills/frontend/design/ui-ux-pro-max/data/typography.csv +74 -73
  88. package/skills/frontend/design/ui-ux-pro-max/data/ui-reasoning.csv +193 -162
  89. package/skills/frontend/design/ui-ux-pro-max/data/ux-guidelines.csv +30 -10
  90. package/skills/frontend/design/ui-ux-pro-max/references/pro-rules.md +117 -0
  91. package/skills/frontend/design/ui-ux-pro-max/references/quick-reference.md +256 -0
  92. package/skills/frontend/design/ui-ux-pro-max/scripts/core.py +800 -69
  93. package/skills/frontend/design/ui-ux-pro-max/scripts/design_system.py +700 -180
  94. package/skills/frontend/design/ui-ux-pro-max/scripts/reasoning_contract.py +123 -0
  95. package/skills/frontend/design/ui-ux-pro-max/scripts/search.py +105 -42
  96. package/skills/frontend/design/ui-ux-pro-max/scripts/validate_data.py +1095 -0
  97. package/skills/frontend/react-native/react-native-unistyles-v3/NOTICE.md +1 -1
  98. package/skills/frontend/react-native/react-native-unistyles-v3/SKILL.md +33 -24
  99. package/skills/frontend/react-native/react-native-unistyles-v3/references/api-reference.md +135 -78
  100. package/skills/frontend/react-native/react-native-unistyles-v3/references/common-issues.md +48 -31
  101. package/skills/frontend/react-native/react-native-unistyles-v3/references/setup-guide.md +76 -34
  102. package/skills/frontend/react-native/react-native-unistyles-v3/references/styling-patterns.md +145 -52
  103. package/skills/frontend/react-native/react-native-unistyles-v3/references/third-party-integration.md +76 -27
  104. package/skills/media/brag-slim/NOTICE.md +3 -3
  105. package/skills/media/brag-slim/SKILL.md +4 -0
  106. package/tools.lock.json +20 -20
  107. package/skills/frontend/design/revenue-centric-design/assets/2018693884449009956__1.jpg +0 -0
  108. package/skills/frontend/design/ui-ux-pro-max/data/_sync_all.py +0 -414
  109. package/skills/frontend/design/ui-ux-pro-max/data/design.csv +0 -1776
  110. package/skills/frontend/design/ui-ux-pro-max/data/draft.csv +0 -1779
@@ -0,0 +1,1095 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Data integrity guardrail for ui-ux-pro-max. Stdlib-only, no pytest dependency,
5
+ so it can run as a standalone pre-publish/CI check:
6
+
7
+ python validate_data.py
8
+
9
+ Checks, per configured domain/stack CSV:
10
+ - file exists
11
+ - header row contains every column referenced in search_cols/output_cols
12
+ - no duplicate primary-key values (first column) within a file
13
+ - any "Decision_Rules"-style JSON column parses as JSON
14
+
15
+ Exits 0 with no output on success; exits 1 and prints every problem found
16
+ on failure (fail-fast is the wrong call here -- a data change can break
17
+ several files at once, so we want the full list in one run).
18
+ """
19
+
20
+ import csv
21
+ import hashlib
22
+ import json
23
+ import math
24
+ import re
25
+ import statistics
26
+ import sys
27
+ from datetime import date
28
+ from pathlib import Path
29
+ from urllib.parse import parse_qs, quote_plus, urlsplit
30
+
31
+ from core import (CSV_CONFIG, STACK_CONFIG, STACK_CURRENT_APPLICABILITY,
32
+ _STACK_COLS, DATA_DIR)
33
+ from reasoning_contract import parse_decision_rules
34
+
35
+ # REASONING_FILE lives in design_system.py, not core.py -- redeclared here to
36
+ # avoid a circular import (design_system.py imports core.py).
37
+ REASONING_FILE = "ui-reasoning.csv"
38
+ STYLE_STATUSES = {"active", "supplemental", "deprecated"}
39
+ STACK_STATUSES = STYLE_STATUSES | {"unverified"}
40
+ HEX_COLOR = re.compile(r"#[0-9A-Fa-f]{6}")
41
+ STYLE_ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
42
+ WCAG_CONFORMANCE = re.compile(
43
+ r"\bWCAG\s+A{2,3}\+?\b|\bWCAG\b.{0,40}\b(?:compliant|compliance)\b",
44
+ re.I,
45
+ )
46
+ WCAG_GRADE = re.compile(r"\bWCAG\s+A{1,3}\b", re.I)
47
+ CHART_TEXT_FALLBACK = re.compile(r"table|list|summary|text")
48
+ CHART_NON_COLOR_GUIDANCE = re.compile(
49
+ r"color alone|hue alone|alone is insufficient|only distinction|only carriers?|"
50
+ r"supplementary|pattern|symbol|outline|direct (?:series |group )?label|"
51
+ r"marker shape|label every"
52
+ )
53
+ CSS_IMPORT = re.compile(
54
+ r"\s*@import\s+url\((['\"])(https://fonts\.googleapis\.com/css2\?[^'\"]+)\1\);\s*",
55
+ re.I,
56
+ )
57
+ FONT_WEIGHT = re.compile(r"(?<!\d)(?:[1-9]00)(?!\d)")
58
+ ICON_IMPORT = re.compile(
59
+ r"import\s*\{\s*[A-Z][A-Za-z0-9]*(?:\s*,\s*[A-Z][A-Za-z0-9]*)*\s*\}"
60
+ r"\s*from\s*['\"](?:@phosphor-icons/react|phosphor-react-native|"
61
+ r"@heroicons/react/24/(?:outline|solid))['\"]"
62
+ )
63
+ ICON_USAGE_REQUIREMENTS = (
64
+ r"aria-hidden", r"text alternative", r"accessible name",
65
+ r"aria-(?:pressed|expanded)",
66
+ )
67
+ LANDING_QUANTIFIED_CLAIM = re.compile(
68
+ r"\b\d+(?:\.\d+)?x\b|"
69
+ r"\b(?:increases?|reduces?)\s+(?:engagement|conversion|returns?)\b",
70
+ re.I,
71
+ )
72
+ CHART_RISKS = {"risk:low", "risk:conditional", "risk:high"}
73
+ ICON_ROLES = {"decorative", "meaningful", "interactive", "guideline"}
74
+ ICON_CONTEXTS = {"decorative", "meaningful", "interactive"}
75
+ COLOR_CONTRAST_PAIRS = {
76
+ "On Primary": ("Primary", "normal-text", 4.5),
77
+ "On Secondary": ("Secondary", "normal-text", 4.5),
78
+ "On Accent": ("Accent", "normal-text", 4.5),
79
+ "Foreground": ("Background", "normal-text", 4.5),
80
+ "Card Foreground": ("Card", "normal-text", 4.5),
81
+ "Muted Foreground": ("Muted", "normal-text", 4.5),
82
+ "On Destructive": ("Destructive", "normal-text", 4.5),
83
+ "Ring": ("Background", "non-text-focus-indicator", 3.0),
84
+ }
85
+ PROVENANCE_KINDS = {"reasoning", "style", "dataset-contract", "catalog-snapshot"}
86
+ PROVENANCE_STATUSES = {"active", "supplemental", "deprecated"}
87
+ PROVENANCE_SLAS = {"manual-verified", "needs-review"}
88
+ PROVENANCE_SOURCE_TYPES = {"official", "derived"}
89
+ PROVENANCE_APPLIES_TO = {
90
+ "search", "design-guidance", "design-system", "gallery", "style-search",
91
+ }
92
+ CORE_PROVENANCE_FILES = {
93
+ "colors.csv", "charts.csv", "ux-guidelines.csv", "landing.csv",
94
+ "typography.csv", "icons.csv", "motion.csv", "app-interface.csv",
95
+ "react-performance.csv", "stacks/html-tailwind.csv",
96
+ }
97
+ CATALOG_PROVENANCE_FILES = {"google-fonts.csv", "phosphor-icons-upstream.json"}
98
+ CATALOG_PROVENANCE_IDS = {
99
+ "google-fonts-catalog-2026-08-13", "phosphor-icons-catalog-2.1.1",
100
+ }
101
+ FONT_LICENSES = {"OFL", "APACHE2", "UFL"}
102
+ GOOGLE_FONTS_REVISION = re.compile(r"[0-9a-f]{40}")
103
+ PHOSPHOR_WEIGHTS = {"thin", "light", "regular", "bold", "fill", "duotone"}
104
+ OFFICIAL_SOURCE_HOSTS = {
105
+ "carbondesignsystem.com", "developer.android.com", "developer.apple.com",
106
+ "developers.google.com", "fluent2.microsoft.design", "github.com",
107
+ "greensock.com", "gsap.com", "m3.material.io", "opensource.adobe.com",
108
+ "react.dev", "s2.spectrum.adobe.com", "shopify.dev",
109
+ "design-system.service.gov.uk", "tailwindcss.com", "www.w3.org",
110
+ }
111
+ STACK_OFFICIAL_HOSTS = {
112
+ "react": {"react.dev"},
113
+ "nextjs": {"nextjs.org"},
114
+ "vue": {"vuejs.org", "pinia.vuejs.org"},
115
+ "svelte": {"svelte.dev", "kit.svelte.dev"},
116
+ "astro": {"docs.astro.build"},
117
+ "angular": {"angular.dev"},
118
+ "html-tailwind": {"tailwindcss.com"},
119
+ "shadcn": {"ui.shadcn.com"},
120
+ "nuxtjs": {"nuxt.com"},
121
+ "nuxt-ui": {"ui.nuxt.com"},
122
+ "react-native": {"reactnative.dev", "react.dev"},
123
+ "flutter": {"api.flutter.dev", "docs.flutter.dev"},
124
+ "swiftui": {"developer.apple.com"},
125
+ "jetpack-compose": {"developer.android.com"},
126
+ "avalonia": {"docs.avaloniaui.net"},
127
+ "uwp": {"learn.microsoft.com"},
128
+ "winui": {"learn.microsoft.com"},
129
+ "wpf": {"learn.microsoft.com"},
130
+ "uno": {"platform.uno"},
131
+ "javafx": {"openjfx.io", "mkpaz.github.io", "www.w3.org"},
132
+ "threejs": {"threejs.org", "github.com", "www.npmjs.com", "www.w3.org"},
133
+ "laravel": {"laravel.com"},
134
+ }
135
+ REQUIRED_UX_GUIDANCE = {
136
+ "Focus Not Obscured (Minimum)": "Web",
137
+ "Focus Not Obscured (Enhanced)": "Web",
138
+ "Focus Appearance": "Web",
139
+ "Dragging Movements": "All",
140
+ "Target Size (Minimum)": "Web",
141
+ "Consistent Help": "All",
142
+ "Redundant Entry": "All",
143
+ "Accessible Authentication (Minimum)": "All",
144
+ "Auto-Rotating Content Controls": "All",
145
+ }
146
+
147
+
148
+ def _relative_luminance(value):
149
+ if not HEX_COLOR.fullmatch(value or ""):
150
+ raise ValueError(f"invalid hex color '{value}'")
151
+ channels = [int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)]
152
+ linear = [channel / 12.92 if channel <= 0.04045
153
+ else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
154
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
155
+
156
+
157
+ def contrast_ratio(foreground, background):
158
+ """Return WCAG contrast for two opaque six-digit sRGB colors."""
159
+ first, second = _relative_luminance(foreground), _relative_luminance(background)
160
+ return (max(first, second) + 0.05) / (min(first, second) + 0.05)
161
+
162
+
163
+ def _read_rows(filepath):
164
+ with open(filepath, "r", encoding="utf-8") as f:
165
+ reader = csv.DictReader(f)
166
+ return reader.fieldnames or [], list(reader)
167
+
168
+
169
+ def _check_file(label, filepath, search_cols, output_cols, problems):
170
+ if not filepath.exists():
171
+ problems.append(f"[{label}] missing file: {filepath}")
172
+ return
173
+
174
+ try:
175
+ headers, rows = _read_rows(filepath)
176
+ except (csv.Error, UnicodeDecodeError, OSError) as e:
177
+ problems.append(f"[{label}] failed to parse {filepath.name}: {e}")
178
+ return
179
+
180
+ header_set = set(headers)
181
+ for col in set(search_cols) | set(output_cols):
182
+ if col not in header_set:
183
+ problems.append(f"[{label}] {filepath.name}: expected column '{col}' not found in header")
184
+
185
+ # Only check for duplicates against an actual identifier column ("No" is
186
+ # the sequential-index convention used across this dataset). The first
187
+ # CSV column is not reliably a unique key -- e.g. stack files use
188
+ # "Category", which legitimately repeats across many guideline rows.
189
+ if "No" in header_set:
190
+ seen = {}
191
+ for i, row in enumerate(rows, start=2): # +1 header, +1 to be 1-indexed
192
+ key = row.get("No", "")
193
+ if key in seen:
194
+ problems.append(
195
+ f"[{label}] {filepath.name}: duplicate 'No' value '{key}' on rows {seen[key]} and {i}"
196
+ )
197
+ else:
198
+ seen[key] = i
199
+ elif label.startswith("stack:"):
200
+ problems.append(
201
+ f"[{label}] {filepath.name}: missing 'No' index column present in other stack files "
202
+ "(schema drift -- harmless for search, but inconsistent with the rest of data/stacks/)"
203
+ )
204
+
205
+ return headers, rows
206
+
207
+
208
+ def _split(value, delimiter):
209
+ return [part.strip() for part in value.split(delimiter) if part.strip()]
210
+
211
+
212
+ def _valid_date(value):
213
+ try:
214
+ return date.fromisoformat(value) <= date.today()
215
+ except (TypeError, ValueError):
216
+ return False
217
+
218
+
219
+ def _valid_confidence(value):
220
+ return (
221
+ value is None
222
+ or (
223
+ not isinstance(value, bool)
224
+ and isinstance(value, (int, float))
225
+ and math.isfinite(value)
226
+ and 0 <= value <= 1
227
+ )
228
+ )
229
+
230
+
231
+ def _check_style_contract(styles, products, reasoning, problems,
232
+ replacement_targets=None):
233
+ replacement_targets = replacement_targets or {}
234
+ ids, lookup, parents, by_id = set(), {}, {}, {}
235
+ for row in styles:
236
+ style_id = row.get("Style ID", "")
237
+ if not STYLE_ID.fullmatch(style_id):
238
+ problems.append(f"[style] invalid Style ID '{style_id}'")
239
+ if style_id in ids:
240
+ problems.append(f"[style] duplicate Style ID '{style_id}'")
241
+ ids.add(style_id)
242
+ by_id[style_id] = row
243
+ status, parent = row.get("Status", ""), row.get("Parent Style ID", "")
244
+ if status not in STYLE_STATUSES:
245
+ problems.append(f"[style:{style_id}] invalid Status '{status}'")
246
+ replacement_domain = row.get("Replacement Domain", "")
247
+ replacement_id = row.get("Replacement ID", "")
248
+ if status == "supplemental" and not parent:
249
+ problems.append(f"[style:{style_id}] supplemental rows require Parent Style ID")
250
+ if status == "deprecated":
251
+ has_parent = bool(parent)
252
+ has_redirect = bool(replacement_domain and replacement_id)
253
+ if has_parent == has_redirect:
254
+ problems.append(
255
+ f"[style:{style_id}] deprecated rows require exactly one parent or redirect"
256
+ )
257
+ if has_redirect and replacement_id not in replacement_targets.get(
258
+ replacement_domain, set()):
259
+ problems.append(
260
+ f"[style:{style_id}] invalid {replacement_domain} redirect '{replacement_id}'"
261
+ )
262
+ elif replacement_domain or replacement_id:
263
+ problems.append(f"[style:{style_id}] only deprecated rows may redirect")
264
+ parents[style_id] = parent
265
+ for key in [style_id, row.get("Style Category", ""), *_split(row.get("Aliases", ""), "|")]:
266
+ folded = key.casefold()
267
+ if folded in lookup and lookup[folded] != style_id:
268
+ problems.append(f"[style] ambiguous identity '{key}' -> {lookup[folded]}, {style_id}")
269
+ lookup[folded] = style_id
270
+ for style_id, parent in parents.items():
271
+ if parent and (parent not in ids or parent == style_id):
272
+ problems.append(f"[style:{style_id}] invalid parent '{parent}'")
273
+ seen, current = {style_id}, parent
274
+ while current:
275
+ if current in seen:
276
+ problems.append(f"[style:{style_id}] parent cycle through '{current}'")
277
+ break
278
+ seen.add(current)
279
+ current = parents.get(current, "")
280
+ if parent and by_id.get(parent, {}).get("Status") != "active":
281
+ problems.append(f"[style:{style_id}] parent must target an active style")
282
+ references = []
283
+ for row in products:
284
+ references += _split(row.get("Primary Style Recommendation", ""), "+")
285
+ references += _split(row.get("Secondary Styles", ""), ",")
286
+ for row in reasoning:
287
+ references += _split(row.get("Style_Priority", ""), "+")
288
+ for reference in sorted(set(references)):
289
+ resolved_id = lookup.get(reference.casefold())
290
+ if not resolved_id:
291
+ problems.append(f"[style] unresolved reference '{reference}'")
292
+ elif next(row for row in styles if row.get("Style ID") == resolved_id).get(
293
+ "Status") == "deprecated":
294
+ problems.append(f"[style] reference targets deprecated style '{reference}'")
295
+
296
+ performance_levels = {"cost:low", "cost:moderate", "cost:high"}
297
+ accessibility_levels = {"risk:low", "risk:conditional", "risk:high"}
298
+ mode_levels = {"supported", "conditional", "not-recommended"}
299
+ prompt_lengths = {}
300
+ for row in styles:
301
+ style_id = row.get("Style ID", "")
302
+ if row.get("Performance", "").split("|", 1)[0] not in performance_levels:
303
+ problems.append(f"[style:{style_id}] invalid Performance vocabulary")
304
+ if row.get("Accessibility", "").split("|", 1)[0] not in accessibility_levels:
305
+ problems.append(f"[style:{style_id}] invalid Accessibility vocabulary")
306
+ claim_text = " ".join(str(value) for value in row.values())
307
+ if WCAG_CONFORMANCE.search(claim_text):
308
+ problems.append(f"[style:{style_id}] accessibility conformance guarantee")
309
+ if re.search(r"\d+/10", row.get("Framework Compatibility", "")):
310
+ problems.append(f"[style:{style_id}] framework score is unsupported")
311
+ if any(term in row.get("Framework Compatibility", "").casefold()
312
+ for term in ("all frameworks", "excellent", "performant", "lightweight")):
313
+ problems.append(f"[style:{style_id}] unsupported framework guarantee")
314
+ for field in ("Light Mode ✓", "Dark Mode ✓"):
315
+ if row.get(field) not in mode_levels:
316
+ problems.append(f"[style:{style_id}] invalid {field} vocabulary")
317
+ if row.get("Preferred Mode") not in {"auto", "light", "dark"}:
318
+ problems.append(f"[style:{style_id}] invalid Preferred Mode")
319
+ length = len(row.get("AI Prompt Keywords", "").split())
320
+ prompt_lengths.setdefault(row.get("Type", ""), []).append(length)
321
+ if length > 40:
322
+ problems.append(f"[style:{style_id}] AI prompt exceeds 40 words")
323
+ if prompt_lengths.get("General") and prompt_lengths.get("Mobile"):
324
+ general = statistics.median(prompt_lengths["General"])
325
+ mobile = statistics.median(prompt_lengths["Mobile"])
326
+ if mobile > general * 1.25:
327
+ problems.append("[style] mobile prompt median exceeds 1.25x general median")
328
+ return ids
329
+
330
+
331
+ def _check_reasoning_contract(products, colors, reasoning, style_ids, patterns, problems):
332
+ semantic_sets = (
333
+ ("products", products, "Product Type"),
334
+ ("colors", colors, "Product Type"),
335
+ ("reasoning", reasoning, "UI_Category"),
336
+ )
337
+ for label, rows, key in semantic_sets:
338
+ counts = {}
339
+ for row in rows:
340
+ value = row.get(key, "")
341
+ counts[value] = counts.get(value, 0) + 1
342
+ duplicates = sorted(value for value, count in counts.items() if count > 1)
343
+ if len(rows) != 192:
344
+ problems.append(f"[reasoning] {label} must contain exactly 192 rows; got {len(rows)}")
345
+ if duplicates:
346
+ problems.append(
347
+ f"[reasoning] duplicate {label} labels: {', '.join(duplicates)}"
348
+ )
349
+ product_names = {row.get("Product Type", "") for row in products}
350
+ color_names = {row.get("Product Type", "") for row in colors}
351
+ reasoning_names = {row.get("UI_Category", "") for row in reasoning}
352
+ if product_names != color_names:
353
+ problems.append("[reasoning] products/colors product labels differ")
354
+ missing, extra = sorted(product_names - reasoning_names), sorted(reasoning_names - product_names)
355
+ if missing:
356
+ problems.append(f"[reasoning] missing exact product rows: {', '.join(missing)}")
357
+ if extra:
358
+ problems.append(f"[reasoning] unknown product rows: {', '.join(extra)}")
359
+ for row in reasoning:
360
+ category = row.get("UI_Category", "")
361
+ try:
362
+ rules = parse_decision_rules(row.get("Decision_Rules", ""))
363
+ except ValueError as error:
364
+ problems.append(f"[reasoning:{category}] {error}")
365
+ continue
366
+ for actions in rules.values():
367
+ for action in actions:
368
+ prefix, value = action.split(":", 1)
369
+ if prefix == "style" and value not in style_ids:
370
+ problems.append(f"[reasoning:{category}] unknown style action '{value}'")
371
+ if prefix == "pattern" and value not in patterns:
372
+ problems.append(f"[reasoning:{category}] unknown pattern action '{value}'")
373
+ pattern = row.get("Recommended_Pattern", "")
374
+ if pattern not in patterns:
375
+ problems.append(
376
+ f"[reasoning:{category}] unknown Recommended_Pattern '{pattern}'"
377
+ )
378
+ confidence = row.get("Confidence", "")
379
+ if confidence:
380
+ try:
381
+ if not 0 <= float(confidence) <= 1:
382
+ raise ValueError
383
+ except ValueError:
384
+ problems.append(f"[reasoning:{category}] invalid Confidence '{confidence}'")
385
+
386
+
387
+ def _check_color_contract(rows, problems):
388
+ for row in rows:
389
+ product = row.get("Product Type", "")
390
+ ratios = {}
391
+ for foreground, (background, role, minimum) in COLOR_CONTRAST_PAIRS.items():
392
+ try:
393
+ ratio = contrast_ratio(row.get(foreground, ""), row.get(background, ""))
394
+ except ValueError as error:
395
+ problems.append(f"[color:{product}] {error}")
396
+ continue
397
+ if ratio + 1e-9 < minimum:
398
+ problems.append(
399
+ f"[color:{product}] {role} {foreground}/{background} contrast "
400
+ f"{ratio:.2f}:1 is below {minimum:.1f}:1"
401
+ )
402
+ ratios[foreground] = ratio
403
+ if ("Muted Foreground" in ratios and "Foreground" in ratios
404
+ and ratios["Muted Foreground"] > ratios["Foreground"] + 1e-9):
405
+ problems.append(
406
+ f"[color:{product}] muted text contrast exceeds primary text contrast"
407
+ )
408
+ try:
409
+ value = row.get("Destructive", "").lstrip("#")
410
+ red, green, blue = (int(value[index:index + 2], 16) for index in (0, 2, 4))
411
+ if green > red * 1.1 and green > blue * 1.1:
412
+ problems.append(f"[color:{product}] Destructive token is success green")
413
+ except ValueError:
414
+ pass
415
+
416
+
417
+ def _check_chart_contract(rows, problems):
418
+ for row in rows:
419
+ data_type = row.get("Data Type", "")
420
+ if row.get("Accessibility Grade") != "deprecated: use Accessibility Risk":
421
+ problems.append(f"[chart:{data_type}] invalid deprecated Accessibility Grade")
422
+ if row.get("Accessibility Risk") not in CHART_RISKS:
423
+ problems.append(f"[chart:{data_type}] invalid Accessibility Risk")
424
+ fallback = " ".join((row.get("Accessibility Notes", ""),
425
+ row.get("A11y Fallback", ""))).casefold()
426
+ if not CHART_TEXT_FALLBACK.search(fallback):
427
+ problems.append(f"[chart:{data_type}] missing text/table/list fallback")
428
+ if not CHART_NON_COLOR_GUIDANCE.search(fallback):
429
+ problems.append(f"[chart:{data_type}] missing non-color distinction guidance")
430
+ if row.get("Interactive Level", "").strip() and "keyboard" not in fallback:
431
+ problems.append(f"[chart:{data_type}] missing keyboard interaction equivalent")
432
+ if WCAG_GRADE.search(fallback):
433
+ problems.append(f"[chart:{data_type}] fallback claims WCAG conformance")
434
+
435
+
436
+ def _font_families(url):
437
+ return parse_qs(urlsplit(url).query).get("family", [])
438
+
439
+
440
+ def _font_names(family_declarations):
441
+ return {declaration.split(":", 1)[0].replace("+", " ")
442
+ for declaration in family_declarations}
443
+
444
+
445
+ def _configured_font_names(config):
446
+ return set(re.findall(r"'([^']+)'", config))
447
+
448
+
449
+ def _imported_weights(family_declarations):
450
+ weights = set()
451
+ for declaration in family_declarations:
452
+ if ":" not in declaration or "@" not in declaration:
453
+ continue
454
+ _, axis_values = declaration.split(":", 1)
455
+ axes, values = axis_values.split("@", 1)
456
+ if "wght" in axes:
457
+ weights.update(FONT_WEIGHT.findall(values))
458
+ return weights
459
+
460
+
461
+ def _declared_weights(notes):
462
+ weights = set()
463
+ for match in re.finditer(r"(?:weights?|strictly)[^.;]{0,100}", notes, re.I):
464
+ weights.update(FONT_WEIGHT.findall(match.group()))
465
+ return weights
466
+
467
+
468
+ def _check_typography_contract(rows, problems):
469
+ for row in rows:
470
+ pairing = row.get("Font Pairing Name", "")
471
+ font_url = row.get("Google Fonts URL", "")
472
+ url_families = _font_families(font_url)
473
+ families = _font_names(url_families)
474
+ configured = _configured_font_names(row.get("Tailwind Config", ""))
475
+ named = {row.get("Heading Font", ""), row.get("Body Font", "")}
476
+ if not named <= families or not named <= configured:
477
+ problems.append(f"[typography:{pairing}] named/imported/configured fonts differ")
478
+ import_match = CSS_IMPORT.fullmatch(row.get("CSS Import", ""))
479
+ if not import_match:
480
+ problems.append(f"[typography:{pairing}] invalid CSS Import")
481
+ continue
482
+ import_families = _font_families(import_match.group(2))
483
+ if sorted(url_families) != sorted(import_families):
484
+ problems.append(f"[typography:{pairing}] Google URL and CSS Import differ")
485
+ imported_weights = _imported_weights(url_families)
486
+ declared_weights = _declared_weights(row.get("Notes", ""))
487
+ if declared_weights and not declared_weights <= imported_weights:
488
+ missing = ", ".join(sorted(declared_weights - imported_weights))
489
+ problems.append(f"[typography:{pairing}] recommended weights not imported: {missing}")
490
+
491
+
492
+ def _load_catalog_json(name, problems):
493
+ try:
494
+ payload = json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
495
+ except (OSError, json.JSONDecodeError) as error:
496
+ problems.append(f"[catalog:{name}] invalid JSON: {error}")
497
+ return {}
498
+ if not isinstance(payload, dict) or payload.get("schemaVersion") != 1:
499
+ problems.append(f"[catalog:{name}] schemaVersion 1 object is required")
500
+ return {}
501
+ return payload
502
+
503
+
504
+ def _catalog_date(value):
505
+ try:
506
+ parsed = date.fromisoformat(value)
507
+ return parsed.year > 1970 and parsed <= date.today()
508
+ except (TypeError, ValueError):
509
+ return False
510
+
511
+
512
+ def _valid_google_fonts_exclusion_source(value):
513
+ if not isinstance(value, str):
514
+ return False
515
+ try:
516
+ parsed = urlsplit(value)
517
+ has_credentials_or_port = bool(parsed.username or parsed.password or parsed.port)
518
+ except ValueError:
519
+ return False
520
+ if parsed.scheme != "https" or has_credentials_or_port:
521
+ return False
522
+ if parsed.hostname == "fonts.google.com":
523
+ return bool(parsed.path)
524
+ return parsed.hostname == "github.com" and (
525
+ parsed.path == "/google/fonts" or parsed.path.startswith("/google/fonts/")
526
+ )
527
+
528
+
529
+ def _check_font_catalog(rows, licenses, typography, problems):
530
+ families = [row.get("Family", "") for row in rows]
531
+ family_set = set(families)
532
+ if not families or len(family_set) != len(families) or "" in family_set:
533
+ problems.append("[catalog:google-fonts] Family values must be non-empty and unique")
534
+ available = {}
535
+ for row in rows:
536
+ family = row.get("Family", "")
537
+ if not _catalog_date(row.get("Date Added")) or not _catalog_date(row.get("Last Modified")):
538
+ problems.append(f"[catalog:google-fonts:{family}] invalid or suspicious date")
539
+ expected_url = f"https://fonts.google.com/specimen/{quote_plus(family)}"
540
+ if row.get("Google Fonts URL") != expected_url:
541
+ problems.append(f"[catalog:google-fonts:{family}] invalid specimen URL")
542
+ styles = set(_split(row.get("Styles", ""), "|"))
543
+ if not styles:
544
+ problems.append(f"[catalog:google-fonts:{family}] Styles cannot be empty")
545
+ available[family] = {style.removesuffix("i") for style in styles}
546
+
547
+ source = licenses.get("source")
548
+ if (not isinstance(source, dict)
549
+ or source.get("repository") != "https://github.com/google/fonts"
550
+ or source.get("metadataFile") != "METADATA.pb"
551
+ or not GOOGLE_FONTS_REVISION.fullmatch(source.get("revision", ""))):
552
+ problems.append("[catalog:google-font-licenses] invalid source revision")
553
+ entries = licenses.get("families")
554
+ excluded = licenses.get("excludedFamilies")
555
+ if (licenses.get("familyCount") != len(rows) or not isinstance(entries, list)
556
+ or not isinstance(excluded, list)):
557
+ problems.append("[catalog:google-font-licenses] invalid counts or arrays")
558
+ return
559
+ licensed_names = set()
560
+ for item in entries:
561
+ if not isinstance(item, dict):
562
+ problems.append("[catalog:google-font-licenses] family entry must be an object")
563
+ continue
564
+ name = item.get("name")
565
+ if (not isinstance(name, str) or not name or name in licensed_names
566
+ or item.get("license") not in FONT_LICENSES
567
+ or item.get("status") != "active"
568
+ or not _catalog_date(item.get("date_added"))
569
+ or not _catalog_date(item.get("verifiedAt"))):
570
+ problems.append(f"[catalog:google-font-licenses:{name}] invalid active family")
571
+ licensed_names.add(name)
572
+ if licensed_names != family_set:
573
+ problems.append("[catalog:google-font-licenses] active families must match google-fonts.csv")
574
+ excluded_names = set()
575
+ for item in excluded:
576
+ name = item.get("name") if isinstance(item, dict) else None
577
+ source = item.get("source") if isinstance(item, dict) else None
578
+ if (not isinstance(name, str) or not name or name in excluded_names
579
+ or name in family_set or item.get("status") != "needs-review"
580
+ or not isinstance(item.get("reason"), str) or not item.get("reason")
581
+ or not _valid_google_fonts_exclusion_source(source)
582
+ or not _catalog_date(item.get("verifiedAt"))):
583
+ problems.append(f"[catalog:google-font-licenses:{name}] invalid exclusion")
584
+ excluded_names.add(name)
585
+
586
+ for row in typography:
587
+ pairing = row.get("Font Pairing Name", "")
588
+ declarations = _font_families(row.get("Google Fonts URL", ""))
589
+ for declaration in declarations:
590
+ family = declaration.split(":", 1)[0].replace("+", " ")
591
+ if family not in available:
592
+ problems.append(f"[typography:{pairing}] font absent from approved catalog: {family}")
593
+ continue
594
+ weights = _imported_weights([declaration]) or {"400"}
595
+ if not weights <= available[family]:
596
+ missing = ", ".join(sorted(weights - available[family]))
597
+ problems.append(f"[typography:{pairing}] catalog lacks {family} weights: {missing}")
598
+
599
+
600
+ def _check_phosphor_catalog(curated, manifest, problems):
601
+ source = manifest.get("source") if isinstance(manifest.get("source"), dict) else {}
602
+ imports = manifest.get("reactImports") if isinstance(manifest.get("reactImports"), dict) else {}
603
+ icons = manifest.get("icons")
604
+ if (source.get("package"), source.get("version")) != ("@phosphor-icons/core", "2.1.1"):
605
+ problems.append("[catalog:phosphor] unpinned core package")
606
+ if (source.get("reactPackage"), source.get("reactVersion")) != ("@phosphor-icons/react", "2.1.10"):
607
+ problems.append("[catalog:phosphor] unpinned React package")
608
+ if (manifest.get("status") != "active" or not _catalog_date(manifest.get("verifiedAt"))
609
+ or set(manifest.get("weights", [])) != PHOSPHOR_WEIGHTS
610
+ or imports != {"clientModule": "@phosphor-icons/react", "ssrModule": "@phosphor-icons/react/ssr"}
611
+ or not isinstance(icons, list) or manifest.get("iconCount") != len(icons)):
612
+ problems.append("[catalog:phosphor] invalid snapshot metadata")
613
+ return
614
+ names, components = {}, set()
615
+ for item in icons:
616
+ name = item.get("name") if isinstance(item, dict) else None
617
+ component = item.get("component") if isinstance(item, dict) else None
618
+ if (not name or name in names or not component or component in components
619
+ or item.get("clientImport") != f'import {{ {component} }} from "@phosphor-icons/react"'
620
+ or item.get("ssrImport") != f'import {{ {component} }} from "@phosphor-icons/react/ssr"'):
621
+ problems.append(f"[catalog:phosphor:{name}] invalid identity or imports")
622
+ names[name] = component
623
+ components.add(component)
624
+ phosphor_rows = [row for row in curated if row.get("Library") == "Phosphor"]
625
+ if manifest.get("curatedValidatedCount") != len(phosphor_rows):
626
+ problems.append("[catalog:phosphor] curated validation count is stale")
627
+ for row in phosphor_rows:
628
+ name = row.get("Icon Name", "")
629
+ component_match = re.search(r"import\s*\{\s*([A-Za-z0-9]+)", row.get("Import Code", ""))
630
+ component = component_match.group(1) if component_match else ""
631
+ if names.get(name) != component:
632
+ problems.append(f"[catalog:phosphor:{name}] curated icon is absent or mismatched")
633
+
634
+
635
+ def _check_catalog_summary(summary, licenses, phosphor, problems):
636
+ if not _catalog_date(summary.get("verifiedAt")):
637
+ problems.append("[catalog:summary] invalid verifiedAt")
638
+ counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
639
+ styles = _read_rows(DATA_DIR / "styles.csv")[1]
640
+ expected = {
641
+ "styles": {
642
+ "total": len(styles),
643
+ "searchable": sum(row.get("Status") != "deprecated" for row in styles),
644
+ "active": sum(row.get("Status") == "active" for row in styles),
645
+ "supplemental": sum(row.get("Status") == "supplemental" for row in styles),
646
+ "deprecated": sum(row.get("Status") == "deprecated" for row in styles),
647
+ },
648
+ "products": len(_read_rows(DATA_DIR / "products.csv")[1]),
649
+ "palettes": len(_read_rows(DATA_DIR / "colors.csv")[1]),
650
+ "reasoningProfiles": len(_read_rows(DATA_DIR / REASONING_FILE)[1]),
651
+ "fontPairings": len(_read_rows(DATA_DIR / "typography.csv")[1]),
652
+ "googleFonts": len(_read_rows(DATA_DIR / "google-fonts.csv")[1]),
653
+ "curatedIcons": len(_read_rows(DATA_DIR / "icons.csv")[1]),
654
+ "upstreamPhosphorIcons": phosphor.get("iconCount"),
655
+ "uxGuidelines": len(_read_rows(DATA_DIR / "ux-guidelines.csv")[1]),
656
+ "motionPresets": len(_read_rows(DATA_DIR / "motion.csv")[1]),
657
+ "chartTypes": len(_read_rows(DATA_DIR / "charts.csv")[1]),
658
+ "stacks": len(STACK_CONFIG),
659
+ "stackGuidelines": sum(len(_read_rows(DATA_DIR / config["file"])[1]) for config in STACK_CONFIG.values()),
660
+ }
661
+ for key, value in expected.items():
662
+ if counts.get(key) != value:
663
+ problems.append(f"[catalog:summary] stale count for {key}")
664
+ snapshots = summary.get("snapshots") if isinstance(summary.get("snapshots"), dict) else {}
665
+ for name in ("google-fonts.csv", "google-font-licenses.json", "icons.csv", "phosphor-icons-upstream.json"):
666
+ # Line endings are normalized so the check matches
667
+ # generate-catalog-summary.py on CRLF checkouts too.
668
+ digest = hashlib.sha256(
669
+ (DATA_DIR / name).read_bytes().replace(b"\r\n", b"\n")
670
+ ).hexdigest()
671
+ if snapshots.get(name) != {"sha256": digest}:
672
+ problems.append(f"[catalog:summary] stale snapshot for {name}")
673
+ policy = summary.get("promotionPolicy")
674
+ if policy != {"changedFamilySetRequiresExplicitApproval": True,
675
+ "relevanceGateRequired": True,
676
+ "unlicensedFamiliesExcluded": True}:
677
+ problems.append("[catalog:summary] invalid promotion policy")
678
+ pending = sorted(
679
+ ({"family": item.get("name"), "reason": item.get("reason")}
680
+ for item in licenses.get("excludedFamilies", []) if isinstance(item, dict)),
681
+ key=lambda item: (item.get("family") or "").casefold(),
682
+ )
683
+ if summary.get("pendingCandidates") != pending:
684
+ problems.append("[catalog:summary] pending candidates do not match exclusions")
685
+
686
+
687
+ def _check_catalog_contract(domain_rows, problems):
688
+ if (DATA_DIR / ".google-font-refresh.incomplete.json").exists():
689
+ problems.append("[catalog:google-fonts] incomplete refresh marker requires review")
690
+ licenses = _load_catalog_json("google-font-licenses.json", problems)
691
+ phosphor = _load_catalog_json("phosphor-icons-upstream.json", problems)
692
+ summary = _load_catalog_json("catalog-summary.json", problems)
693
+ if licenses:
694
+ _check_font_catalog(domain_rows.get("google-fonts", []), licenses,
695
+ domain_rows.get("typography", []), problems)
696
+ if phosphor:
697
+ _check_phosphor_catalog(domain_rows.get("icons", []), phosphor, problems)
698
+ if summary:
699
+ _check_catalog_summary(summary, licenses, phosphor, problems)
700
+
701
+
702
+ def _check_icon_contract(rows, problems):
703
+ for row in rows:
704
+ icon = row.get("Icon Name", "")
705
+ role = row.get("Semantic Role", "")
706
+ contexts = set(_split(row.get("Allowed Contexts", ""), "|"))
707
+ usage = row.get("Usage", "")
708
+ if role not in ICON_ROLES:
709
+ problems.append(f"[icons:{icon}] invalid Semantic Role '{role}'")
710
+ if contexts != ICON_CONTEXTS:
711
+ problems.append(f"[icons:{icon}] incomplete contextual semantics")
712
+ if re.search(r"[\u3400-\u9fff]", usage):
713
+ problems.append(f"[icons:{icon}] Usage must use canonical English")
714
+ imports = row.get("Import Code", "")
715
+ if "IconName" in imports or not ICON_IMPORT.search(imports):
716
+ problems.append(f"[icons:{icon}] invalid or placeholder icon import")
717
+ if any(not re.search(pattern, usage, re.I)
718
+ for pattern in ICON_USAGE_REQUIREMENTS):
719
+ problems.append(f"[icons:{icon}] incomplete contextual accessibility guidance")
720
+
721
+
722
+ def _check_ux_contract(rows, problems):
723
+ ux_by_issue = {row.get("Issue", ""): row for row in rows}
724
+ for issue in sorted(set(REQUIRED_UX_GUIDANCE) - set(ux_by_issue)):
725
+ problems.append(f"[ux] missing WCAG 2.2 guidance '{issue}'")
726
+ for issue, platform in REQUIRED_UX_GUIDANCE.items():
727
+ row = ux_by_issue.get(issue, {})
728
+ if row and (row.get("Platform") != platform
729
+ or row.get("Severity") not in {"Medium", "High", "Critical"}):
730
+ problems.append(f"[ux:{issue}] shifted or invalid semantic fields")
731
+
732
+
733
+ def _check_motion_contract(rows, problems):
734
+ for row in rows:
735
+ text = " ".join(row.values()).casefold()
736
+ if "reduced-motion" not in text and "user-controlled" not in text:
737
+ problems.append(f"[motion:{row.get('No')}] missing explicit motion opt-out")
738
+
739
+
740
+ def _check_app_interface_contract(rows, problems):
741
+ native_target = next((row for row in rows
742
+ if row.get("Issue") == "Touch Target Size"), {})
743
+ if not {"44pt", "48dp"} <= set(re.findall(r"44pt|48dp", " ".join(native_target.values()))):
744
+ problems.append("[web:Touch Target Size] must distinguish iOS 44pt and Android 48dp")
745
+
746
+
747
+ def _check_react_contract(rows, problems):
748
+ react_text = "\n".join(" ".join(row.values()) for row in rows)
749
+ if "useLatest" in react_text:
750
+ problems.append("[react] unqualified community useLatest guidance is not allowed")
751
+ effect_event = next((row for row in rows
752
+ if row.get("Issue") == "Effect Events"), {})
753
+ effect_text = " ".join(effect_event.values()).casefold()
754
+ if not all(phrase in effect_text for phrase in ("inside effects", "dependencies")):
755
+ problems.append("[react:Effect Events] current scope/dependency guidance is required")
756
+
757
+
758
+ def _check_landing_claims(rows, problems):
759
+ for row in rows:
760
+ optimization = row.get("Conversion Optimization", "")
761
+ if ("%" in optimization
762
+ or LANDING_QUANTIFIED_CLAIM.search(optimization)
763
+ or "best conversion" in optimization.casefold()):
764
+ problems.append(
765
+ f"[landing:{row.get('Pattern Name')}] unsupported quantitative claim"
766
+ )
767
+
768
+
769
+ def _check_core_data_contract(domain_rows, problems):
770
+ _check_color_contract(domain_rows.get("color", []), problems)
771
+ _check_chart_contract(domain_rows.get("chart", []), problems)
772
+ _check_typography_contract(domain_rows.get("typography", []), problems)
773
+ _check_icon_contract(domain_rows.get("icons", []), problems)
774
+ _check_ux_contract(domain_rows.get("ux", []), problems)
775
+ _check_motion_contract(domain_rows.get("gsap", []), problems)
776
+ _check_app_interface_contract(domain_rows.get("web", []), problems)
777
+ _check_react_contract(domain_rows.get("react", []), problems)
778
+ _check_landing_claims(domain_rows.get("landing", []), problems)
779
+
780
+
781
+ def _valid_provenance_source(source, source_index, identity, problems):
782
+ if not isinstance(source, dict):
783
+ problems.append(
784
+ f"[provenance] source {source_index} for {identity} must be an object"
785
+ )
786
+ return False
787
+
788
+ source_type, ref = source.get("type"), source.get("ref")
789
+ if source_type not in PROVENANCE_SOURCE_TYPES or not isinstance(ref, str):
790
+ problems.append(f"[provenance] invalid source for {identity}")
791
+ return False
792
+ if source_type == "official":
793
+ parsed = urlsplit(ref)
794
+ if parsed.scheme != "https" or parsed.hostname not in OFFICIAL_SOURCE_HOSTS:
795
+ problems.append(f"[provenance] unapproved official source for {identity}")
796
+ return False
797
+ elif not ref or urlsplit(ref).scheme:
798
+ problems.append(
799
+ f"[provenance] derived source must use a local dataset reference for {identity}"
800
+ )
801
+ return False
802
+ return True
803
+
804
+
805
+ def _check_stack_freshness_contract(stack, rows, problems):
806
+ """Validate curated-stack applicability and official high-impact sources."""
807
+ if stack not in STACK_OFFICIAL_HOSTS:
808
+ return
809
+
810
+ active_count = 0
811
+ for row in rows:
812
+ identity = f"[stack:{stack}:{row.get('No', '?')}]"
813
+ status = row.get("Status", "")
814
+ applies_to = row.get("Applies To", "").strip().casefold()
815
+ expected = STACK_CURRENT_APPLICABILITY[stack]
816
+ if not applies_to or not applies_to.startswith(stack):
817
+ problems.append(f"{identity} Applies To must start with '{stack}'")
818
+ elif status == "active" and not applies_to.startswith(expected):
819
+ problems.append(f"{identity} Applies To must target '{expected}'")
820
+ if status == "active":
821
+ active_count += 1
822
+ if "legacy" in applies_to:
823
+ problems.append(f"{identity} active row cannot target legacy versions")
824
+ elif status == "deprecated" and "legacy" not in applies_to:
825
+ problems.append(f"{identity} deprecated row must be visibly legacy")
826
+ elif status == "unverified":
827
+ problems.append(f"{identity} curated stack row cannot remain unverified")
828
+
829
+ if row.get("Severity") not in {"Critical", "High"}:
830
+ continue
831
+ docs_url = row.get("Docs URL", "")
832
+ parsed = urlsplit(docs_url)
833
+ if parsed.scheme != "https" or parsed.hostname not in STACK_OFFICIAL_HOSTS[stack]:
834
+ problems.append(f"{identity} Critical/High row requires an official Docs URL")
835
+ if not _valid_date(row.get("Verified At", "")):
836
+ problems.append(f"{identity} Critical/High row requires ISO Verified At")
837
+
838
+ if not active_count and stack != "uwp":
839
+ problems.append(f"[stack:{stack}] requires at least one active current row")
840
+
841
+
842
+ def _valid_dataset_source_key(source_file, source_key, identity, problems):
843
+ if source_file not in CORE_PROVENANCE_FILES:
844
+ problems.append(f"[provenance] unknown dataset-contract sourceFile for {identity}")
845
+ return False
846
+ path = DATA_DIR / source_file
847
+ try:
848
+ headers, rows = _read_rows(path)
849
+ except (csv.Error, UnicodeDecodeError, OSError):
850
+ problems.append(f"[provenance] unreadable sourceFile for {identity}")
851
+ return False
852
+ scope = source_key.get("Scope") if isinstance(source_key, dict) else None
853
+ if not isinstance(scope, str) or not scope.startswith("No "):
854
+ problems.append(f"[provenance] dataset scope must bind rows for {identity}")
855
+ return False
856
+ row_part = scope.split(";", 1)[0]
857
+ referenced = {int(value) for value in re.findall(r"\b\d+\b", row_part)}
858
+ for start, end in re.findall(r"(\d+)\s*-\s*(\d+)", row_part):
859
+ referenced.update(range(int(start), int(end) + 1))
860
+ row_ids = {int(row["No"]) for row in rows if row.get("No", "").isdigit()}
861
+ if not referenced or not referenced <= row_ids:
862
+ problems.append(f"[provenance] dataset scope references unknown rows for {identity}")
863
+ return False
864
+ semantic_headers = [header for header in headers if header != "No"]
865
+ if not any(
866
+ re.search(rf"\b{re.escape(header)}\b", scope, re.I)
867
+ for header in semantic_headers):
868
+ problems.append(f"[provenance] dataset scope must bind source fields for {identity}")
869
+ return False
870
+ return True
871
+
872
+
873
+ def _valid_catalog_source_key(source_file, source_key, identity, problems):
874
+ if source_file not in CATALOG_PROVENANCE_FILES:
875
+ problems.append(f"[provenance] unknown catalog sourceFile for {identity}")
876
+ return False
877
+ if not (DATA_DIR / source_file).is_file():
878
+ problems.append(f"[provenance] missing catalog sourceFile for {identity}")
879
+ return False
880
+ snapshot = source_key.get("Snapshot") if isinstance(source_key, dict) else None
881
+ count = source_key.get("Count") if isinstance(source_key, dict) else None
882
+ if snapshot != "catalog-summary.json" or isinstance(count, bool) or not isinstance(count, int) or count <= 0:
883
+ problems.append(f"[provenance] catalog sourceKey must bind snapshot and count for {identity}")
884
+ return False
885
+ try:
886
+ if source_file.endswith(".csv"):
887
+ expected_count = len(_read_rows(DATA_DIR / source_file)[1])
888
+ else:
889
+ payload = json.loads((DATA_DIR / source_file).read_text(encoding="utf-8"))
890
+ expected_count = payload.get("iconCount")
891
+ except (csv.Error, json.JSONDecodeError, OSError, UnicodeDecodeError):
892
+ problems.append(f"[provenance] unreadable catalog sourceFile for {identity}")
893
+ return False
894
+ if count != expected_count:
895
+ problems.append(f"[provenance] catalog count is stale for {identity}")
896
+ return False
897
+ return True
898
+
899
+
900
+ def _check_provenance(reasoning, styles, problems):
901
+ path = DATA_DIR / "data-provenance.json"
902
+ try:
903
+ payload = json.loads(path.read_text(encoding="utf-8"))
904
+ except (OSError, json.JSONDecodeError) as error:
905
+ problems.append(f"[provenance] invalid data-provenance.json: {error}")
906
+ return
907
+ if (not isinstance(payload, dict) or payload.get("schemaVersion") != 1
908
+ or not isinstance(payload.get("records"), list)):
909
+ problems.append("[provenance] schemaVersion 1 and records array are required")
910
+ return
911
+ covered, covered_styles, identities = set(), set(), set()
912
+ valid_records = []
913
+ for index, record in enumerate(payload["records"]):
914
+ if not isinstance(record, dict):
915
+ problems.append(f"[provenance] record {index} must be an object")
916
+ continue
917
+ identity = (record.get("entityKind"), record.get("entityId"))
918
+ if identity[0] not in PROVENANCE_KINDS or not isinstance(identity[1], str) or not identity[1]:
919
+ problems.append(f"[provenance] invalid identity at record {index}")
920
+ continue
921
+ if identity in identities:
922
+ problems.append(f"[provenance] duplicate identity {identity}")
923
+ identities.add(identity)
924
+ if record.get("status") not in PROVENANCE_STATUSES:
925
+ problems.append(f"[provenance] invalid status for {identity}")
926
+ if record.get("sla") not in PROVENANCE_SLAS:
927
+ problems.append(f"[provenance] invalid sla for {identity}")
928
+ if not _valid_date(record.get("verifiedAt")):
929
+ problems.append(f"[provenance] invalid verifiedAt for {identity}")
930
+ if not isinstance(record.get("sourceFile"), str) or not record.get("sourceFile"):
931
+ problems.append(f"[provenance] sourceFile required for {identity}")
932
+ if not isinstance(record.get("sourceKey"), dict) or not record.get("sourceKey"):
933
+ problems.append(f"[provenance] sourceKey object required for {identity}")
934
+ applies_to = record.get("appliesTo")
935
+ if (not isinstance(applies_to, list) or not applies_to
936
+ or any(value not in PROVENANCE_APPLIES_TO for value in applies_to)):
937
+ problems.append(f"[provenance] invalid appliesTo for {identity}")
938
+ confidence = record.get("confidence")
939
+ if not _valid_confidence(confidence):
940
+ problems.append(f"[provenance] invalid confidence for {identity}")
941
+ sources = record.get("sources")
942
+ if not isinstance(sources, list) or not sources:
943
+ problems.append(f"[provenance] sources required for {identity}")
944
+ sources = []
945
+ valid_sources = [
946
+ source for source_index, source in enumerate(sources)
947
+ if _valid_provenance_source(source, source_index, identity, problems)
948
+ ]
949
+ source_types = {source["type"] for source in valid_sources}
950
+ if record.get("sla") == "manual-verified" and source_types <= {"derived"}:
951
+ problems.append(
952
+ f"[provenance] {identity} cannot be manual-verified from derived sources only"
953
+ )
954
+ source_key = record.get("sourceKey")
955
+ source_key = source_key if isinstance(source_key, dict) else {}
956
+ if identity[0] == "reasoning" and source_key.get("UI_Category"):
957
+ covered.add(source_key["UI_Category"])
958
+ if identity[0] == "style" and source_key.get("Style ID"):
959
+ covered_styles.add(source_key["Style ID"])
960
+ source_key_valid = True
961
+ if identity[0] == "dataset-contract":
962
+ source_key_valid = _valid_dataset_source_key(
963
+ record.get("sourceFile"), source_key, identity, problems
964
+ )
965
+ elif identity[0] == "catalog-snapshot":
966
+ source_key_valid = _valid_catalog_source_key(
967
+ record.get("sourceFile"), source_key, identity, problems
968
+ )
969
+ if valid_sources and source_key_valid:
970
+ valid_records.append(record)
971
+ new_rows = {row["UI_Category"] for row in reasoning if int(row.get("No", 0)) >= 162}
972
+ if new_rows - covered:
973
+ problems.append(f"[provenance] missing new reasoning rows: {', '.join(sorted(new_rows - covered))}")
974
+ new_styles = {row["Style ID"] for row in styles if int(row.get("No", 0)) > 85}
975
+ if new_styles - covered_styles:
976
+ problems.append(
977
+ f"[provenance] missing new style rows: {', '.join(sorted(new_styles - covered_styles))}"
978
+ )
979
+ core_files = {
980
+ record.get("sourceFile") for record in valid_records
981
+ if record.get("entityKind") == "dataset-contract"
982
+ and any(isinstance(source, dict) and source.get("type") == "official"
983
+ for source in record.get("sources", []))
984
+ }
985
+ if CORE_PROVENANCE_FILES - core_files:
986
+ problems.append(
987
+ "[provenance] missing official core dataset records: "
988
+ + ", ".join(sorted(CORE_PROVENANCE_FILES - core_files))
989
+ )
990
+ catalog_ids = {
991
+ record.get("entityId") for record in valid_records
992
+ if record.get("entityKind") == "catalog-snapshot"
993
+ and any(isinstance(source, dict) and source.get("type") == "official"
994
+ for source in record.get("sources", []))
995
+ }
996
+ if CATALOG_PROVENANCE_IDS - catalog_ids:
997
+ problems.append(
998
+ "[provenance] missing official catalog snapshots: "
999
+ + ", ".join(sorted(CATALOG_PROVENANCE_IDS - catalog_ids))
1000
+ )
1001
+
1002
+
1003
+ def validate():
1004
+ """Return every semantic data problem without terminating the process."""
1005
+ problems, domain_rows = [], {}
1006
+
1007
+ for domain, config in CSV_CONFIG.items():
1008
+ _, rows = _check_file(
1009
+ f"domain:{domain}", DATA_DIR / config["file"],
1010
+ config["search_cols"], config["output_cols"], problems,
1011
+ ) or ([], [])
1012
+ domain_rows[domain] = rows
1013
+
1014
+ for stack, config in STACK_CONFIG.items():
1015
+ headers, rows = _check_file(
1016
+ f"stack:{stack}", DATA_DIR / config["file"],
1017
+ _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], problems,
1018
+ ) or ([], [])
1019
+ required = {"Applies To", "Status", "Verified At"}
1020
+ if headers and not required <= set(headers):
1021
+ problems.append(f"[stack:{stack}] missing contract fields {sorted(required - set(headers))}")
1022
+ for row in rows:
1023
+ status, verified = row.get("Status", ""), row.get("Verified At", "")
1024
+ if status not in STACK_STATUSES:
1025
+ problems.append(f"[stack:{stack}] invalid Status '{status}'")
1026
+ if status != "unverified" and not _valid_date(verified):
1027
+ problems.append(f"[stack:{stack}] active rows require ISO Verified At")
1028
+ _check_stack_freshness_contract(stack, rows, problems)
1029
+
1030
+ reasoning_path = DATA_DIR / REASONING_FILE
1031
+ if reasoning_path.exists():
1032
+ _, reasoning = _check_file(
1033
+ "reasoning", reasoning_path, ["UI_Category"],
1034
+ ["UI_Category", "Decision_Rules", "Reasoning", "Confidence"], problems,
1035
+ ) or ([], [])
1036
+ else:
1037
+ problems.append(f"[reasoning] missing file: {reasoning_path}")
1038
+ reasoning = []
1039
+
1040
+ styles = domain_rows.get("style", [])
1041
+ products = domain_rows.get("product", [])
1042
+ colors = domain_rows.get("color", [])
1043
+ landing = domain_rows.get("landing", [])
1044
+ patterns = {row.get("Pattern Name", "") for row in landing}
1045
+ for row in landing:
1046
+ patterns.update(_split(row.get("Aliases", ""), "|"))
1047
+ pattern_ids = {row.get("Pattern ID", "") for row in landing}
1048
+ if len(pattern_ids) != len(landing) or "" in pattern_ids:
1049
+ problems.append("[landing] Pattern ID values must be non-empty and unique")
1050
+ landing_identities = {}
1051
+ for row in landing:
1052
+ identities = [row.get("Pattern ID", ""), row.get("Pattern Name", "")]
1053
+ identities.extend(_split(row.get("Aliases", ""), "|"))
1054
+ for identity in identities:
1055
+ folded = identity.strip().casefold()
1056
+ owner = landing_identities.get(folded)
1057
+ if not folded:
1058
+ problems.append("[landing] Pattern Name values must be non-empty")
1059
+ elif owner and owner != row.get("Pattern ID"):
1060
+ problems.append(
1061
+ f"[landing] ambiguous identity '{identity}' -> {owner}, "
1062
+ f"{row.get('Pattern ID')}"
1063
+ )
1064
+ else:
1065
+ landing_identities[folded] = row.get("Pattern ID")
1066
+ style_ids = _check_style_contract(
1067
+ styles, products, reasoning, problems,
1068
+ {"landing": pattern_ids, "style": {row.get("Style ID", "") for row in styles}})
1069
+ _check_reasoning_contract(products, colors, reasoning, style_ids, patterns, problems)
1070
+ _check_core_data_contract(domain_rows, problems)
1071
+ _check_catalog_contract(domain_rows, problems)
1072
+ for row in landing:
1073
+ sections = row.get("Section Order", "").split(" > ")
1074
+ if len(sections) < 2 or any(re.match(r"^\d+\.\s", part) for part in sections):
1075
+ problems.append(f"[landing:{row.get('Pattern Name')}] invalid Section Order delimiter")
1076
+ _check_provenance(reasoning, styles, problems)
1077
+
1078
+ return problems
1079
+
1080
+
1081
+ def main():
1082
+ problems = validate()
1083
+
1084
+ if problems:
1085
+ print(f"FAILED: {len(problems)} data integrity issue(s) found:\n")
1086
+ for p in problems:
1087
+ print(f" - {p}")
1088
+ sys.exit(1)
1089
+
1090
+ print(f"OK: validated {len(CSV_CONFIG)} domain files, {len(STACK_CONFIG)} stack files, and ui-reasoning.csv")
1091
+ sys.exit(0)
1092
+
1093
+
1094
+ if __name__ == "__main__":
1095
+ main()