@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
@@ -5,6 +5,7 @@ UI/UX Pro Max Core - BM25 search engine for UI/UX style guides
5
5
  """
6
6
 
7
7
  import csv
8
+ import difflib
8
9
  import re
9
10
  from pathlib import Path
10
11
  from math import log
@@ -17,8 +18,8 @@ MAX_RESULTS = 3
17
18
  CSV_CONFIG = {
18
19
  "style": {
19
20
  "file": "styles.csv",
20
- "search_cols": ["Style Category", "Keywords", "Best For", "Type", "AI Prompt Keywords"],
21
- "output_cols": ["Style Category", "Type", "Keywords", "Primary Colors", "Effects & Animation", "Best For", "Light Mode ✓", "Dark Mode ✓", "Performance", "Accessibility", "Framework Compatibility", "Complexity", "AI Prompt Keywords", "CSS/Technical Keywords", "Implementation Checklist", "Design System Variables"]
21
+ "search_cols": ["Style ID", "Style Category", "Aliases", "Keywords", "Best For", "Type", "AI Prompt Keywords"],
22
+ "output_cols": ["Style ID", "Style Category", "Aliases", "Status", "Parent Style ID", "Preferred Mode", "Type", "Keywords", "Primary Colors", "Effects & Animation", "Best For", "Light Mode ✓", "Dark Mode ✓", "Performance", "Accessibility", "Framework Compatibility", "Complexity", "AI Prompt Keywords", "CSS/Technical Keywords", "Implementation Checklist", "Design System Variables"]
22
23
  },
23
24
  "color": {
24
25
  "file": "colors.csv",
@@ -28,12 +29,12 @@ CSV_CONFIG = {
28
29
  "chart": {
29
30
  "file": "charts.csv",
30
31
  "search_cols": ["Data Type", "Keywords", "Best Chart Type", "When to Use", "When NOT to Use", "Accessibility Notes"],
31
- "output_cols": ["Data Type", "Keywords", "Best Chart Type", "Secondary Options", "When to Use", "When NOT to Use", "Data Volume Threshold", "Color Guidance", "Accessibility Grade", "Accessibility Notes", "A11y Fallback", "Library Recommendation", "Interactive Level"]
32
+ "output_cols": ["Data Type", "Keywords", "Best Chart Type", "Secondary Options", "When to Use", "When NOT to Use", "Data Volume Threshold", "Color Guidance", "Accessibility Grade", "Accessibility Risk", "Accessibility Notes", "A11y Fallback", "Library Recommendation", "Interactive Level"]
32
33
  },
33
34
  "landing": {
34
35
  "file": "landing.csv",
35
- "search_cols": ["Pattern Name", "Keywords", "Conversion Optimization", "Section Order"],
36
- "output_cols": ["Pattern Name", "Keywords", "Section Order", "Primary CTA Placement", "Color Strategy", "Conversion Optimization"]
36
+ "search_cols": ["Pattern ID", "Pattern Name", "Aliases", "Keywords", "Conversion Optimization", "Section Order"],
37
+ "output_cols": ["Pattern ID", "Pattern Name", "Aliases", "Keywords", "Section Order", "Primary CTA Placement", "Color Strategy", "Conversion Optimization"]
37
38
  },
38
39
  "product": {
39
40
  "file": "products.csv",
@@ -52,8 +53,13 @@ CSV_CONFIG = {
52
53
  },
53
54
  "icons": {
54
55
  "file": "icons.csv",
55
- "search_cols": ["Category", "Icon Name", "Keywords", "Best For"],
56
- "output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style"]
56
+ "search_cols": ["Category", "Icon Name", "Keywords", "Best For", "Library"],
57
+ "output_cols": ["Category", "Icon Name", "Keywords", "Library", "Import Code", "Usage", "Best For", "Style", "Semantic Role", "Allowed Contexts"]
58
+ },
59
+ "gsap": {
60
+ "file": "motion.csv",
61
+ "search_cols": ["Category", "Intensity Tier", "Keywords", "Trigger"],
62
+ "output_cols": ["Category", "Intensity Tier", "Trigger", "Duration", "Easing", "GSAP Snippet", "Framework Notes", "Do", "Don't", "Performance Notes"]
57
63
  },
58
64
  "react": {
59
65
  "file": "react-performance.csv",
@@ -72,6 +78,14 @@ CSV_CONFIG = {
72
78
  }
73
79
  }
74
80
 
81
+ # Output columns whose content (code samples, checklists) must never be
82
+ # hard-truncated for display -- truncating mid-snippet destroys the value.
83
+ UNTRUNCATED_COLS = {
84
+ "Code Example Good", "Code Example Bad", "Code Good", "Code Bad",
85
+ "Implementation Checklist", "Design System Variables", "CSS Import",
86
+ "Tailwind Config", "GSAP Snippet",
87
+ }
88
+
75
89
  STACK_CONFIG = {
76
90
  "react": {"file": "stacks/react.csv"},
77
91
  "nextjs": {"file": "stacks/nextjs.csv"},
@@ -89,16 +103,180 @@ STACK_CONFIG = {
89
103
  "threejs": {"file": "stacks/threejs.csv"},
90
104
  "angular": {"file": "stacks/angular.csv"},
91
105
  "laravel": {"file": "stacks/laravel.csv"},
106
+ "javafx": {"file": "stacks/javafx.csv"},
107
+ "wpf": {"file": "stacks/wpf.csv"},
108
+ "winui": {"file": "stacks/winui.csv"},
109
+ "avalonia": {"file": "stacks/avalonia.csv"},
110
+ "uno": {"file": "stacks/uno.csv"},
111
+ "uwp": {"file": "stacks/uwp.csv"},
92
112
  }
93
113
 
94
114
  # Common columns for all stacks
95
115
  _STACK_COLS = {
96
- "search_cols": ["Category", "Guideline", "Description", "Do", "Don't"],
97
- "output_cols": ["Category", "Guideline", "Description", "Do", "Don't", "Code Good", "Code Bad", "Severity", "Docs URL"]
116
+ "search_cols": ["Category", "Guideline", "Description", "Do", "Don't",
117
+ "Code Good", "Code Bad"],
118
+ "output_cols": ["Category", "Guideline", "Description", "Do", "Don't",
119
+ "Code Good", "Code Bad", "Severity", "Docs URL",
120
+ "Applies To", "Status", "Verified At"]
121
+ }
122
+
123
+ WEB_STACK_CURRENT_MAJORS = {
124
+ "react": 19,
125
+ "nextjs": 16,
126
+ "vue": 3,
127
+ "svelte": 5,
128
+ "astro": 7,
129
+ "angular": 22,
130
+ "html-tailwind": 4,
131
+ "nuxtjs": 4,
132
+ "nuxt-ui": 4,
133
+ }
134
+ WEB_STACKS = frozenset(WEB_STACK_CURRENT_MAJORS) | {"shadcn"}
135
+
136
+ STACK_CURRENT_VERSIONS = {
137
+ **{stack: (major,) for stack, major in WEB_STACK_CURRENT_MAJORS.items()},
138
+ "react-native": (0, 86),
139
+ "flutter": (3, 44),
140
+ "swiftui": (16,),
141
+ "jetpack-compose": (1, 11),
142
+ "avalonia": (12,),
143
+ "winui": (3,),
144
+ "javafx": (26,),
145
+ "threejs": (0, 185),
146
+ "laravel": (13,),
147
+ }
148
+ LEGACY_ONLY_STACKS = frozenset({"uwp"})
149
+ STACK_CURRENT_APPLICABILITY = {
150
+ "react": "react 19.2.x",
151
+ "nextjs": "nextjs 16.2",
152
+ "vue": "vue 3.5.x",
153
+ "svelte": "svelte 5",
154
+ "astro": "astro 7.1.6",
155
+ "angular": "angular 22.x",
156
+ "html-tailwind": "html-tailwind 4.3",
157
+ "shadcn": "shadcn cli 4",
158
+ "nuxtjs": "nuxtjs 4.5",
159
+ "nuxt-ui": "nuxt-ui 4.11.1",
160
+ "react-native": "react-native 0.86.x",
161
+ "flutter": "flutter 3.44.x",
162
+ "swiftui": "swiftui current",
163
+ "jetpack-compose": "jetpack-compose 1.11.4",
164
+ "avalonia": "avalonia 12",
165
+ "uwp": "uwp legacy",
166
+ "winui": "winui current",
167
+ "wpf": "wpf current",
168
+ "uno": "uno current",
169
+ "javafx": "javafx 26",
170
+ "threejs": "threejs 0.185.1",
171
+ "laravel": "laravel 13.x",
172
+ }
173
+
174
+ _STACK_QUERY_NAMES = {
175
+ "react": r"react",
176
+ "nextjs": r"next(?:\.js|js)?",
177
+ "vue": r"vue",
178
+ "svelte": r"svelte",
179
+ "astro": r"astro",
180
+ "angular": r"angular",
181
+ "html-tailwind": r"tailwind(?:\s*css)?",
182
+ "nuxtjs": r"nuxt(?:\.js|js)?",
183
+ "nuxt-ui": r"nuxt\s*ui",
184
+ "react-native": r"react[\s-]*native",
185
+ "flutter": r"flutter",
186
+ "swiftui": r"(?:ios|swiftui\s+ios)",
187
+ "jetpack-compose": r"(?:jetpack\s*)?compose",
188
+ "avalonia": r"avalonia",
189
+ "winui": r"winui",
190
+ "javafx": r"javafx",
191
+ "threejs": r"three(?:\.js|js)?",
192
+ "laravel": r"laravel",
98
193
  }
99
194
 
100
195
  AVAILABLE_STACKS = list(STACK_CONFIG.keys())
101
196
 
197
+ _INDEX_VERSION = 2
198
+ _SEARCH_CALIBRATION_VERSION = "2026-08-12-v1"
199
+
200
+ # Search calibration uses evidence coverage first; raw BM25 floors are kept
201
+ # domain-specific because corpora vary greatly in size and document length.
202
+ # Values are intentionally conservative and are measured by the calibration suite.
203
+ _DOMAIN_SCORE_FLOORS = {
204
+ "style": 4.3, "landing": 4.0, "product": 6.0, "icons": 5.8,
205
+ "react": 3.3,
206
+ }
207
+ _SEARCH_THRESHOLDS = {
208
+ domain: {"min_score": _DOMAIN_SCORE_FLOORS.get(domain, 0.0),
209
+ "min_margin": 0.0, "min_coverage": 0.5 if domain == "landing" else 0.0}
210
+ for domain in CSV_CONFIG
211
+ }
212
+ _STACK_THRESHOLD = {"min_score": 3.6, "min_margin": 0.0, "min_coverage": 1 / 3}
213
+ _NO_THRESHOLD = {"min_score": 0.0, "min_margin": 0.0, "min_coverage": 0.0}
214
+ _STYLE_IDENTITY_FIELDS = ("Style ID", "Style Category", "Aliases")
215
+ _LANDING_IDENTITY_FIELDS = ("Pattern ID", "Pattern Name", "Aliases")
216
+ _DOMAIN_QUERY_REWRITES = {
217
+ "color": {term: None for term in (
218
+ "color", "palette", "hex", "rgb", "token", "semantic",
219
+ "destructive", "muted", "foreground")},
220
+ "landing": {"testimonial": "testimonials"},
221
+ "style": {"css": None, "implementation": None, "variable": None,
222
+ "checklist": None, "tailwind": None},
223
+ "ux": {"ux": "accessibility", "usability": "accessibility",
224
+ "wcag": "accessibility"},
225
+ "google-fonts": {"typography": "font"},
226
+ "icons": {"lucide": None, "symbol": None, "glyph": None, "pictogram": None},
227
+ "gsap": {"gsap": "animation", "quickto": None, "scrolltrigger": "scroll",
228
+ "flip plugin": None, "splittext": None},
229
+ "react": {"nextjs": "react", "usecallback": "memoization",
230
+ "useeffect": "effects"},
231
+ "web": {"aria": "accessibility", "outline": "focus",
232
+ "semantic": None, "autocomplete": "input", "preconnect": None},
233
+ }
234
+
235
+
236
+ # ============ TOKENIZATION ============
237
+ # Common two-letter/three-letter words that add noise without adding search
238
+ # signal. Deliberately short -- domain-relevant short tokens (ui, ux, ai,
239
+ # css, 3d, js, os, md, gsap) must stay searchable, which is why we don't
240
+ # filter purely by length.
241
+ _STOPWORDS = {
242
+ "to", "in", "on", "at", "is", "of", "by", "or", "an", "if", "no", "so",
243
+ "do", "be", "we", "it", "as", "the", "and", "for", "are", "was",
244
+ }
245
+
246
+ # Query/corpus normalization so common spelling variants match each other.
247
+ # Keep this a plain dict (stdlib only, no fuzzy-matching dependency).
248
+ _SYNONYMS = {
249
+ "q&a": "question answer",
250
+ "e-commerce": "ecommerce",
251
+ "dark-mode": "dark",
252
+ "darkmode": "dark",
253
+ "light-mode": "light",
254
+ "lightmode": "light",
255
+ "a11y": "accessibility",
256
+ "nav": "navigation",
257
+ "sign-up": "signup",
258
+ "log-in": "login",
259
+ "colour": "color",
260
+ "colours": "colors",
261
+ "customisation": "customization",
262
+ "organisation": "organization",
263
+ "behaviour": "behavior",
264
+ "ux/ui": "ux ui",
265
+ }
266
+
267
+ _SYNONYM_PATTERNS = [
268
+ (re.compile(r"(?<!\w)" + re.escape(variant) + r"(?!\w)", re.IGNORECASE), canonical)
269
+ for variant, canonical in sorted(_SYNONYMS.items(), key=lambda item: len(item[0]), reverse=True)
270
+ ]
271
+
272
+
273
+ def _normalize(text):
274
+ """Apply longest-first synonym substitution at token boundaries."""
275
+ normalized = str(text)
276
+ for pattern, canonical in _SYNONYM_PATTERNS:
277
+ normalized = pattern.sub(canonical, normalized)
278
+ return normalized
279
+
102
280
 
103
281
  # ============ BM25 IMPLEMENTATION ============
104
282
  class BM25:
@@ -113,11 +291,13 @@ class BM25:
113
291
  self.idf = {}
114
292
  self.doc_freqs = defaultdict(int)
115
293
  self.N = 0
294
+ self._term_freqs = [] # precomputed per-doc term frequencies
116
295
 
117
296
  def tokenize(self, text):
118
- """Lowercase, split, remove punctuation, filter short words"""
119
- text = re.sub(r'[^\w\s]', ' ', str(text).lower())
120
- return [w for w in text.split() if len(w) > 2]
297
+ """Lowercase, normalize synonyms, split, remove punctuation, filter stopwords"""
298
+ text = _normalize(str(text).lower())
299
+ text = re.sub(r'[^\w\s]', ' ', text)
300
+ return [w for w in text.split() if len(w) >= 2 and w not in _STOPWORDS]
121
301
 
122
302
  def fit(self, documents):
123
303
  """Build BM25 index from documents"""
@@ -126,14 +306,16 @@ class BM25:
126
306
  if self.N == 0:
127
307
  return
128
308
  self.doc_lengths = [len(doc) for doc in self.corpus]
129
- self.avgdl = sum(self.doc_lengths) / self.N
309
+ self.avgdl = sum(self.doc_lengths) / self.N or 1.0
130
310
 
311
+ self._term_freqs = []
131
312
  for doc in self.corpus:
132
- seen = set()
313
+ tf = defaultdict(int)
133
314
  for word in doc:
134
- if word not in seen:
135
- self.doc_freqs[word] += 1
136
- seen.add(word)
315
+ tf[word] += 1
316
+ self._term_freqs.append(tf)
317
+ for word in tf:
318
+ self.doc_freqs[word] += 1
137
319
 
138
320
  for word, freq in self.doc_freqs.items():
139
321
  self.idf[word] = log((self.N - freq + 0.5) / (freq + 0.5) + 1)
@@ -143,16 +325,14 @@ class BM25:
143
325
  query_tokens = self.tokenize(query)
144
326
  scores = []
145
327
 
146
- for idx, doc in enumerate(self.corpus):
328
+ for idx in range(self.N):
147
329
  score = 0
148
330
  doc_len = self.doc_lengths[idx]
149
- term_freqs = defaultdict(int)
150
- for word in doc:
151
- term_freqs[word] += 1
331
+ term_freqs = self._term_freqs[idx]
152
332
 
153
333
  for token in query_tokens:
154
334
  if token in self.idf:
155
- tf = term_freqs[token]
335
+ tf = term_freqs.get(token, 0)
156
336
  idf = self.idf[token]
157
337
  numerator = tf * (self.k1 + 1)
158
338
  denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
@@ -162,86 +342,616 @@ class BM25:
162
342
 
163
343
  return sorted(scores, key=lambda x: x[1], reverse=True)
164
344
 
345
+ def vocabulary(self):
346
+ """All indexed terms, for suggestion/typo-recovery purposes."""
347
+ return list(self.idf.keys())
348
+
349
+
350
+ # ============ CSV / INDEX CACHE ============
351
+ # Data files are small and reused across multiple domain searches within a
352
+ # single --design-system run; avoid re-reading + re-indexing the same file
353
+ # repeatedly in one process.
354
+ _csv_cache = {} # filepath -> (signature, rows)
355
+ _bm25_cache = {} # (path, fields, scorer version) -> (file signature, index)
356
+
357
+
358
+ def _file_signature(filepath):
359
+ stat = filepath.stat()
360
+ return stat.st_mtime_ns, stat.st_size
361
+
362
+
363
+ def _load_csv_snapshot(filepath, attempts=3):
364
+ """Return rows and the verified signature of the bytes they came from."""
365
+ signature = _file_signature(filepath)
366
+ cached = _csv_cache.get(filepath)
367
+ if cached and cached[0] == signature:
368
+ return cached[1], signature
369
+
370
+ for _ in range(attempts):
371
+ before = _file_signature(filepath)
372
+ with open(filepath, 'r', encoding='utf-8') as f:
373
+ rows = list(csv.DictReader(f))
374
+ after = _file_signature(filepath)
375
+ if before == after:
376
+ _csv_cache[filepath] = (after, rows)
377
+ return rows, after
378
+ raise OSError(f"File changed while reading: {filepath}")
379
+
165
380
 
166
- # ============ SEARCH FUNCTIONS ============
167
381
  def _load_csv(filepath):
168
- """Load CSV and return list of dicts"""
169
- with open(filepath, 'r', encoding='utf-8') as f:
170
- return list(csv.DictReader(f))
382
+ """Load CSV rows from a stable, signature-verified snapshot."""
383
+ return _load_csv_snapshot(filepath)[0]
171
384
 
172
385
 
173
- def _search_csv(filepath, search_cols, output_cols, query, max_results):
174
- """Core search function using BM25"""
175
- if not filepath.exists():
176
- return []
386
+ def _get_bm25(filepath, search_cols, data, signature=None, cache_variant=""):
387
+ """Fitted index with cache identity covering fields and scorer version."""
388
+ key = (filepath, tuple(search_cols), _INDEX_VERSION, cache_variant)
389
+ if signature is None:
390
+ cached_rows = _csv_cache.get(filepath)
391
+ signature = (cached_rows[0] if cached_rows and cached_rows[1] is data
392
+ else _file_signature(filepath))
393
+ cached = _bm25_cache.get(key)
394
+ if cached and cached[0] == signature:
395
+ return cached[1]
177
396
 
178
- data = _load_csv(filepath)
397
+ documents = [" ".join(str(row.get(column, "")) for column in search_cols)
398
+ for row in data]
399
+ index = BM25()
400
+ index.fit(documents)
401
+ _bm25_cache[key] = (signature, index)
402
+ return index
179
403
 
180
- # Build documents from search columns
181
- documents = [" ".join(str(row.get(col, "")) for col in search_cols) for row in data]
182
404
 
183
- # BM25 search
184
- bm25 = BM25()
185
- bm25.fit(documents)
186
- ranked = bm25.score(query)
405
+ # ============ SEARCH FUNCTIONS ============
406
+ def _query_coverage(index, query):
407
+ tokens = set(index.tokenize(query))
408
+ if not tokens:
409
+ return 0.0
410
+ vocabulary = set(index.vocabulary())
411
+ return sum(token in vocabulary for token in tokens) / len(tokens)
412
+
413
+
414
+ def _search_csv_detailed(filepath, search_cols, output_cols, query, max_results,
415
+ threshold=None, routing_domain=None, row_filter=None,
416
+ cache_variant=""):
417
+ """Calibrated search returning results, index, and internal diagnostics."""
418
+ if not filepath.exists():
419
+ return [], None, {"reason": "missing-file"}
420
+
421
+ try:
422
+ data, signature = _load_csv_snapshot(filepath)
423
+ except (csv.Error, OSError, UnicodeDecodeError):
424
+ return [], None, {
425
+ "reason": "read-error",
426
+ "error": f"Unable to read search data: {filepath.name}",
427
+ }
428
+
429
+ if not data:
430
+ return [], None, {"reason": "empty-data"}
431
+
432
+ if row_filter is not None:
433
+ data = [row for row in data if row_filter(row)]
434
+ if not data:
435
+ return [], None, {"reason": "empty-data"}
436
+
437
+ bm25 = _get_bm25(filepath, search_cols, data, signature, cache_variant)
438
+ search_query, rewrites = _rewrite_query_for_domain(query, routing_domain, bm25)
439
+ ranked = bm25.score(search_query)
440
+ threshold = threshold or _NO_THRESHOLD
441
+ top_score = ranked[0][1] if ranked else 0.0
442
+ runner_up_score = ranked[1][1] if len(ranked) > 1 else 0.0
443
+ coverage = _query_coverage(bm25, search_query)
444
+ abstain = (top_score <= threshold["min_score"]
445
+ or coverage < threshold["min_coverage"]
446
+ or (threshold["min_margin"] > 0
447
+ and top_score - runner_up_score < threshold["min_margin"]))
187
448
 
188
- # Get top results with score > 0
189
449
  results = []
190
- for idx, score in ranked[:max_results]:
191
- if score > 0:
450
+ if not abstain:
451
+ for idx, score in ranked[:max_results]:
452
+ if score <= 0:
453
+ continue
192
454
  row = data[idx]
193
455
  results.append({col: row.get(col, "") for col in output_cols if col in row})
194
456
 
195
- return results
457
+ diagnostic = {"normalized_query": _normalize(query), "search_query": search_query,
458
+ "query_rewrites": rewrites, "top_score": top_score,
459
+ "runner_up_score": runner_up_score, "margin": top_score - runner_up_score,
460
+ "token_coverage": coverage, "abstained": abstain,
461
+ "calibration_version": _SEARCH_CALIBRATION_VERSION,
462
+ "reason": "low-confidence" if abstain else "matched"}
463
+ return results, bm25, diagnostic
196
464
 
197
465
 
198
- def detect_domain(query):
199
- """Auto-detect the most relevant domain from query"""
200
- query_lower = query.lower()
466
+ def _search_csv(filepath, search_cols, output_cols, query, max_results):
467
+ """Backward-compatible internal search tuple used by existing callers/tests."""
468
+ results, index, _ = _search_csv_detailed(
469
+ filepath, search_cols, output_cols, query, max_results)
470
+ return results, index
471
+
472
+
473
+ def _passes_threshold(index, query, threshold):
474
+ ranked = index.score(query)
475
+ top_score = ranked[0][1] if ranked else 0.0
476
+ runner_up_score = ranked[1][1] if len(ranked) > 1 else 0.0
477
+ return (top_score > threshold["min_score"]
478
+ and _query_coverage(index, query) >= threshold["min_coverage"]
479
+ and (threshold["min_margin"] <= 0
480
+ or top_score - runner_up_score >= threshold["min_margin"]))
481
+
482
+
483
+ def _suggest_terms(bm25, query, limit=6, threshold=None):
484
+ """Nearest known vocabulary terms for a query that returned 0 hits,
485
+ so the caller can retry instead of silently reporting nothing."""
486
+ if bm25 is None:
487
+ return []
488
+ query_tokens = set(bm25.tokenize(query))
489
+ if not query_tokens:
490
+ return []
201
491
 
202
- domain_keywords = {
203
- "color": ["color", "palette", "hex", "#", "rgb", "token", "semantic", "accent", "destructive", "muted", "foreground"],
204
- "chart": ["chart", "graph", "visualization", "trend", "bar", "pie", "scatter", "heatmap", "funnel"],
492
+ candidates = []
493
+ for term in bm25.vocabulary():
494
+ if term in query_tokens:
495
+ continue
496
+ similarity = max(difflib.SequenceMatcher(None, token, term).ratio()
497
+ for token in query_tokens)
498
+ if (similarity >= 0.72
499
+ and (threshold is None or _passes_threshold(bm25, term, threshold))):
500
+ candidates.append((-similarity, -bm25.doc_freqs.get(term, 0), term))
501
+ return [term for _, _, term in sorted(candidates)[:limit]]
502
+
503
+
504
+ def _suggest_identities(rows, query, fields, limit=6):
505
+ """Suggest complete public identities so a retry can bypass score thresholds."""
506
+ tokenizer = BM25()
507
+ query_tokens = set(tokenizer.tokenize(query))
508
+ if not query_tokens:
509
+ return []
510
+ candidates = []
511
+ for row in rows:
512
+ for identity in _row_identities(row, fields):
513
+ identity_tokens = set(tokenizer.tokenize(identity))
514
+ if not identity_tokens:
515
+ continue
516
+ similarity = max(
517
+ difflib.SequenceMatcher(None, source, target).ratio()
518
+ for source in query_tokens for target in identity_tokens
519
+ )
520
+ if similarity >= 0.72 and identity.casefold() != str(query).strip().casefold():
521
+ candidates.append((-similarity, len(identity_tokens), identity))
522
+ return [identity for _, _, identity in sorted(set(candidates))[:limit]]
523
+
524
+
525
+ def _row_identities(row, fields):
526
+ """Return non-empty public identities from ordinary and alias fields."""
527
+ identities = []
528
+ for field in fields:
529
+ values = row.get(field, "").split("|") if field == "Aliases" else [row.get(field, "")]
530
+ identities.extend(value.strip() for value in values if value.strip())
531
+ return identities
532
+
533
+
534
+ # Load the product-domain keyword list from products.csv at import time so
535
+ # it stays in sync with the data instead of needing manual updates to a
536
+ # hardcoded list. Falls back to a small built-in seed if the file is
537
+ # missing (e.g. package built without data/).
538
+ def _load_product_keywords():
539
+ """Return high-signal product labels/aliases, never every corpus keyword."""
540
+ seed = ["saas", "ecommerce", "fintech", "healthcare", "gaming", "portfolio",
541
+ "crypto", "fitness", "marketplace", "banking", "cybersecurity",
542
+ "education", "travel", "restaurant", "real estate", "social media",
543
+ "beauty", "spa", "salon", "wellness", "booking"]
544
+ filepath = DATA_DIR / CSV_CONFIG["product"]["file"]
545
+ if not filepath.exists():
546
+ return seed
547
+ try:
548
+ rows = _load_csv(filepath)
549
+ except (csv.Error, OSError, UnicodeDecodeError):
550
+ return seed
551
+
552
+ keywords = set(seed)
553
+ for row in rows:
554
+ label = re.sub(r"\([^)]*\)", "", row.get("Product Type", "")).strip().lower()
555
+ if len(label) >= 4:
556
+ keywords.add(label)
557
+ return sorted(keywords, key=len, reverse=True)
558
+
559
+
560
+ _DOMAIN_KEYWORDS = None
561
+ _DOMAIN_KEYWORDS_SIGNATURE = None
562
+
563
+
564
+ def _domain_keywords():
565
+ global _DOMAIN_KEYWORDS, _DOMAIN_KEYWORDS_SIGNATURE
566
+ product_path = DATA_DIR / CSV_CONFIG["product"]["file"]
567
+ signature = _file_signature(product_path) if product_path.exists() else None
568
+ if _DOMAIN_KEYWORDS is not None and _DOMAIN_KEYWORDS_SIGNATURE == signature:
569
+ return _DOMAIN_KEYWORDS
570
+
571
+ _DOMAIN_KEYWORDS = {
572
+ "color": ["color", "palette", "hex", "rgb", "token", "semantic", "accent", "destructive", "muted", "foreground"],
573
+ "chart": ["time series", "chart", "graph", "visualization", "trend", "bar chart", "pie", "scatter", "heatmap", "funnel", "forecast"],
205
574
  "landing": ["landing", "page", "cta", "conversion", "hero", "testimonial", "pricing", "section"],
206
- "product": ["saas", "ecommerce", "e-commerce", "fintech", "healthcare", "gaming", "portfolio", "crypto", "dashboard", "fitness", "restaurant", "hotel", "travel", "music", "education", "learning", "legal", "insurance", "medical", "beauty", "pharmacy", "dental", "pet", "dating", "wedding", "recipe", "delivery", "ride", "booking", "calendar", "timer", "tracker", "diary", "note", "chat", "messenger", "crm", "invoice", "parking", "transit", "vpn", "alarm", "weather", "sleep", "meditation", "fasting", "habit", "grocery", "meme", "wardrobe", "plant care", "reading", "flashcard", "puzzle", "trivia", "arcade", "photography", "streaming", "podcast", "newsletter", "marketplace", "freelancer", "coworking", "airline", "museum", "theater", "church", "non-profit", "charity", "kindergarten", "daycare", "senior care", "veterinary", "florist", "bakery", "brewery", "construction", "automotive", "real estate", "logistics", "agriculture", "coding bootcamp"],
207
- "style": ["style", "design", "ui", "minimalism", "glassmorphism", "neumorphism", "brutalism", "dark mode", "flat", "aurora", "prompt", "css", "implementation", "variable", "checklist", "tailwind"],
575
+ "product": _load_product_keywords(),
576
+ "style": ["style", "design", "ui", "minimalism", "glassmorphism", "neumorphism", "brutalism", "dark mode", "flat", "aurora", "css", "implementation", "variable", "checklist", "tailwind"],
208
577
  "ux": ["ux", "usability", "accessibility", "wcag", "touch", "scroll", "animation", "keyboard", "navigation", "mobile"],
209
578
  "typography": ["font pairing", "typography pairing", "heading font", "body font"],
210
579
  "google-fonts": ["google font", "font family", "font weight", "font style", "variable font", "noto", "font for", "find font", "font subset", "font language", "monospace font", "serif font", "sans serif font", "display font", "handwriting font", "font", "typography", "serif", "sans"],
211
- "icons": ["icon", "icons", "lucide", "heroicons", "symbol", "glyph", "pictogram", "svg icon"],
580
+ "icons": ["icon", "icons", "lucide", "phosphor", "heroicons", "symbol", "glyph", "pictogram", "svg icon"],
581
+ "gsap": ["gsap", "quickto", "scrolltrigger", "stagger", "magnetic cursor", "parallax", "page transition", "scroll reveal", "scroll-triggered", "scrollytelling", "flip plugin", "splittext", "shimmer", "skeleton loader"],
212
582
  "react": ["react", "next.js", "nextjs", "suspense", "memo", "usecallback", "useeffect", "rerender", "bundle", "waterfall", "barrel", "dynamic import", "rsc", "server component"],
213
- "web": ["aria", "focus", "outline", "semantic", "virtualize", "autocomplete", "form", "input type", "preconnect"]
583
+ "web": ["aria", "focus", "outline", "semantic", "virtualize", "autocomplete", "form", "input type", "preconnect", "drag reorder", "single pointer", "touch target", "native accessibility"]
584
+ }
585
+ _DOMAIN_KEYWORDS_SIGNATURE = signature
586
+ return _DOMAIN_KEYWORDS
587
+
588
+
589
+ def _contains_phrase(text, phrase):
590
+ if re.search(r"\w", phrase):
591
+ return bool(re.search(r'(?<!\w)' + re.escape(phrase) + r'(?!\w)', text))
592
+ return phrase in text
593
+
594
+
595
+ def _rewrite_query_for_domain(query, domain, index):
596
+ """Apply only explicit, semantic rewrites for routing-only vocabulary."""
597
+ if not domain or domain not in _domain_keywords():
598
+ return query, []
599
+ normalized = _normalize(query.lower())
600
+ vocabulary = set(index.vocabulary())
601
+ rewrites = []
602
+ replacement_terms = []
603
+ for keyword in _domain_keywords()[domain]:
604
+ if not _contains_phrase(normalized, keyword):
605
+ continue
606
+ if set(index.tokenize(keyword)) & vocabulary:
607
+ continue
608
+ replacement = _DOMAIN_QUERY_REWRITES.get(domain, {}).get(keyword)
609
+ if replacement:
610
+ rewrites.append(f"{keyword}->{replacement}")
611
+ replacement_terms.append(replacement)
612
+ if not replacement_terms:
613
+ return query, []
614
+ return f"{query} {' '.join(sorted(set(replacement_terms)))}", sorted(set(rewrites))
615
+
616
+
617
+ # Domains checked in this fixed order when scores tie, so results are
618
+ # deterministic instead of depending on dict/hash ordering.
619
+ _DOMAIN_TIEBREAK_ORDER = [
620
+ "ux", "product", "style", "color", "typography", "google-fonts",
621
+ "chart", "landing", "icons", "gsap", "react", "web",
622
+ ]
623
+ _DOMAIN_TIEBREAK_RANK = {
624
+ domain: rank for rank, domain in enumerate(_DOMAIN_TIEBREAK_ORDER)
625
+ }
626
+
627
+
628
+ def detect_domain(query, return_scores=False):
629
+ """Auto-detect the most relevant domain from query.
630
+
631
+ Matches are weighted by keyword length (multi-word/longer phrases are
632
+ more specific and score higher than short generic words). Ties are
633
+ broken by a fixed domain priority order, not dict/insertion order.
634
+ """
635
+ query_lower = _normalize(query.lower())
636
+ domain_keywords = _domain_keywords()
637
+
638
+ scores = {}
639
+ for domain, keywords in domain_keywords.items():
640
+ total = 0.0
641
+ for kw in keywords:
642
+ if _contains_phrase(query_lower, kw):
643
+ # weight = 1 point per word in the keyword phrase
644
+ specificity = max(1, len(kw.split()))
645
+ total += 2.0 * specificity if domain != "product" else specificity
646
+ scores[domain] = total
647
+ if re.search(r"(?<!\w)#[0-9a-f]{3,8}(?!\w)", query_lower, re.IGNORECASE):
648
+ scores["color"] += 2.0
649
+
650
+ ranked = sorted(
651
+ scores.items(),
652
+ key=lambda item: (item[1], -_DOMAIN_TIEBREAK_RANK.get(item[0], 999)),
653
+ reverse=True,
654
+ )
655
+ best_domain, best_score = ranked[0]
656
+ result = best_domain if best_score > 0 else "style"
657
+
658
+ if return_scores:
659
+ runner_up = ranked[1][0] if len(ranked) > 1 and ranked[1][1] > 0 else None
660
+ return result, runner_up
661
+ return result
662
+
663
+
664
+ def _style_identity(rows, query, allow_contained=True):
665
+ """Resolve an explicit style identity without opening generic variant ranking."""
666
+ folded = str(query or "").strip().casefold()
667
+ query_tokens = set(re.findall(r"\w+", _normalize(folded), re.UNICODE))
668
+ generic_tokens = {"app", "design", "interface", "style", "system", "ui"}
669
+ candidates = []
670
+ for row in rows:
671
+ identities = _row_identities(row, _STYLE_IDENTITY_FIELDS)
672
+ if folded in {identity.casefold() for identity in identities}:
673
+ return row
674
+ if not allow_contained:
675
+ continue
676
+ for identity in identities:
677
+ identity_tokens = set(re.findall(
678
+ r"\w+", _normalize(identity.casefold()), re.UNICODE))
679
+ if (identity_tokens and identity_tokens <= query_tokens
680
+ and any(len(token) >= 4 for token in identity_tokens)):
681
+ distinctive = identity_tokens - generic_tokens
682
+ candidates.append(
683
+ (len(distinctive), len(identity_tokens), len(identity), row))
684
+ if not candidates:
685
+ return None
686
+ candidates.sort(key=lambda item: (item[0], item[1]), reverse=True)
687
+ best_score = candidates[0][:3]
688
+ best_rows = {
689
+ candidate[3].get("Style ID", ""): candidate[3]
690
+ for candidate in candidates if candidate[:3] == best_score
214
691
  }
692
+ return next(iter(best_rows.values())) if len(best_rows) == 1 else None
693
+
694
+
695
+ def _exact_row_identity(rows, query, fields):
696
+ """Return one row whose stable public identity exactly matches the query."""
697
+ folded = str(query or "").strip().casefold()
698
+ matches = []
699
+ for row in rows:
700
+ if folded in {identity.casefold() for identity in _row_identities(row, fields)}:
701
+ matches.append(row)
702
+ return matches[0] if len(matches) == 1 else None
703
+
215
704
 
216
- scores = {domain: sum(1 for kw in keywords if re.search(r'\b' + re.escape(kw) + r'\b', query_lower)) for domain, keywords in domain_keywords.items()}
217
- best = max(scores, key=scores.get)
218
- return best if scores[best] > 0 else "style"
705
+ def _load_rows_or_empty(filepath):
706
+ """Load rows for optional identity routing, leaving search to report I/O errors."""
707
+ try:
708
+ return _load_csv(filepath)
709
+ except (csv.Error, OSError, UnicodeDecodeError):
710
+ return []
711
+
712
+
713
+ def _project_row(row, columns):
714
+ return {column: row.get(column, "") for column in columns if column in row}
715
+
716
+
717
+ def _valid_max_results(value):
718
+ return not isinstance(value, bool) and isinstance(value, int) and 1 <= value <= 20
719
+
720
+
721
+ def _exact_match_diagnostic(query, reason):
722
+ return {
723
+ "normalized_query": _normalize(query),
724
+ "search_query": query,
725
+ "query_rewrites": [],
726
+ "top_score": 0.0,
727
+ "runner_up_score": 0.0,
728
+ "margin": 0.0,
729
+ "token_coverage": 1.0,
730
+ "abstained": False,
731
+ "calibration_version": _SEARCH_CALIBRATION_VERSION,
732
+ "reason": reason,
733
+ }
219
734
 
220
735
 
221
- def search(query, domain=None, max_results=MAX_RESULTS):
736
+ def _style_search_destination(rows, matched):
737
+ """Resolve a deprecated in-domain alias, or expose a cross-domain redirect."""
738
+ if not matched or matched.get("Status", "active") != "deprecated":
739
+ return matched, None
740
+ parent_id = matched.get("Parent Style ID", "").strip()
741
+ if parent_id:
742
+ parent = next((row for row in rows if row.get("Style ID") == parent_id), None)
743
+ return parent, None
744
+ domain = matched.get("Replacement Domain", "").strip()
745
+ replacement_id = matched.get("Replacement ID", "").strip()
746
+ if domain == "style" and replacement_id:
747
+ replacement = next(
748
+ (row for row in rows if row.get("Style ID") == replacement_id), None)
749
+ return replacement, None
750
+ if domain and replacement_id:
751
+ return None, {"domain": domain, "id": replacement_id}
752
+ return None, None
753
+
754
+
755
+ def search(query, domain=None, max_results=MAX_RESULTS, diagnostics=False):
222
756
  """Main search function with auto-domain detection"""
757
+ if not _valid_max_results(max_results):
758
+ return {"error": "max_results must be an integer from 1 to 20", "domain": domain}
759
+ auto_detected = domain is None
760
+ runner_up = None
761
+ style_rows = None
762
+ exact_style = None
763
+ redirect = None
223
764
  if domain is None:
224
- domain = detect_domain(query)
225
-
226
- config = CSV_CONFIG.get(domain, CSV_CONFIG["style"])
765
+ style_path = DATA_DIR / CSV_CONFIG["style"]["file"]
766
+ style_rows = _load_rows_or_empty(style_path)
767
+ matched_style = _style_identity(style_rows, query, allow_contained=False)
768
+ if matched_style is not None:
769
+ domain = "style"
770
+ exact_style, redirect = _style_search_destination(
771
+ style_rows, matched_style)
772
+ else:
773
+ domain, runner_up = detect_domain(query, return_scores=True)
774
+
775
+ search_domain = domain if domain in CSV_CONFIG else "style"
776
+ config = CSV_CONFIG[search_domain]
227
777
  filepath = DATA_DIR / config["file"]
228
778
 
229
779
  if not filepath.exists():
230
780
  return {"error": f"File not found: {filepath}", "domain": domain}
231
781
 
232
- results = _search_csv(filepath, config["search_cols"], config["output_cols"], query, max_results)
233
-
234
- return {
782
+ if search_domain == "style" and exact_style is None and redirect is None:
783
+ if style_rows is None:
784
+ style_rows = _load_rows_or_empty(filepath)
785
+ exact_style, redirect = _style_search_destination(
786
+ style_rows, _style_identity(style_rows, query))
787
+ elif search_domain == "landing":
788
+ landing_rows = _load_rows_or_empty(filepath)
789
+ exact_style = _exact_row_identity(
790
+ landing_rows, query, _LANDING_IDENTITY_FIELDS)
791
+
792
+ if exact_style is not None:
793
+ results = [_project_row(exact_style, config["output_cols"])]
794
+ bm25 = None
795
+ diagnostic = _exact_match_diagnostic(query, "exact-identity")
796
+ elif redirect is not None:
797
+ results, bm25 = [], None
798
+ diagnostic = {
799
+ "normalized_query": _normalize(query),
800
+ "search_query": query,
801
+ "query_rewrites": [],
802
+ "abstained": True,
803
+ "calibration_version": _SEARCH_CALIBRATION_VERSION,
804
+ "reason": "cross-domain-redirect",
805
+ }
806
+ else:
807
+ results, bm25, diagnostic = _search_csv_detailed(
808
+ filepath, config["search_cols"], config["output_cols"], query,
809
+ max_results, _SEARCH_THRESHOLDS[search_domain], search_domain,
810
+ row_filter=(
811
+ (lambda row: row.get("Status", "active") == "active")
812
+ if search_domain == "style" else None
813
+ ),
814
+ cache_variant="active-only" if search_domain == "style" else "",
815
+ )
816
+
817
+ if search_domain == "icons" and _contains_phrase(_normalize(query.lower()), "lucide"):
818
+ results = []
819
+ diagnostic.update({"abstained": True, "reason": "unsupported-library"})
820
+
821
+ out = {
235
822
  "domain": domain,
236
823
  "query": query,
237
824
  "file": config["file"],
238
825
  "count": len(results),
239
- "results": results
826
+ "results": results,
240
827
  }
241
-
242
-
243
- def search_stack(query, stack, max_results=MAX_RESULTS):
828
+ if auto_detected:
829
+ out["auto_detected"] = True
830
+ if runner_up:
831
+ out["runner_up_domain"] = runner_up
832
+ if redirect is not None:
833
+ out["redirect"] = redirect
834
+ if diagnostic.get("error"):
835
+ out["error"] = diagnostic["error"]
836
+ if not results:
837
+ if search_domain == "landing":
838
+ out["suggestions"] = _suggest_identities(
839
+ landing_rows, query, _LANDING_IDENTITY_FIELDS)
840
+ else:
841
+ out["suggestions"] = _suggest_terms(
842
+ bm25, query, threshold=_SEARCH_THRESHOLDS[search_domain])
843
+ if diagnostics:
844
+ out["diagnostics"] = diagnostic
845
+ return out
846
+
847
+
848
+ def _stack_query_requests_legacy(query, stack):
849
+ """Whether a stack query explicitly targets an older framework generation."""
850
+ normalized = _normalize(str(query or "").casefold())
851
+ if stack in LEGACY_ONLY_STACKS:
852
+ return True
853
+
854
+ current_version = STACK_CURRENT_VERSIONS.get(stack)
855
+ stack_name = _STACK_QUERY_NAMES.get(stack)
856
+ if current_version is not None and stack_name is not None:
857
+ matches = re.finditer(
858
+ rf"\b(?:{stack_name})\s*(?:sdk|ui)?\s*(?:[@(]\s*)?(?:v(?:ersion)?\s*)?"
859
+ rf"(\d+)(?:\.(\d+))?\s*\)?",
860
+ normalized,
861
+ )
862
+ requested_versions = [
863
+ tuple(int(value) for value in match.groups() if value is not None)
864
+ for match in matches
865
+ ]
866
+ if stack == "threejs":
867
+ requested_versions.extend(
868
+ (0, int(release)) for release in re.findall(r"\br(\d+)\b", normalized)
869
+ )
870
+ migration_intent = bool(re.search(
871
+ r"\b(?:migrat\w*|upgrad\w*|replac\w*|instead|modern|current)\b",
872
+ normalized,
873
+ ))
874
+ if requested_versions:
875
+ if migration_intent and any(
876
+ requested >= current_version[:len(requested)]
877
+ for requested in requested_versions):
878
+ return False
879
+ return all(
880
+ requested < current_version[:len(requested)]
881
+ for requested in requested_versions
882
+ )
883
+ if re.search(r"\b(?:migrat\w*|upgrad\w*|replac\w*|instead|modern|current)\b", normalized):
884
+ return False
885
+ return bool(re.search(r"\b(?:legacy|deprecated)\b", normalized))
886
+
887
+
888
+ def _stack_row_filter(rows, query, stack):
889
+ """Choose one coherent applicability generation for stack retrieval."""
890
+ statuses = {row.get("Status", "unverified") for row in rows}
891
+ has_legacy = "deprecated" in statuses
892
+ requests_legacy = _stack_query_requests_legacy(query, stack)
893
+ if has_legacy and requests_legacy:
894
+ status_filter = lambda row: row.get("Status") == "deprecated"
895
+ variant = "legacy-only"
896
+ elif requests_legacy and stack in STACK_CURRENT_VERSIONS:
897
+ return lambda row: False, "legacy-unavailable"
898
+ elif "active" in statuses:
899
+ status_filter = lambda row: row.get("Status") == "active"
900
+ variant = "current-only"
901
+ else:
902
+ status_filter = lambda row: row.get("Status", "unverified") != "deprecated"
903
+ variant = "non-legacy"
904
+
905
+ if stack != "shadcn":
906
+ return status_filter, variant
907
+ normalized = _normalize(str(query or "").casefold())
908
+ if "base ui" in normalized:
909
+ requested_base = "base"
910
+ elif "react aria" in normalized:
911
+ requested_base = "aria"
912
+ elif "radix" in normalized or "aschild" in normalized:
913
+ requested_base = "radix"
914
+ else:
915
+ return status_filter, variant
916
+
917
+ def matches_base(row):
918
+ match = re.search(r"\bbase=([^;]+)", row.get("Applies To", "").casefold())
919
+ bases = match.group(1).split("|") if match else []
920
+ return status_filter(row) and requested_base in bases
921
+
922
+ return matches_base, f"{variant};base={requested_base}"
923
+
924
+
925
+ def _exact_stack_identifier(rows, query, row_filter):
926
+ """Resolve a standalone API identifier even when its BM25 IDF is low."""
927
+ identifier = str(query or "").strip()
928
+ if len(identifier) < 6 or re.search(r"\s", identifier):
929
+ return None
930
+ pattern = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(identifier)}(?![A-Za-z0-9_])", re.I)
931
+ fields = ("Guideline", "Description", "Do", "Don't", "Code Good", "Code Bad")
932
+ matches = [row for row in rows if row_filter(row) and any(
933
+ pattern.search(row.get(field, "")) for field in fields
934
+ )]
935
+ return matches[0] if len(matches) == 1 else None
936
+
937
+
938
+ def _legacy_successor_guidance(rows, query, stack, row_filter):
939
+ """Prefer the explicit successor row for a brand-new app on legacy-only stacks."""
940
+ normalized = _normalize(str(query or "").casefold())
941
+ if stack not in LEGACY_ONLY_STACKS or not re.search(
942
+ r"\b(?:brand new|new)\s+(?:app|application|project)\b", normalized):
943
+ return None
944
+ matches = [row for row in rows if row_filter(row) and re.search(
945
+ r"\b(?:prefer|choose|use)\b.*\bnew (?:apps?|projects?)\b",
946
+ " ".join((row.get("Guideline", ""), row.get("Description", ""), row.get("Do", ""))).casefold(),
947
+ )]
948
+ return matches[0] if len(matches) == 1 else None
949
+
950
+
951
+ def search_stack(query, stack, max_results=MAX_RESULTS, diagnostics=False):
244
952
  """Search stack-specific guidelines"""
953
+ if not _valid_max_results(max_results):
954
+ return {"error": "max_results must be an integer from 1 to 20", "stack": stack}
245
955
  if stack not in STACK_CONFIG:
246
956
  return {"error": f"Unknown stack: {stack}. Available: {', '.join(AVAILABLE_STACKS)}"}
247
957
 
@@ -250,13 +960,34 @@ def search_stack(query, stack, max_results=MAX_RESULTS):
250
960
  if not filepath.exists():
251
961
  return {"error": f"Stack file not found: {filepath}", "stack": stack}
252
962
 
253
- results = _search_csv(filepath, _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], query, max_results)
254
-
255
- return {
963
+ rows = _load_rows_or_empty(filepath)
964
+ row_filter, cache_variant = _stack_row_filter(rows, query, stack)
965
+ threshold = _NO_THRESHOLD if cache_variant == "legacy-only" else _STACK_THRESHOLD
966
+ exact = (_legacy_successor_guidance(rows, query, stack, row_filter)
967
+ or _exact_stack_identifier(rows, query, row_filter))
968
+ if exact is not None:
969
+ results = [_project_row(exact, _STACK_COLS["output_cols"])]
970
+ bm25 = None
971
+ diagnostic = _exact_match_diagnostic(query, "exact-identifier")
972
+ else:
973
+ results, bm25, diagnostic = _search_csv_detailed(
974
+ filepath, _STACK_COLS["search_cols"], _STACK_COLS["output_cols"], query,
975
+ max_results, threshold, row_filter=row_filter,
976
+ cache_variant=cache_variant)
977
+
978
+ out = {
256
979
  "domain": "stack",
257
980
  "stack": stack,
258
981
  "query": query,
259
982
  "file": STACK_CONFIG[stack]["file"],
260
983
  "count": len(results),
261
- "results": results
984
+ "results": results,
262
985
  }
986
+ if diagnostic.get("error"):
987
+ out["error"] = diagnostic["error"]
988
+ if not results:
989
+ out["suggestions"] = _suggest_terms(
990
+ bm25, query, threshold=threshold)
991
+ if diagnostics:
992
+ out["diagnostics"] = diagnostic
993
+ return out