@soulbatical/tetra-dev-toolkit 1.20.17 → 1.20.19

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.
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Tetra Dark Mode Autofix
5
+ *
6
+ * Mechanically replaces hardcoded Tailwind colors with Tetra design tokens.
7
+ * Run on a clean git branch so you can easily revert.
8
+ *
9
+ * Usage:
10
+ * node tetra-dark-mode-fix.js # Dry run (shows what would change)
11
+ * node tetra-dark-mode-fix.js --apply # Apply changes
12
+ * node tetra-dark-mode-fix.js --apply --verbose # Apply + show each replacement
13
+ *
14
+ * Strategy:
15
+ * 1. PAIRED replacements first (bg-white dark:bg-gray-900 → bg-background)
16
+ * 2. NEUTRAL unpaired (bg-white → bg-background, text-gray-500 → text-muted-foreground)
17
+ * 3. SEMANTIC unpaired (bg-green-50 → bg-success-subtle, text-red-600 → text-danger-foreground)
18
+ * 4. HOVER/FOCUS modifiers (hover:bg-gray-100 → hover:bg-accent)
19
+ * 5. Remove orphaned dark: classes that no longer have a light counterpart
20
+ */
21
+
22
+ import { readFileSync, writeFileSync } from 'fs'
23
+ import { join } from 'path'
24
+ import { glob } from 'glob'
25
+
26
+ const args = process.argv.slice(2)
27
+ const APPLY = args.includes('--apply')
28
+ const VERBOSE = args.includes('--verbose')
29
+ const projectRoot = process.cwd()
30
+
31
+ // ============================================================================
32
+ // REPLACEMENT RULES — ordered from most specific to least specific
33
+ // ============================================================================
34
+
35
+ /**
36
+ * PAIRED rules: match "light-class dark:dark-class" and replace both with a single token.
37
+ * These run FIRST to avoid partial matches.
38
+ */
39
+ const PAIRED_RULES = [
40
+ // ── Backgrounds ──
41
+ [/\bbg-white\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\b/g, 'bg-background'],
42
+ [/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\s+bg-white\b/g, 'bg-background'],
43
+ [/\bbg-(?:gray|slate|zinc|neutral)-50\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\b/g, 'bg-background'],
44
+ [/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\s+bg-(?:gray|slate|zinc|neutral)-50\b/g, 'bg-background'],
45
+ [/\bbg-(?:gray|slate|zinc|neutral)-100\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\b/g, 'bg-secondary'],
46
+ [/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\s+bg-(?:gray|slate|zinc|neutral)-100\b/g, 'bg-secondary'],
47
+ [/\bbg-(?:gray|slate|zinc|neutral)-200\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:700|800)\b/g, 'bg-muted'],
48
+ [/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:700|800)\s+bg-(?:gray|slate|zinc|neutral)-200\b/g, 'bg-muted'],
49
+ // ── Text ──
50
+ [/\btext-(?:gray|slate|zinc|neutral)-900\s+dark:text-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'text-foreground'],
51
+ [/\bdark:text-(?:gray|slate|zinc|neutral)-(?:50|100)\s+text-(?:gray|slate|zinc|neutral)-900\b/g, 'text-foreground'],
52
+ [/\btext-(?:gray|slate|zinc|neutral)-(?:700|800)\s+dark:text-(?:gray|slate|zinc|neutral)-(?:200|300)\b/g, 'text-foreground'],
53
+ [/\bdark:text-(?:gray|slate|zinc|neutral)-(?:200|300)\s+text-(?:gray|slate|zinc|neutral)-(?:700|800)\b/g, 'text-foreground'],
54
+ [/\btext-(?:gray|slate|zinc|neutral)-(?:500|600)\s+dark:text-(?:gray|slate|zinc|neutral)-(?:300|400)\b/g, 'text-muted-foreground'],
55
+ [/\bdark:text-(?:gray|slate|zinc|neutral)-(?:300|400)\s+text-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'text-muted-foreground'],
56
+ // ── Borders ──
57
+ [/\bborder-(?:gray|slate|zinc|neutral)-(?:200|300)\s+dark:border-(?:gray|slate|zinc|neutral)-(?:600|700|800)\b/g, 'border-border'],
58
+ [/\bdark:border-(?:gray|slate|zinc|neutral)-(?:600|700|800)\s+border-(?:gray|slate|zinc|neutral)-(?:200|300)\b/g, 'border-border'],
59
+ // ── Divide ──
60
+ [/\bdivide-(?:gray|slate|zinc|neutral)-(?:200|300)\s+dark:divide-(?:gray|slate|zinc|neutral)-(?:600|700|800)\b/g, 'divide-border'],
61
+ // ── Ring ──
62
+ [/\bring-(?:gray|slate|zinc|neutral)-(?:200|300)\s+dark:ring-(?:gray|slate|zinc|neutral)-(?:600|700|800)\b/g, 'ring-ring'],
63
+ // ── Hover backgrounds ──
64
+ [/\bhover:bg-(?:gray|slate|zinc|neutral)-(?:50|100)\s+dark:hover:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\b/g, 'hover:bg-accent'],
65
+ [/\bdark:hover:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\s+hover:bg-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'hover:bg-accent'],
66
+ // ── Hover text ──
67
+ [/\bhover:text-(?:gray|slate|zinc|neutral)-900\s+dark:hover:text-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'hover:text-foreground'],
68
+ // ── Hover borders ──
69
+ [/\bhover:border-(?:gray|slate|zinc|neutral)-(?:300|400)\s+dark:hover:border-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'hover:border-border'],
70
+ ]
71
+
72
+ /**
73
+ * UNPAIRED neutral rules: single class → token.
74
+ * Run AFTER paired rules so we don't break paired sets.
75
+ */
76
+ const UNPAIRED_NEUTRAL_RULES = [
77
+ // Backgrounds (including opacity suffixes like bg-black/50)
78
+ [/\bbg-white\b/g, 'bg-background'],
79
+ [/\bbg-(?:gray|slate|zinc|neutral)-50\b/g, 'bg-background'],
80
+ [/\bbg-(?:gray|slate|zinc|neutral)-100\b/g, 'bg-secondary'],
81
+ [/\bbg-(?:gray|slate|zinc|neutral)-200\b/g, 'bg-muted'],
82
+ [/\bbg-(?:gray|slate|zinc|neutral)-(?:300|400|500|600|700)\b/g, 'bg-muted'],
83
+ [/\bbg-(?:gray|slate|zinc|neutral)-(?:800|900|950)\b/g, 'bg-foreground'],
84
+ [/\bbg-black(?:\/\d+)?\b/g, 'bg-foreground/50'],
85
+ // Text (including white on dark surfaces)
86
+ [/\btext-(?:gray|slate|zinc|neutral)-(?:900|950)\b/g, 'text-foreground'],
87
+ [/\btext-(?:gray|slate|zinc|neutral)-(?:700|800)\b/g, 'text-foreground'],
88
+ [/\btext-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'text-muted-foreground'],
89
+ [/\btext-(?:gray|slate|zinc|neutral)-(?:100|200|300|400)\b/g, 'text-muted-foreground'],
90
+ [/\btext-black\b/g, 'text-foreground'],
91
+ [/\btext-white\b/g, 'text-primary-foreground'],
92
+ // Borders
93
+ [/\bborder-(?:gray|slate|zinc|neutral)-\d+\b/g, 'border-border'],
94
+ // Divide (all shades)
95
+ [/\bdivide-(?:gray|slate|zinc|neutral)-\d+\b/g, 'divide-border'],
96
+ // Ring
97
+ [/\bring-(?:gray|slate|zinc|neutral)-\d+\b/g, 'ring-ring'],
98
+ // Placeholder
99
+ [/\bplaceholder-(?:gray|slate|zinc|neutral)-\d+\b/g, 'placeholder:text-muted-foreground'],
100
+ // Fill / Stroke
101
+ [/\bfill-(?:gray|slate|zinc|neutral)-\d+\b/g, 'fill-muted-foreground'],
102
+ [/\bstroke-(?:gray|slate|zinc|neutral)-\d+\b/g, 'stroke-border'],
103
+ // Hover
104
+ [/\bhover:bg-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'hover:bg-accent'],
105
+ [/\bhover:bg-(?:gray|slate|zinc|neutral)-(?:200|300)\b/g, 'hover:bg-accent'],
106
+ [/\bhover:text-(?:gray|slate|zinc|neutral)-(?:700|800|900)\b/g, 'hover:text-foreground'],
107
+ [/\bhover:text-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'hover:text-muted-foreground'],
108
+ [/\bhover:border-(?:gray|slate|zinc|neutral)-(?:300|400|500)\b/g, 'hover:border-border'],
109
+ // Focus
110
+ [/\bfocus:ring-(?:gray|slate|zinc|neutral)-(?:300|400|500)\b/g, 'focus:ring-ring'],
111
+ [/\bfocus:border-(?:gray|slate|zinc|neutral)-(?:300|400|500)\b/g, 'focus:border-ring'],
112
+ ]
113
+
114
+ /**
115
+ * SEMANTIC color rules: map colored classes to semantic tokens.
116
+ */
117
+ const SEMANTIC_RULES = [
118
+ // ── Success (green/emerald) ──
119
+ [/\bbg-(?:green|emerald)-(?:50|100)\b/g, 'bg-success-subtle'],
120
+ [/\bbg-(?:green|emerald)-(?:500|600)\b/g, 'bg-success'],
121
+ [/\btext-(?:green|emerald)-(?:400|500|600|700|800|900)\b/g, 'text-success-foreground'],
122
+ [/\bborder-(?:green|emerald)-(?:200|300|400)\b/g, 'border-success'],
123
+ // ── Danger (red/rose) ──
124
+ [/\bbg-(?:red|rose)-(?:50|100)\b/g, 'bg-danger-subtle'],
125
+ [/\bbg-(?:red|rose)-(?:500|600)\b/g, 'bg-destructive'],
126
+ [/\btext-(?:red|rose)-(?:400|500|600|700|800|900)\b/g, 'text-danger-foreground'],
127
+ [/\bborder-(?:red|rose)-(?:200|300|400)\b/g, 'border-destructive'],
128
+ // ── Warning (amber/yellow/orange) ──
129
+ [/\bbg-(?:amber|yellow|orange)-(?:50|100)\b/g, 'bg-warning-subtle'],
130
+ [/\bbg-(?:amber|yellow|orange)-(?:500|600)\b/g, 'bg-warning'],
131
+ [/\btext-(?:amber|yellow|orange)-(?:400|500|600|700|800|900)\b/g, 'text-warning-foreground'],
132
+ [/\bborder-(?:amber|yellow|orange)-(?:200|300|400)\b/g, 'border-warning'],
133
+ // ── Info (blue/sky/cyan) ──
134
+ [/\bbg-(?:blue|sky|cyan)-(?:50|100)\b/g, 'bg-info-subtle'],
135
+ [/\bbg-(?:blue|sky|cyan)-(?:500|600)\b/g, 'bg-info'],
136
+ [/\btext-(?:blue|sky|cyan)-(?:400|500|600|700|800|900)\b/g, 'text-info-foreground'],
137
+ [/\bborder-(?:blue|sky|cyan)-(?:200|300|400)\b/g, 'border-info'],
138
+ ]
139
+
140
+ /**
141
+ * BRAND color rules: map indigo/violet/purple to brand tokens.
142
+ * Projects override --tetra-brand-* in their globals.css.
143
+ */
144
+ const BRAND_RULES = [
145
+ // ── Primary brand (indigo/violet → brand) ──
146
+ [/\bbg-(?:indigo|violet)-(?:50|100)\b/g, 'bg-brand-subtle'],
147
+ [/\bbg-(?:indigo|violet)-(?:500|600)\b/g, 'bg-brand'],
148
+ [/\bbg-(?:indigo|violet)-(?:700|800)\b/g, 'bg-brand-hover'],
149
+ [/\bbg-(?:indigo|violet)-(?:900|950)(?:\/\d+)?\b/g, 'bg-brand/10'],
150
+ [/\btext-(?:indigo|violet)-(?:400|500)\b/g, 'text-brand-text'],
151
+ [/\btext-(?:indigo|violet)-(?:600|700)\b/g, 'text-brand-text'],
152
+ [/\btext-(?:indigo|violet)-(?:800|900)\b/g, 'text-brand-text'],
153
+ [/\bborder-(?:indigo|violet)-(?:200|300)\b/g, 'border-brand/30'],
154
+ [/\bborder-(?:indigo|violet)-(?:400|500)\b/g, 'border-brand'],
155
+ [/\bborder-(?:indigo|violet)-(?:600)\b/g, 'border-brand'],
156
+ [/\bring-(?:indigo|violet)-(?:100|200)\b/g, 'ring-brand/20'],
157
+ [/\bring-(?:indigo|violet)-(?:500|600)\b/g, 'ring-brand-ring'],
158
+ [/\bhover:bg-(?:indigo|violet)-(?:500|600|700)\b/g, 'hover:bg-brand-hover'],
159
+ [/\bhover:text-(?:indigo|violet)-(?:300|400)\b/g, 'hover:text-brand-text'],
160
+ [/\bfocus:ring-(?:indigo|violet)-(?:500|600)\b/g, 'focus:ring-brand-ring'],
161
+ [/\bfocus:border-(?:indigo|violet)-(?:500|600)\b/g, 'focus:border-brand'],
162
+ // ── Alt brand (purple/fuchsia → brand-alt) ──
163
+ [/\bbg-(?:purple|fuchsia)-(?:50|100)\b/g, 'bg-brand-alt-subtle'],
164
+ [/\bbg-(?:purple|fuchsia)-(?:500|600)\b/g, 'bg-brand-alt'],
165
+ [/\bbg-(?:purple|fuchsia)-(?:700|800)\b/g, 'bg-brand-alt-hover'],
166
+ [/\bbg-(?:purple|fuchsia)-(?:900)(?:\/\d+)?\b/g, 'bg-brand-alt/10'],
167
+ [/\btext-(?:purple|fuchsia)-(?:400|500)\b/g, 'text-brand-alt-text'],
168
+ [/\btext-(?:purple|fuchsia)-(?:600|700)\b/g, 'text-brand-alt-text'],
169
+ [/\btext-(?:purple|fuchsia)-(?:800|900)\b/g, 'text-brand-alt-text'],
170
+ [/\bborder-(?:purple|fuchsia)-(?:200|300)\b/g, 'border-brand-alt/30'],
171
+ [/\bborder-(?:purple|fuchsia)-(?:500)\b/g, 'border-brand-alt'],
172
+ [/\bring-(?:purple|fuchsia)-(?:500|600)\b/g, 'ring-brand-alt'],
173
+ [/\bhover:bg-(?:purple|fuchsia)-(?:500|600|700)\b/g, 'hover:bg-brand-alt-hover'],
174
+ [/\bfocus:ring-(?:purple|fuchsia)-(?:500|600)\b/g, 'focus:ring-brand-alt'],
175
+ // ── Platform A: pink (Instagram, etc.) ──
176
+ [/\bbg-pink-(?:50|100)\b/g, 'bg-platform-a-subtle'],
177
+ [/\bbg-pink-(?:400|500|600)\b/g, 'bg-platform-a'],
178
+ [/\bbg-pink-(?:900)(?:\/\d+)?\b/g, 'bg-platform-a/10'],
179
+ [/\btext-pink-(?:400|500)\b/g, 'text-platform-a-text'],
180
+ [/\btext-pink-(?:600|700|800)\b/g, 'text-platform-a-text'],
181
+ [/\bborder-pink-(?:200|300)\b/g, 'border-platform-a/30'],
182
+ [/\bborder-pink-(?:500)\b/g, 'border-platform-a'],
183
+ // ── Platform B: teal (TikTok, etc.) ──
184
+ [/\bbg-teal-(?:50|100)\b/g, 'bg-platform-b-subtle'],
185
+ [/\bbg-teal-(?:400|500|600)\b/g, 'bg-platform-b'],
186
+ [/\bbg-teal-(?:900)(?:\/\d+)?\b/g, 'bg-platform-b/10'],
187
+ [/\btext-teal-(?:400|500)\b/g, 'text-platform-b-text'],
188
+ [/\btext-teal-(?:600|700|800)\b/g, 'text-platform-b-text'],
189
+ [/\bborder-teal-(?:200|300)\b/g, 'border-platform-b/30'],
190
+ [/\bborder-teal-(?:500)\b/g, 'border-platform-b'],
191
+ // ── Platform C: orange/amber (Reddit, warnings, etc.) ──
192
+ [/\bbg-(?:orange|amber)-(?:50|100)\b/g, 'bg-platform-c-subtle'],
193
+ [/\bbg-(?:orange|amber)-(?:400|500|600)\b/g, 'bg-platform-c'],
194
+ [/\bbg-(?:orange|amber)-(?:700|800|900)(?:\/\d+)?\b/g, 'bg-platform-c/10'],
195
+ [/\bbg-(?:orange|amber)-(?:950)(?:\/\d+)?\b/g, 'bg-platform-c/10'],
196
+ [/\btext-(?:orange|amber)-(?:400|500|600)\b/g, 'text-platform-c-text'],
197
+ [/\btext-(?:orange|amber)-(?:700|800)\b/g, 'text-platform-c-text'],
198
+ [/\bborder-(?:orange|amber)-(?:200|300|500)\b/g, 'border-platform-c/30'],
199
+ [/\bborder-(?:orange|amber)-(?:800)\b/g, 'border-platform-c'],
200
+ [/\bring-(?:orange|amber)-(?:300|400|500)(?:\/\d+)?\b/g, 'ring-platform-c/40'],
201
+ [/\bhover:bg-(?:orange|amber)-(?:200|700)\b/g, 'hover:bg-platform-c-subtle'],
202
+ [/\bhover:border-(?:orange|amber)-(?:500)(?:\/\d+)?\b/g, 'hover:border-platform-c'],
203
+ // ── Platform D: purple (decorative, Twitch, etc.) ──
204
+ [/\bborder-purple-(?:400|600)\b/g, 'border-platform-d'],
205
+ [/\bbg-purple-(?:200|400)\b/g, 'bg-platform-d-subtle'],
206
+ [/\bhover:bg-purple-(?:200)\b/g, 'hover:bg-platform-d-subtle'],
207
+ [/\bhover:text-purple-(?:200|300)\b/g, 'hover:text-platform-d-text'],
208
+ [/\bhover:border-purple-(?:400)\b/g, 'hover:border-platform-d'],
209
+ // ── Remaining: emerald borders/hovers ──
210
+ [/\bborder-emerald-(?:500)\b/g, 'border-success'],
211
+ [/\bhover:border-emerald-(?:500)(?:\/\d+)?\b/g, 'hover:border-success'],
212
+ [/\bhover:bg-emerald-(?:900)(?:\/\d+)?\b/g, 'hover:bg-success/10'],
213
+ ]
214
+
215
+ /**
216
+ * ORPHAN dark: cleanup — remove dark: classes whose light counterpart was already replaced.
217
+ * These are now redundant because tokens handle dark mode automatically.
218
+ */
219
+ const ORPHAN_DARK_RULES = [
220
+ // Backgrounds
221
+ [/\s*\bdark:bg-(?:gray|slate|zinc|neutral)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g, ''],
222
+ [/\s*\bdark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\/\d+\b/g, ''],
223
+ // Text
224
+ [/\s*\bdark:text-(?:gray|slate|zinc|neutral)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g, ''],
225
+ // Borders
226
+ [/\s*\bdark:border-(?:gray|slate|zinc|neutral)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g, ''],
227
+ // Divide
228
+ [/\s*\bdark:divide-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
229
+ // Ring
230
+ [/\s*\bdark:ring-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
231
+ // Hover
232
+ [/\s*\bdark:hover:bg-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
233
+ [/\s*\bdark:hover:text-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
234
+ [/\s*\bdark:hover:border-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
235
+ // Focus
236
+ [/\s*\bdark:focus:ring-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
237
+ [/\s*\bdark:focus:border-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
238
+ // Semantic dark overrides
239
+ [/\s*\bdark:bg-(?:green|emerald|red|rose|amber|yellow|orange|blue|sky|cyan)-\d+(?:\/\d+)?\b/g, ''],
240
+ [/\s*\bdark:text-(?:green|emerald|red|rose|amber|yellow|orange|blue|sky|cyan)-\d+\b/g, ''],
241
+ [/\s*\bdark:border-(?:green|emerald|red|rose|amber|yellow|orange|blue|sky|cyan)-\d+\b/g, ''],
242
+ // Brand dark overrides
243
+ [/\s*\bdark:bg-(?:indigo|violet|purple|fuchsia)-\d+(?:\/\d+)?\b/g, ''],
244
+ [/\s*\bdark:text-(?:indigo|violet|purple|fuchsia)-\d+\b/g, ''],
245
+ [/\s*\bdark:border-(?:indigo|violet|purple|fuchsia)-\d+\b/g, ''],
246
+ [/\s*\bdark:ring-(?:indigo|violet|purple|fuchsia)-\d+(?:\/\d+)?\b/g, ''],
247
+ [/\s*\bdark:hover:bg-(?:indigo|violet|purple|fuchsia)-\d+(?:\/\d+)?\b/g, ''],
248
+ // Platform dark overrides
249
+ [/\s*\bdark:(?:bg|text|border|ring)-(?:pink|teal|orange|amber)-\d+(?:\/\d+)?\b/g, ''],
250
+ [/\s*\bdark:hover:(?:bg|text|border)-(?:pink|teal|orange|amber)-\d+(?:\/\d+)?\b/g, ''],
251
+ ]
252
+
253
+ // ============================================================================
254
+ // APPLY
255
+ // ============================================================================
256
+
257
+ const frontendDir = join(projectRoot, 'frontend', 'src')
258
+ const files = [
259
+ ...glob.sync('**/*.tsx', { cwd: frontendDir, ignore: ['**/node_modules/**', '**/.next/**'] }),
260
+ ...glob.sync('**/*.jsx', { cwd: frontendDir, ignore: ['**/node_modules/**', '**/.next/**'] }),
261
+ ]
262
+
263
+ let totalReplacements = 0
264
+ let totalFiles = 0
265
+ const stats = { paired: 0, neutral: 0, semantic: 0, brand: 0, orphanDark: 0 }
266
+
267
+ for (const file of files) {
268
+ const fullPath = join(frontendDir, file)
269
+ const original = readFileSync(fullPath, 'utf-8')
270
+ let content = original
271
+
272
+ // Phase 1: Paired replacements
273
+ for (const [re, token] of PAIRED_RULES) {
274
+ re.lastIndex = 0
275
+ content = content.replace(re, (match) => {
276
+ stats.paired++
277
+ if (VERBOSE) console.log(` [paired] ${file}: ${match} → ${token}`)
278
+ return token
279
+ })
280
+ }
281
+
282
+ // Phase 2: Unpaired neutrals
283
+ for (const [re, token] of UNPAIRED_NEUTRAL_RULES) {
284
+ re.lastIndex = 0
285
+ content = content.replace(re, (match) => {
286
+ stats.neutral++
287
+ if (VERBOSE) console.log(` [neutral] ${file}: ${match} → ${token}`)
288
+ return token
289
+ })
290
+ }
291
+
292
+ // Phase 3: Semantics
293
+ for (const [re, token] of SEMANTIC_RULES) {
294
+ re.lastIndex = 0
295
+ content = content.replace(re, (match) => {
296
+ stats.semantic++
297
+ if (VERBOSE) console.log(` [semantic] ${file}: ${match} → ${token}`)
298
+ return token
299
+ })
300
+ }
301
+
302
+ // Phase 3b: Brand colors
303
+ for (const [re, token] of BRAND_RULES) {
304
+ re.lastIndex = 0
305
+ content = content.replace(re, (match) => {
306
+ stats.brand++
307
+ if (VERBOSE) console.log(` [brand] ${file}: ${match} → ${token}`)
308
+ return token
309
+ })
310
+ }
311
+
312
+ // Phase 4: Orphan dark: cleanup
313
+ for (const [re, replacement] of ORPHAN_DARK_RULES) {
314
+ re.lastIndex = 0
315
+ content = content.replace(re, (match) => {
316
+ stats.orphanDark++
317
+ if (VERBOSE) console.log(` [orphan-dark] ${file}: removed ${match.trim()}`)
318
+ return replacement
319
+ })
320
+ }
321
+
322
+ // Clean up double/triple spaces left by removals
323
+ content = content.replace(/ +/g, ' ')
324
+ // Clean up space before closing quote
325
+ content = content.replace(/ "/g, '"').replace(/ '/g, "'")
326
+ // But restore intentional space-before-quote in template literals etc.
327
+ // Actually, be conservative — only clean within className attributes
328
+ // This simple approach works for most cases
329
+
330
+ if (content !== original) {
331
+ totalFiles++
332
+ const currentTotal = stats.paired + stats.neutral + stats.semantic + stats.brand + stats.orphanDark
333
+ const replacements = currentTotal - totalReplacements
334
+ totalReplacements = currentTotal
335
+ console.log(`${APPLY ? '✅' : '📋'} ${file} (${replacements} changes)`)
336
+
337
+ if (APPLY) {
338
+ writeFileSync(fullPath, content, 'utf-8')
339
+ }
340
+ }
341
+ }
342
+
343
+ console.log('')
344
+ console.log(`${'='.repeat(60)}`)
345
+ const total = stats.paired + stats.neutral + stats.semantic + stats.brand + stats.orphanDark
346
+ console.log(`${APPLY ? 'APPLIED' : 'DRY RUN'} — ${totalFiles} files, ${total} total replacements`)
347
+ console.log(` Paired (light+dark → token): ${stats.paired}`)
348
+ console.log(` Neutral (single → token): ${stats.neutral}`)
349
+ console.log(` Semantic (color → token): ${stats.semantic}`)
350
+ console.log(` Brand (indigo/purple → token): ${stats.brand}`)
351
+ console.log(` Orphan dark: removed: ${stats.orphanDark}`)
352
+ console.log('')
353
+ if (!APPLY) {
354
+ console.log('Run with --apply to write changes. Make sure you are on a clean branch!')
355
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Tetra Doctor — checks a project for common Tetra integration issues.
5
+ *
6
+ * Scans layout.tsx, providers.tsx, globals.css, package.json, app-config.tsx
7
+ * to find missing or outdated Tetra setup before they become runtime bugs.
8
+ *
9
+ * Usage:
10
+ * tetra-doctor # Check current project
11
+ * tetra-doctor --path /path # Check specific project
12
+ * tetra-doctor --json # JSON output for CI
13
+ * tetra-doctor --fix # Auto-fix safe issues
14
+ * tetra-doctor --ci # GitHub Actions annotations
15
+ *
16
+ * Exit codes:
17
+ * 0 = all critical checks pass
18
+ * 1 = one or more critical checks fail
19
+ */
20
+
21
+ import { program } from 'commander'
22
+ import chalk from 'chalk'
23
+ import { readFileSync, existsSync } from 'fs'
24
+ import { join } from 'path'
25
+ import {
26
+ runDoctorAudit,
27
+ applyFixes,
28
+ formatDoctorReport,
29
+ formatDoctorReportJSON,
30
+ formatDoctorCIAnnotations,
31
+ } from '../lib/audits/doctor-audit.js'
32
+
33
+ /**
34
+ * Re-read the files that are subject to auto-fix, so applyFixes has
35
+ * fresh content after a potential prior fix in the same session.
36
+ */
37
+ function readFixableFiles(projectRoot, report) {
38
+ function tryRead(relativePath) {
39
+ if (!relativePath) return null
40
+ const fullPath = join(projectRoot, relativePath)
41
+ if (!existsSync(fullPath)) return null
42
+ return { path: relativePath, content: readFileSync(fullPath, 'utf-8') }
43
+ }
44
+
45
+ return {
46
+ layout: tryRead(report.files.layout),
47
+ globalsCss: tryRead(report.files.globalsCss),
48
+ }
49
+ }
50
+
51
+ program
52
+ .name('tetra-doctor')
53
+ .description('Check a Tetra project for integration issues (versions, layout, CSS tokens, etc.)')
54
+ .version('1.0.0')
55
+ .option('--path <dir>', 'Project root directory (default: cwd)')
56
+ .option('--json', 'JSON output')
57
+ .option('--fix', 'Auto-fix safe issues (suppressHydrationWarning, dark-mode.css)')
58
+ .option('--ci', 'GitHub Actions annotations for failures')
59
+ .action(async (options) => {
60
+ try {
61
+ const projectRoot = options.path || process.cwd()
62
+
63
+ if (!options.json) {
64
+ console.log(chalk.gray('\n Running checks...'))
65
+ }
66
+
67
+ let report = await runDoctorAudit(projectRoot)
68
+
69
+ // Apply fixes if requested
70
+ if (options.fix && !options.json) {
71
+ const fixableChecks = report.checks.filter(c => !c.pass && c.fixable)
72
+ if (fixableChecks.length === 0) {
73
+ console.log(chalk.gray(' Nothing to auto-fix.\n'))
74
+ } else {
75
+ const fixableFiles = readFixableFiles(projectRoot, report)
76
+ const fixes = applyFixes(projectRoot, report.checks, fixableFiles)
77
+
78
+ for (const fix of fixes) {
79
+ if (fix.success) {
80
+ console.log(chalk.green(` Fixed: ${fix.fix} in ${fix.file}`))
81
+ } else {
82
+ console.log(chalk.red(` Failed to fix ${fix.fix}: ${fix.error}`))
83
+ }
84
+ }
85
+ console.log('')
86
+
87
+ // Re-run after fixes to show updated state
88
+ report = await runDoctorAudit(projectRoot)
89
+ }
90
+ }
91
+
92
+ // Output
93
+ if (options.json) {
94
+ console.log(formatDoctorReportJSON(report))
95
+ } else {
96
+ console.log(formatDoctorReport(report, chalk, projectRoot))
97
+
98
+ if (options.ci) {
99
+ const annotations = formatDoctorCIAnnotations(report)
100
+ if (annotations) {
101
+ console.log(annotations)
102
+ }
103
+ }
104
+ }
105
+
106
+ // Exit code: fail only on critical checks
107
+ process.exit(report.summary.criticalFailed > 0 ? 1 : 0)
108
+ } catch (err) {
109
+ console.error(chalk.red(`\n ERROR: ${err.message}\n`))
110
+ if (!options.json) {
111
+ console.error(chalk.gray(` ${err.stack}`))
112
+ }
113
+ process.exit(1)
114
+ }
115
+ })
116
+
117
+ program.parse()