@soulbatical/tetra-dev-toolkit 1.20.21 → 1.20.23

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,573 @@
1
+ /**
2
+ * Code Quality Check: AppShell Compliance
3
+ *
4
+ * Verifies that consumer projects correctly integrate with the Tetra UI
5
+ * AppShell and design token system. Catches issues that were found in
6
+ * CoachHub and would affect any consumer:
7
+ *
8
+ * 1. globals.css pattern: Has @theme inline block, :root with --tetra-* tokens,
9
+ * and body styled with var(--tetra-*)
10
+ * 2. AppShell usage: Uses AppShell from @soulbatical/tetra-ui (not custom sidebar)
11
+ * 3. Config consistency: No items duplicated across navigation and userMenu
12
+ * 4. TetraAppConfig completeness: Has branding, navigation, layout, theme, features
13
+ * 5. Version check: Installed tetra-ui version meets minimum
14
+ *
15
+ * Severity: high — these issues break theming, layout, and UX consistency
16
+ */
17
+
18
+ import { readFileSync, existsSync } from 'fs'
19
+ import { execFileSync } from 'child_process'
20
+ import { join } from 'path'
21
+ import { glob } from 'glob'
22
+
23
+ // ============================================================================
24
+ // META
25
+ // ============================================================================
26
+
27
+ export const meta = {
28
+ id: 'appshell-compliance',
29
+ name: 'AppShell Compliance',
30
+ category: 'codeQuality',
31
+ severity: 'high',
32
+ description: 'Verifies consumer projects correctly use Tetra AppShell, design tokens, and config patterns'
33
+ }
34
+
35
+ // ============================================================================
36
+ // HELPERS
37
+ // ============================================================================
38
+
39
+ function readFileSafe(filePath) {
40
+ try {
41
+ return readFileSync(filePath, 'utf-8')
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ function findFrontendDir(projectRoot) {
48
+ const candidates = [
49
+ join(projectRoot, 'frontend', 'src'),
50
+ join(projectRoot, 'src'),
51
+ ]
52
+ for (const dir of candidates) {
53
+ if (existsSync(dir)) return dir
54
+ }
55
+ return null
56
+ }
57
+
58
+ function findGlobalsCss(frontendDir) {
59
+ const candidates = [
60
+ join(frontendDir, 'app', 'globals.css'),
61
+ join(frontendDir, 'styles', 'globals.css'),
62
+ join(frontendDir, 'globals.css'),
63
+ ]
64
+ for (const p of candidates) {
65
+ if (existsSync(p)) return p
66
+ }
67
+ const found = glob.sync('**/globals.css', { cwd: frontendDir, absolute: true, ignore: ['**/node_modules/**'] })
68
+ return found[0] || null
69
+ }
70
+
71
+ // ============================================================================
72
+ // CHECK 1: globals.css pattern
73
+ // ============================================================================
74
+
75
+ function checkGlobalsCss(frontendDir) {
76
+ const findings = []
77
+ const cssPath = findGlobalsCss(frontendDir)
78
+
79
+ if (!cssPath) {
80
+ findings.push({
81
+ severity: 'high',
82
+ message: 'No globals.css found — required for Tetra design token integration',
83
+ rule: 'globals-css-missing',
84
+ })
85
+ return findings
86
+ }
87
+
88
+ const content = readFileSafe(cssPath)
89
+ if (!content) return findings
90
+
91
+ // 1a. Must have @theme inline block (not just @import tokens.css)
92
+ if (!content.includes('@theme inline')) {
93
+ findings.push({
94
+ severity: 'critical',
95
+ message: 'globals.css missing `@theme inline` block — Tailwind v4 requires @theme inline to map utility classes to Tetra tokens',
96
+ rule: 'missing-theme-inline',
97
+ fix: 'Add @theme inline { --color-background: var(--tetra-bg); ... } block',
98
+ })
99
+ }
100
+
101
+ // Check for old @import pattern (warn but don't fail if @theme inline exists)
102
+ if (content.includes('@import') && content.includes('tokens.css')) {
103
+ findings.push({
104
+ severity: content.includes('@theme inline') ? 'medium' : 'critical',
105
+ message: 'globals.css uses old @import tokens.css pattern — migrate to @theme inline with --tetra-* tokens',
106
+ rule: 'old-import-pattern',
107
+ })
108
+ }
109
+
110
+ // 1b. Must have :root with --tetra-* tokens
111
+ const hasRootBlock = /:root\s*\{/.test(content)
112
+ const hasTetraTokens = /--tetra-\w+/.test(content)
113
+ if (!hasRootBlock || !hasTetraTokens) {
114
+ findings.push({
115
+ severity: 'critical',
116
+ message: 'globals.css missing :root block with --tetra-* token definitions',
117
+ rule: 'missing-root-tokens',
118
+ fix: 'Add :root { --tetra-bg: #ffffff; --tetra-text: #0f172a; ... } block',
119
+ })
120
+ }
121
+
122
+ // 1c. Must have body styling with var(--tetra-*)
123
+ const bodyMatch = content.match(/body\s*\{([^}]+)\}/s)
124
+ if (!bodyMatch) {
125
+ findings.push({
126
+ severity: 'high',
127
+ message: 'globals.css missing body { } block with Tetra token references',
128
+ rule: 'missing-body-styling',
129
+ fix: 'Add body { background: var(--tetra-bg); color: var(--tetra-text); font-family: var(--tetra-font); }',
130
+ })
131
+ } else {
132
+ const bodyBlock = bodyMatch[1]
133
+ if (!bodyBlock.includes('var(--tetra-')) {
134
+ findings.push({
135
+ severity: 'high',
136
+ message: 'body block does not use var(--tetra-*) tokens — hardcoded values will break theming',
137
+ rule: 'body-no-tetra-vars',
138
+ fix: 'Use var(--tetra-bg), var(--tetra-text), var(--tetra-font) in body styles',
139
+ })
140
+ }
141
+ }
142
+
143
+ // 1d. Check that @theme inline maps to var(--tetra-*), not hardcoded values
144
+ const themeInlineMatch = content.match(/@theme inline\s*\{([^}]+)\}/s)
145
+ if (themeInlineMatch) {
146
+ const themeBlock = themeInlineMatch[1]
147
+ const lines = themeBlock.split('\n').filter(l => l.trim() && !l.trim().startsWith('/*') && !l.trim().startsWith('//'))
148
+ const hardcodedInTheme = lines.filter(l => {
149
+ const valueMatch = l.match(/:\s*([^;]+);/)
150
+ if (!valueMatch) return false
151
+ const value = valueMatch[1].trim()
152
+ return value.startsWith('#') || value.startsWith('rgb') || value.startsWith('hsl')
153
+ })
154
+
155
+ if (hardcodedInTheme.length > 0) {
156
+ findings.push({
157
+ severity: 'high',
158
+ message: `@theme inline has ${hardcodedInTheme.length} hardcoded value(s) — should reference var(--tetra-*) tokens`,
159
+ rule: 'theme-inline-hardcoded',
160
+ details: hardcodedInTheme.map(l => l.trim()).slice(0, 5),
161
+ })
162
+ }
163
+ }
164
+
165
+ // 1e. Dark mode block should exist
166
+ const hasDarkBlock = /(?:html\.dark|\.dark|\[data-theme="dark"\])\s*(?:,\s*(?:html\.dark|\.dark|\[data-theme="dark"\])\s*)*\{/.test(content)
167
+ if (!hasDarkBlock) {
168
+ findings.push({
169
+ severity: 'medium',
170
+ message: 'globals.css missing dark mode override block (html.dark or [data-theme="dark"])',
171
+ rule: 'missing-dark-mode',
172
+ fix: 'Add html.dark, [data-theme="dark"] { --tetra-bg: ...; --tetra-text: ...; } block',
173
+ })
174
+ }
175
+
176
+ return findings
177
+ }
178
+
179
+ // ============================================================================
180
+ // CHECK 2: AppShell usage
181
+ // ============================================================================
182
+
183
+ function checkAppShellUsage(frontendDir) {
184
+ const findings = []
185
+
186
+ const files = glob.sync('**/*.{tsx,ts,jsx}', {
187
+ cwd: frontendDir,
188
+ ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**'],
189
+ absolute: false,
190
+ })
191
+
192
+ let usesTetraAppShell = false
193
+ let hasCustomSidebar = false
194
+ const customSidebarFiles = []
195
+
196
+ for (const file of files) {
197
+ const content = readFileSafe(join(frontendDir, file))
198
+ if (!content) continue
199
+
200
+ // Check for tetra-ui AppShell import
201
+ if (content.includes('@soulbatical/tetra-ui') && /\bAppShell\b/.test(content)) {
202
+ usesTetraAppShell = true
203
+ }
204
+
205
+ // Detect custom sidebar implementations (not from tetra-ui)
206
+ if (
207
+ (/\bSidebar\b/.test(content) || /\bAppSidebar\b/.test(content)) &&
208
+ !content.includes('@soulbatical/tetra-ui') &&
209
+ (file.toLowerCase().includes('sidebar') || file.toLowerCase().includes('layout'))
210
+ ) {
211
+ if (content.includes('export') && (/function\s+\w*[Ss]idebar/.test(content) || /const\s+\w*[Ss]idebar/.test(content))) {
212
+ hasCustomSidebar = true
213
+ customSidebarFiles.push(file)
214
+ }
215
+ }
216
+ }
217
+
218
+ if (!usesTetraAppShell) {
219
+ findings.push({
220
+ severity: 'high',
221
+ message: 'Project does not use AppShell from @soulbatical/tetra-ui — should use the standard shell for consistent UX',
222
+ rule: 'no-tetra-appshell',
223
+ fix: 'Import and use AppShell from @soulbatical/tetra-ui in your root layout',
224
+ })
225
+ }
226
+
227
+ if (hasCustomSidebar && usesTetraAppShell) {
228
+ findings.push({
229
+ severity: 'medium',
230
+ message: `Custom sidebar component(s) found alongside Tetra AppShell: ${customSidebarFiles.join(', ')} — remove custom implementations`,
231
+ rule: 'custom-sidebar-with-appshell',
232
+ })
233
+ }
234
+
235
+ return findings
236
+ }
237
+
238
+ // ============================================================================
239
+ // CHECK 3: Config consistency (no duplicate nav/userMenu items)
240
+ // ============================================================================
241
+
242
+ function checkConfigConsistency(frontendDir) {
243
+ const findings = []
244
+
245
+ const files = glob.sync('**/*.{ts,tsx}', {
246
+ cwd: frontendDir,
247
+ ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**'],
248
+ absolute: false,
249
+ })
250
+
251
+ for (const file of files) {
252
+ const content = readFileSafe(join(frontendDir, file))
253
+ if (!content) continue
254
+
255
+ // Look for files defining TetraAppConfig or app config objects
256
+ if (!content.includes('navigation') || !content.includes('userMenu')) continue
257
+
258
+ // Extract navigation item labels/titles
259
+ const navLabels = extractLabels(content, 'navigation')
260
+ const userMenuLabels = extractLabels(content, 'userMenu')
261
+
262
+ if (navLabels.length === 0 || userMenuLabels.length === 0) continue
263
+
264
+ // Find duplicates between navigation and userMenu
265
+ const duplicates = navLabels.filter(label => userMenuLabels.includes(label))
266
+ if (duplicates.length > 0) {
267
+ findings.push({
268
+ file,
269
+ severity: 'medium',
270
+ message: `Config has items in BOTH navigation and userMenu: ${duplicates.join(', ')} — each item should appear in only one location`,
271
+ rule: 'duplicate-nav-usermenu',
272
+ fix: `Move "${duplicates.join('", "')}" to either navigation OR userMenu, not both`,
273
+ })
274
+ }
275
+
276
+ // Check for duplicate items within navigation itself
277
+ const navDuplicates = findDuplicates(navLabels)
278
+ if (navDuplicates.length > 0) {
279
+ findings.push({
280
+ file,
281
+ severity: 'medium',
282
+ message: `Duplicate items within navigation: ${navDuplicates.join(', ')}`,
283
+ rule: 'duplicate-nav-items',
284
+ })
285
+ }
286
+
287
+ // Check for duplicate items within userMenu itself
288
+ const menuDuplicates = findDuplicates(userMenuLabels)
289
+ if (menuDuplicates.length > 0) {
290
+ findings.push({
291
+ file,
292
+ severity: 'medium',
293
+ message: `Duplicate items within userMenu: ${menuDuplicates.join(', ')}`,
294
+ rule: 'duplicate-usermenu-items',
295
+ })
296
+ }
297
+ }
298
+
299
+ return findings
300
+ }
301
+
302
+ function extractLabels(content, sectionName) {
303
+ const labels = []
304
+ const sectionRegex = new RegExp(`${sectionName}\\s*[:\\[]`, 'g')
305
+ let match
306
+ while ((match = sectionRegex.exec(content)) !== null) {
307
+ const remaining = content.slice(match.index, match.index + 2000)
308
+ const labelMatches = remaining.matchAll(/(?:label|title)\s*:\s*['"]([^'"]+)['"]/g)
309
+ for (const m of labelMatches) {
310
+ labels.push(m[1])
311
+ }
312
+ }
313
+ return labels
314
+ }
315
+
316
+ function findDuplicates(arr) {
317
+ const seen = new Set()
318
+ const dupes = new Set()
319
+ for (const item of arr) {
320
+ if (seen.has(item)) dupes.add(item)
321
+ seen.add(item)
322
+ }
323
+ return [...dupes]
324
+ }
325
+
326
+ // ============================================================================
327
+ // CHECK 4: TetraAppConfig completeness
328
+ // ============================================================================
329
+
330
+ function checkConfigCompleteness(frontendDir) {
331
+ const findings = []
332
+
333
+ const files = glob.sync('**/*.{ts,tsx}', {
334
+ cwd: frontendDir,
335
+ ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**'],
336
+ absolute: false,
337
+ })
338
+
339
+ let foundConfig = false
340
+ const requiredSections = ['branding', 'navigation', 'layout', 'theme', 'features']
341
+
342
+ for (const file of files) {
343
+ const content = readFileSafe(join(frontendDir, file))
344
+ if (!content) continue
345
+
346
+ if (!content.includes('TetraAppConfig')) continue
347
+ foundConfig = true
348
+
349
+ const missingSections = []
350
+ for (const section of requiredSections) {
351
+ const sectionRegex = new RegExp(`\\b${section}\\s*[:\\?]`)
352
+ if (!sectionRegex.test(content)) {
353
+ missingSections.push(section)
354
+ }
355
+ }
356
+
357
+ if (missingSections.length > 0) {
358
+ findings.push({
359
+ file,
360
+ severity: 'medium',
361
+ message: `TetraAppConfig missing section(s): ${missingSections.join(', ')}`,
362
+ rule: 'incomplete-config',
363
+ fix: `Add ${missingSections.map(s => `${s}: { ... }`).join(', ')} to your TetraAppConfig`,
364
+ })
365
+ }
366
+ }
367
+
368
+ if (!foundConfig) {
369
+ findings.push({
370
+ severity: 'high',
371
+ message: 'No TetraAppConfig found — project should define a typed config for AppShell integration',
372
+ rule: 'no-tetra-config',
373
+ fix: 'Create a config file with `const config: TetraAppConfig = { branding, navigation, layout, theme, features }`',
374
+ })
375
+ }
376
+
377
+ return findings
378
+ }
379
+
380
+ // ============================================================================
381
+ // CHECK 5: Version check
382
+ // ============================================================================
383
+
384
+ function checkTetraUiVersion(projectRoot) {
385
+ const findings = []
386
+
387
+ const pkgJsonPath = join(projectRoot, 'frontend', 'package.json')
388
+ const altPkgJsonPath = join(projectRoot, 'package.json')
389
+
390
+ const pkgContent = readFileSafe(pkgJsonPath) || readFileSafe(altPkgJsonPath)
391
+ if (!pkgContent) {
392
+ findings.push({
393
+ severity: 'low',
394
+ message: 'Could not read package.json to check tetra-ui version',
395
+ rule: 'version-check-skipped',
396
+ })
397
+ return findings
398
+ }
399
+
400
+ let pkg
401
+ try {
402
+ pkg = JSON.parse(pkgContent)
403
+ } catch {
404
+ return findings
405
+ }
406
+
407
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
408
+ const tetraSpec = deps['@soulbatical/tetra-ui']
409
+
410
+ if (!tetraSpec) {
411
+ findings.push({
412
+ severity: 'high',
413
+ message: '@soulbatical/tetra-ui is not listed as a dependency',
414
+ rule: 'tetra-ui-not-installed',
415
+ fix: 'Run npm install @soulbatical/tetra-ui',
416
+ })
417
+ return findings
418
+ }
419
+
420
+ // Check installed version from node_modules
421
+ const installedPkgPath = join(projectRoot, 'frontend', 'node_modules', '@soulbatical', 'tetra-ui', 'package.json')
422
+ const altInstalledPkgPath = join(projectRoot, 'node_modules', '@soulbatical', 'tetra-ui', 'package.json')
423
+ const installedPkg = readFileSafe(installedPkgPath) || readFileSafe(altInstalledPkgPath)
424
+
425
+ let installedVersion = null
426
+ if (installedPkg) {
427
+ try {
428
+ installedVersion = JSON.parse(installedPkg).version
429
+ } catch { /* ignore */ }
430
+ }
431
+
432
+ // Try to get latest published version (best effort, using execFileSync to avoid shell injection)
433
+ let latestVersion = null
434
+ try {
435
+ latestVersion = execFileSync('npm', ['view', '@soulbatical/tetra-ui', 'version'], {
436
+ timeout: 10000,
437
+ encoding: 'utf-8',
438
+ }).trim()
439
+ } catch {
440
+ // Network or registry not available — skip
441
+ }
442
+
443
+ if (installedVersion && latestVersion && installedVersion !== latestVersion) {
444
+ const installed = installedVersion.split('.').map(Number)
445
+ const latest = latestVersion.split('.').map(Number)
446
+ const isOutdated =
447
+ installed[0] < latest[0] ||
448
+ (installed[0] === latest[0] && installed[1] < latest[1]) ||
449
+ (installed[0] === latest[0] && installed[1] === latest[1] && installed[2] < latest[2])
450
+
451
+ if (isOutdated) {
452
+ findings.push({
453
+ severity: installed[0] < latest[0] ? 'high' : 'medium',
454
+ message: `@soulbatical/tetra-ui is outdated: installed ${installedVersion}, latest ${latestVersion}`,
455
+ rule: 'tetra-ui-outdated',
456
+ fix: `Run npm install @soulbatical/tetra-ui@${latestVersion}`,
457
+ })
458
+ }
459
+ } else if (installedVersion) {
460
+ findings.push({
461
+ severity: 'low',
462
+ message: `@soulbatical/tetra-ui version ${installedVersion} installed (could not verify latest)`,
463
+ rule: 'version-info',
464
+ })
465
+ }
466
+
467
+ return findings
468
+ }
469
+
470
+ // ============================================================================
471
+ // MAIN CHECK
472
+ // ============================================================================
473
+
474
+ export async function run(config, projectRoot) {
475
+ const result = {
476
+ passed: true,
477
+ findings: [],
478
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
479
+ details: {
480
+ globalsCss: { checked: false, issues: 0 },
481
+ appShell: { checked: false, issues: 0 },
482
+ configConsistency: { checked: false, issues: 0 },
483
+ configCompleteness: { checked: false, issues: 0 },
484
+ versionCheck: { checked: false, issues: 0 },
485
+ }
486
+ }
487
+
488
+ const frontendDir = findFrontendDir(projectRoot)
489
+ if (!frontendDir) {
490
+ result.findings.push({
491
+ file: 'project',
492
+ line: 0,
493
+ severity: 'low',
494
+ message: 'No frontend source directory found — skipping AppShell compliance check',
495
+ })
496
+ return result
497
+ }
498
+
499
+ // ── 1. globals.css ──
500
+ const cssFindings = checkGlobalsCss(frontendDir)
501
+ result.details.globalsCss.checked = true
502
+ result.details.globalsCss.issues = cssFindings.length
503
+ for (const f of cssFindings) {
504
+ result.summary[f.severity] = (result.summary[f.severity] || 0) + 1
505
+ result.summary.total++
506
+ result.findings.push({ file: 'globals.css', line: 0, ...f })
507
+ }
508
+
509
+ // ── 2. AppShell usage ──
510
+ const shellFindings = checkAppShellUsage(frontendDir)
511
+ result.details.appShell.checked = true
512
+ result.details.appShell.issues = shellFindings.length
513
+ for (const f of shellFindings) {
514
+ result.summary[f.severity] = (result.summary[f.severity] || 0) + 1
515
+ result.summary.total++
516
+ result.findings.push({ file: 'project', line: 0, ...f })
517
+ }
518
+
519
+ // ── 3. Config consistency ──
520
+ const consistencyFindings = checkConfigConsistency(frontendDir)
521
+ result.details.configConsistency.checked = true
522
+ result.details.configConsistency.issues = consistencyFindings.length
523
+ for (const f of consistencyFindings) {
524
+ result.summary[f.severity] = (result.summary[f.severity] || 0) + 1
525
+ result.summary.total++
526
+ result.findings.push({ line: 0, ...f })
527
+ }
528
+
529
+ // ── 4. Config completeness ──
530
+ const completenessFindings = checkConfigCompleteness(frontendDir)
531
+ result.details.configCompleteness.checked = true
532
+ result.details.configCompleteness.issues = completenessFindings.length
533
+ for (const f of completenessFindings) {
534
+ result.summary[f.severity] = (result.summary[f.severity] || 0) + 1
535
+ result.summary.total++
536
+ result.findings.push({ file: 'project', line: 0, ...f })
537
+ }
538
+
539
+ // ── 5. Version check ──
540
+ const versionFindings = checkTetraUiVersion(projectRoot)
541
+ result.details.versionCheck.checked = true
542
+ result.details.versionCheck.issues = versionFindings.length
543
+ for (const f of versionFindings) {
544
+ result.summary[f.severity] = (result.summary[f.severity] || 0) + 1
545
+ result.summary.total++
546
+ result.findings.push({ file: 'package.json', line: 0, ...f })
547
+ }
548
+
549
+ // ── Determine pass/fail ──
550
+ result.passed = result.summary.critical === 0 && result.summary.high === 0
551
+
552
+ // ── Summary finding ──
553
+ if (!result.passed) {
554
+ const d = result.details
555
+ result.findings.unshift({
556
+ file: 'project',
557
+ line: 0,
558
+ severity: 'high',
559
+ message: [
560
+ `AppShell compliance FAILED:`,
561
+ `globals.css: ${d.globalsCss.issues} issue(s),`,
562
+ `AppShell: ${d.appShell.issues} issue(s),`,
563
+ `Config consistency: ${d.configConsistency.issues} issue(s),`,
564
+ `Config completeness: ${d.configCompleteness.issues} issue(s),`,
565
+ `Version: ${d.versionCheck.issues} issue(s).`,
566
+ `Fix: ensure globals.css has @theme inline + :root --tetra-* tokens,`,
567
+ `use AppShell from @soulbatical/tetra-ui, and define a complete TetraAppConfig.`,
568
+ ].join(' '),
569
+ })
570
+ }
571
+
572
+ return result
573
+ }