@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.
- package/bin/tetra-init.js +30 -4
- package/bin/tetra-style-audit.js +86 -0
- package/lib/audits/style-compliance-audit.js +528 -0
- package/lib/checks/codeQuality/appshell-compliance.js +573 -0
- package/lib/checks/health/deploy-readiness.js +222 -0
- package/lib/checks/health/file-organization.js +21 -9
- package/lib/checks/health/gitignore.js +2 -1
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/index.js +1 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|