@soulbatical/tetra-dev-toolkit 1.20.21 → 1.20.22
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/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 +1 -1
package/bin/tetra-init.js
CHANGED
|
@@ -442,8 +442,8 @@ async function initCi(config, options) {
|
|
|
442
442
|
// Detect project structure
|
|
443
443
|
const hasBackend = existsSync(join(projectRoot, 'backend/package.json'))
|
|
444
444
|
const hasFrontend = existsSync(join(projectRoot, 'frontend/package.json'))
|
|
445
|
-
const hasMigrations = existsSync(join(projectRoot, '
|
|
446
|
-
existsSync(join(projectRoot, 'supabase/migrations'))
|
|
445
|
+
const hasMigrations = existsSync(join(projectRoot, 'supabase/migrations')) ||
|
|
446
|
+
existsSync(join(projectRoot, 'backend/supabase/migrations'))
|
|
447
447
|
|
|
448
448
|
let workspaces
|
|
449
449
|
if (hasBackend && hasFrontend) {
|
|
@@ -488,6 +488,31 @@ ${withLines.join('\n')}
|
|
|
488
488
|
`
|
|
489
489
|
|
|
490
490
|
writeIfMissing(join(workflowDir, 'quality.yml'), workflowContent, options)
|
|
491
|
+
|
|
492
|
+
// Deploy Migrations workflow (only if supabase migrations exist)
|
|
493
|
+
if (hasMigrations) {
|
|
494
|
+
const deployContent = `name: Deploy Migrations
|
|
495
|
+
|
|
496
|
+
on:
|
|
497
|
+
push:
|
|
498
|
+
branches: [main]
|
|
499
|
+
paths:
|
|
500
|
+
- 'supabase/migrations/**'
|
|
501
|
+
|
|
502
|
+
jobs:
|
|
503
|
+
deploy:
|
|
504
|
+
uses: mralbertzwolle/tetra/.github/workflows/deploy-migrations.yml@main
|
|
505
|
+
secrets: inherit
|
|
506
|
+
`
|
|
507
|
+
writeIfMissing(join(workflowDir, 'deploy-migrations.yml'), deployContent, options)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Ensure supabase/migrations/ directory exists at root
|
|
511
|
+
const migrationsDir = join(projectRoot, 'supabase/migrations')
|
|
512
|
+
if (!existsSync(migrationsDir)) {
|
|
513
|
+
mkdirSync(migrationsDir, { recursive: true })
|
|
514
|
+
console.log(` 📁 Created supabase/migrations/`)
|
|
515
|
+
}
|
|
491
516
|
}
|
|
492
517
|
|
|
493
518
|
function checkCompleteness() {
|
|
@@ -529,8 +554,9 @@ function checkCompleteness() {
|
|
|
529
554
|
{ path: 'frontend/next.config.ts', category: 'frontend', required: true },
|
|
530
555
|
{ path: 'frontend/src/app/layout.tsx', category: 'frontend', required: true },
|
|
531
556
|
|
|
532
|
-
// Database
|
|
533
|
-
{ path: 'supabase/migrations', category: 'database', required:
|
|
557
|
+
// Database & CI
|
|
558
|
+
{ path: 'supabase/migrations', category: 'database', required: true },
|
|
559
|
+
{ path: '.github/workflows/deploy-migrations.yml', category: 'database', required: false },
|
|
534
560
|
]
|
|
535
561
|
|
|
536
562
|
let currentCategory = ''
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Deploy Readiness for Private Packages
|
|
3
|
+
*
|
|
4
|
+
* Verifies that a project is correctly configured to deploy with
|
|
5
|
+
* @soulbatical private npm packages on Railway and Netlify.
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* 1. .npmrc exists with @soulbatical scope + ${NPM_TOKEN} auth
|
|
9
|
+
* 2. No file: references to @soulbatical packages (breaks CI/CD)
|
|
10
|
+
* 3. Railway projects have a Dockerfile with npm auth step
|
|
11
|
+
* 4. Dockerfile ARG NPM_TOKEN + .npmrc creation happens BEFORE npm install
|
|
12
|
+
*
|
|
13
|
+
* Score: 0-4 (1 per aspect)
|
|
14
|
+
* Skipped if project has no @soulbatical dependencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { createCheck } from './types.js'
|
|
20
|
+
|
|
21
|
+
const SOULBATICAL_PACKAGES = [
|
|
22
|
+
'@soulbatical/tetra-core',
|
|
23
|
+
'@soulbatical/tetra-ui',
|
|
24
|
+
'@soulbatical/tetra-dev-toolkit',
|
|
25
|
+
'@soulbatical/stella'
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collect all @soulbatical dependency references from a project's package.json files
|
|
30
|
+
*/
|
|
31
|
+
function findSoulbaticalDeps(projectPath) {
|
|
32
|
+
const pkgPaths = [
|
|
33
|
+
{ path: join(projectPath, 'package.json'), label: 'root' },
|
|
34
|
+
{ path: join(projectPath, 'backend', 'package.json'), label: 'backend' },
|
|
35
|
+
{ path: join(projectPath, 'frontend', 'package.json'), label: 'frontend' },
|
|
36
|
+
{ path: join(projectPath, 'backend-mcp', 'package.json'), label: 'backend-mcp' },
|
|
37
|
+
{ path: join(projectPath, 'bot', 'package.json'), label: 'bot' }
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const deps = []
|
|
41
|
+
const fileRefs = []
|
|
42
|
+
|
|
43
|
+
for (const { path: pkgPath, label } of pkgPaths) {
|
|
44
|
+
if (!existsSync(pkgPath)) continue
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
47
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
48
|
+
|
|
49
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
50
|
+
if (!name.startsWith('@soulbatical/')) continue
|
|
51
|
+
deps.push({ name, version, location: label })
|
|
52
|
+
|
|
53
|
+
if (version.startsWith('file:') || version.startsWith('link:')) {
|
|
54
|
+
fileRefs.push({ name, version, location: label })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { deps, fileRefs }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if .npmrc has proper config for private packages
|
|
65
|
+
*/
|
|
66
|
+
function checkNpmrc(projectPath) {
|
|
67
|
+
const npmrcPath = join(projectPath, '.npmrc')
|
|
68
|
+
if (!existsSync(npmrcPath)) return { exists: false, hasAuth: false, hasScope: false, content: '' }
|
|
69
|
+
|
|
70
|
+
const content = readFileSync(npmrcPath, 'utf-8')
|
|
71
|
+
return {
|
|
72
|
+
exists: true,
|
|
73
|
+
hasAuth: content.includes('_authToken=${NPM_TOKEN}') || content.includes('_authToken=$NPM_TOKEN'),
|
|
74
|
+
hasScope: content.includes('@soulbatical:registry='),
|
|
75
|
+
content
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if Dockerfile has proper npm auth for private packages
|
|
81
|
+
*/
|
|
82
|
+
function checkDockerfile(projectPath) {
|
|
83
|
+
// Check common Dockerfile locations
|
|
84
|
+
const dockerfiles = [
|
|
85
|
+
join(projectPath, 'Dockerfile'),
|
|
86
|
+
join(projectPath, 'Dockerfile.prod'),
|
|
87
|
+
join(projectPath, 'backend', 'Dockerfile')
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
for (const dfPath of dockerfiles) {
|
|
91
|
+
if (!existsSync(dfPath)) continue
|
|
92
|
+
const content = readFileSync(dfPath, 'utf-8')
|
|
93
|
+
|
|
94
|
+
const hasArgNpmToken = /ARG\s+NPM_TOKEN/i.test(content)
|
|
95
|
+
const hasNpmrcCreation = content.includes('.npmrc') && content.includes('authToken')
|
|
96
|
+
const hasNpmInstall = /npm\s+(ci|install)/i.test(content)
|
|
97
|
+
|
|
98
|
+
// Check ordering: .npmrc creation should be BEFORE npm install
|
|
99
|
+
let orderCorrect = false
|
|
100
|
+
if (hasNpmrcCreation && hasNpmInstall) {
|
|
101
|
+
const npmrcPos = content.indexOf('.npmrc')
|
|
102
|
+
const installPos = content.search(/npm\s+(ci|install)/i)
|
|
103
|
+
orderCorrect = npmrcPos < installPos
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
exists: true,
|
|
108
|
+
path: dfPath,
|
|
109
|
+
hasArgNpmToken,
|
|
110
|
+
hasNpmrcCreation,
|
|
111
|
+
hasNpmInstall,
|
|
112
|
+
orderCorrect
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { exists: false }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Detect if project deploys to Railway (has railway config or infrastructure hints)
|
|
121
|
+
*/
|
|
122
|
+
function isRailwayProject(projectPath) {
|
|
123
|
+
if (existsSync(join(projectPath, 'railway.json'))) return true
|
|
124
|
+
if (existsSync(join(projectPath, 'railway.toml'))) return true
|
|
125
|
+
|
|
126
|
+
// Check INFRASTRUCTURE.yml for Railway hosting
|
|
127
|
+
const infraPath = join(projectPath, '.ralph', 'INFRASTRUCTURE.yml')
|
|
128
|
+
if (existsSync(infraPath)) {
|
|
129
|
+
try {
|
|
130
|
+
const content = readFileSync(infraPath, 'utf-8')
|
|
131
|
+
if (content.toLowerCase().includes('railway')) return true
|
|
132
|
+
} catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function check(projectPath) {
|
|
139
|
+
const result = createCheck('deploy-readiness', 4, {
|
|
140
|
+
hasSoulbaticalDeps: false,
|
|
141
|
+
soulbaticalDeps: [],
|
|
142
|
+
fileRefs: [],
|
|
143
|
+
npmrc: {},
|
|
144
|
+
dockerfile: {},
|
|
145
|
+
isRailway: false,
|
|
146
|
+
skipped: false
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Find @soulbatical dependencies
|
|
150
|
+
const { deps, fileRefs } = findSoulbaticalDeps(projectPath)
|
|
151
|
+
result.details.soulbaticalDeps = deps
|
|
152
|
+
result.details.fileRefs = fileRefs
|
|
153
|
+
|
|
154
|
+
// Skip if no @soulbatical dependencies
|
|
155
|
+
if (deps.length === 0) {
|
|
156
|
+
result.details.skipped = true
|
|
157
|
+
result.details.message = 'No @soulbatical dependencies — check skipped'
|
|
158
|
+
result.score = result.maxScore // Full score if not applicable
|
|
159
|
+
return result
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
result.details.hasSoulbaticalDeps = true
|
|
163
|
+
const isRailway = isRailwayProject(projectPath)
|
|
164
|
+
result.details.isRailway = isRailway
|
|
165
|
+
|
|
166
|
+
// --- Check 1: .npmrc exists with auth + scope (+1 point) ---
|
|
167
|
+
const npmrc = checkNpmrc(projectPath)
|
|
168
|
+
result.details.npmrc = npmrc
|
|
169
|
+
|
|
170
|
+
if (npmrc.exists && npmrc.hasAuth && npmrc.hasScope) {
|
|
171
|
+
result.score += 1
|
|
172
|
+
} else if (npmrc.exists && npmrc.hasAuth) {
|
|
173
|
+
result.score += 0.5 // Auth OK but missing scope
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Check 2: No file: references (+1 point) ---
|
|
177
|
+
if (fileRefs.length === 0) {
|
|
178
|
+
result.score += 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Check 3: Railway projects need Dockerfile (+1 point) ---
|
|
182
|
+
if (isRailway) {
|
|
183
|
+
const df = checkDockerfile(projectPath)
|
|
184
|
+
result.details.dockerfile = df
|
|
185
|
+
|
|
186
|
+
if (df.exists && df.hasArgNpmToken && df.hasNpmrcCreation && df.orderCorrect) {
|
|
187
|
+
result.score += 1
|
|
188
|
+
} else if (df.exists && df.hasNpmInstall) {
|
|
189
|
+
result.score += 0.5 // Dockerfile exists but missing npm auth
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
result.score += 1 // Not Railway — full score for this check
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Check 4: Overall deploy confidence (+1 point) ---
|
|
196
|
+
// All non-dev @soulbatical deps use semver (not file:, not link:)
|
|
197
|
+
const nonDevFileRefs = fileRefs.filter(r => r.location !== 'root') // root devDeps are OK locally
|
|
198
|
+
const allSemver = nonDevFileRefs.length === 0
|
|
199
|
+
const npmrcReady = npmrc.exists && npmrc.hasAuth
|
|
200
|
+
const dockerReady = !isRailway || (result.details.dockerfile.exists && result.details.dockerfile.hasArgNpmToken)
|
|
201
|
+
|
|
202
|
+
if (allSemver && npmrcReady && dockerReady) {
|
|
203
|
+
result.score += 1
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
207
|
+
|
|
208
|
+
// Summary message
|
|
209
|
+
if (result.score < result.maxScore) {
|
|
210
|
+
result.status = 'warning'
|
|
211
|
+
const issues = []
|
|
212
|
+
if (!npmrc.exists) issues.push('missing .npmrc')
|
|
213
|
+
else if (!npmrc.hasAuth) issues.push('.npmrc missing ${NPM_TOKEN} auth')
|
|
214
|
+
else if (!npmrc.hasScope) issues.push('.npmrc missing @soulbatical:registry scope')
|
|
215
|
+
if (fileRefs.length > 0) issues.push(`${fileRefs.length} file: ref(s) to @soulbatical packages — will break CI/CD`)
|
|
216
|
+
if (isRailway && !result.details.dockerfile.exists) issues.push('Railway project without Dockerfile �� private packages will fail')
|
|
217
|
+
else if (isRailway && !result.details.dockerfile.hasArgNpmToken) issues.push('Dockerfile missing ARG NPM_TOKEN')
|
|
218
|
+
result.details.message = issues.join(', ')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
@@ -102,9 +102,15 @@ const ROOT_CLUTTER_DIRS = new Set([
|
|
|
102
102
|
])
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Recursively find files matching a predicate, respecting ignored dirs
|
|
105
|
+
* Recursively find files matching a predicate, respecting ignored dirs.
|
|
106
|
+
* @param {string} dir - Directory to scan
|
|
107
|
+
* @param {Function} predicate - (name, fullPath) => boolean
|
|
108
|
+
* @param {number} maxDepth - Max recursion depth
|
|
109
|
+
* @param {number} currentDepth - Current depth
|
|
110
|
+
* @param {string} rootPath - Project root (for relative path matching against gitignore)
|
|
111
|
+
* @param {Set<string>} gitignored - Gitignored path patterns from .gitignore
|
|
106
112
|
*/
|
|
107
|
-
function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
|
|
113
|
+
function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0, rootPath = dir, gitignored = new Set()) {
|
|
108
114
|
const results = []
|
|
109
115
|
if (currentDepth >= maxDepth) return results
|
|
110
116
|
|
|
@@ -117,7 +123,10 @@ function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
|
|
|
117
123
|
|
|
118
124
|
const fullPath = join(dir, name)
|
|
119
125
|
if (entry.isDirectory()) {
|
|
120
|
-
|
|
126
|
+
// Skip directories matched by .gitignore entries
|
|
127
|
+
const relPath = relative(rootPath, fullPath)
|
|
128
|
+
if (gitignored.has(name) || gitignored.has(relPath)) continue
|
|
129
|
+
results.push(...findFiles(fullPath, predicate, maxDepth, currentDepth + 1, rootPath, gitignored))
|
|
121
130
|
} else if (entry.isFile() && predicate(name, fullPath)) {
|
|
122
131
|
results.push(fullPath)
|
|
123
132
|
}
|
|
@@ -132,7 +141,7 @@ const ALLOWED_NESTED_DOCS_PARENTS = new Set(['.ralph'])
|
|
|
132
141
|
* Find directories named "docs" that are not at the repo root,
|
|
133
142
|
* excluding allowed parents like .ralph/docs
|
|
134
143
|
*/
|
|
135
|
-
function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0) {
|
|
144
|
+
function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0, gitignored = new Set()) {
|
|
136
145
|
const results = []
|
|
137
146
|
if (currentDepth >= maxDepth) return results
|
|
138
147
|
|
|
@@ -146,6 +155,9 @@ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0
|
|
|
146
155
|
|
|
147
156
|
const fullPath = join(currentDir, name)
|
|
148
157
|
const relFromRoot = relative(rootPath, fullPath)
|
|
158
|
+
|
|
159
|
+
// Skip gitignored directories
|
|
160
|
+
if (gitignored.has(name) || gitignored.has(relFromRoot)) continue
|
|
149
161
|
const topDir = relFromRoot.split('/')[0]
|
|
150
162
|
|
|
151
163
|
if (name === 'docs' && currentDepth > 0) {
|
|
@@ -154,7 +166,7 @@ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0
|
|
|
154
166
|
results.push(fullPath)
|
|
155
167
|
}
|
|
156
168
|
} else {
|
|
157
|
-
results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1))
|
|
169
|
+
results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1, gitignored))
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
return results
|
|
@@ -192,7 +204,7 @@ export async function check(projectPath) {
|
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
// --- Check 1: Stray .md files ---
|
|
195
|
-
const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'))
|
|
207
|
+
const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'), 5, 0, projectPath, gitignoredDirs)
|
|
196
208
|
const strayMd = []
|
|
197
209
|
|
|
198
210
|
for (const file of allMdFiles) {
|
|
@@ -224,7 +236,7 @@ export async function check(projectPath) {
|
|
|
224
236
|
result.details.totalStrayMd = strayMd.length
|
|
225
237
|
|
|
226
238
|
// --- Check 2: Stray scripts ---
|
|
227
|
-
const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'))
|
|
239
|
+
const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'), 5, 0, projectPath, gitignoredDirs)
|
|
228
240
|
const strayScripts = []
|
|
229
241
|
|
|
230
242
|
for (const file of allScripts) {
|
|
@@ -246,7 +258,7 @@ export async function check(projectPath) {
|
|
|
246
258
|
result.details.totalStrayScripts = strayScripts.length
|
|
247
259
|
|
|
248
260
|
// --- Check 3: Stray config files (.yml/.yaml) ---
|
|
249
|
-
const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'))
|
|
261
|
+
const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'), 5, 0, projectPath, gitignoredDirs)
|
|
250
262
|
const strayConfigs = []
|
|
251
263
|
|
|
252
264
|
for (const file of allConfigs) {
|
|
@@ -352,7 +364,7 @@ export async function check(projectPath) {
|
|
|
352
364
|
result.details.totalRootClutter = rootClutter.length
|
|
353
365
|
|
|
354
366
|
// --- Check 6: Nested docs/ directories ---
|
|
355
|
-
const nestedDocs = findNestedDocsDirs(projectPath, projectPath)
|
|
367
|
+
const nestedDocs = findNestedDocsDirs(projectPath, projectPath, 4, 0, gitignoredDirs)
|
|
356
368
|
const nestedDocsRel = nestedDocs.map(d => relative(projectPath, d))
|
|
357
369
|
|
|
358
370
|
result.details.nestedDocsDirs = nestedDocsRel
|
|
@@ -18,7 +18,8 @@ const CRITICAL = [
|
|
|
18
18
|
const RECOMMENDED = [
|
|
19
19
|
{ name: 'credential files (*.pem, *.key)', patterns: ['*.pem', '*.key'] },
|
|
20
20
|
{ name: 'Supabase temp', patterns: ['.supabase', 'supabase/.temp'] },
|
|
21
|
-
{ name: '.DS_Store', patterns: ['.DS_Store'] }
|
|
21
|
+
{ name: '.DS_Store', patterns: ['.DS_Store'] },
|
|
22
|
+
{ name: 'Ralph runtime state (.ralph/)', patterns: ['.ralph'] }
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
function isCovered(lines, pattern) {
|
|
@@ -43,3 +43,4 @@ export { check as checkReleasePipeline } from './release-pipeline.js'
|
|
|
43
43
|
export { check as checkTestStructure } from './test-structure.js'
|
|
44
44
|
export { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
45
45
|
export { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
|
|
46
|
+
export { check as checkDeployReadiness } from './deploy-readiness.js'
|
|
@@ -39,6 +39,7 @@ import { check as checkReleasePipeline } from './release-pipeline.js'
|
|
|
39
39
|
import { check as checkTestStructure } from './test-structure.js'
|
|
40
40
|
import { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
41
41
|
import { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
|
|
42
|
+
import { check as checkDeployReadiness } from './deploy-readiness.js'
|
|
42
43
|
import { calculateHealthStatus } from './types.js'
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -86,7 +87,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
86
87
|
checkReleasePipeline(projectPath),
|
|
87
88
|
checkTestStructure(projectPath),
|
|
88
89
|
checkSentryMonitoring(projectPath),
|
|
89
|
-
checkShadcnUiTokens(projectPath)
|
|
90
|
+
checkShadcnUiTokens(projectPath),
|
|
91
|
+
checkDeployReadiness(projectPath)
|
|
90
92
|
])
|
|
91
93
|
|
|
92
94
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
package/lib/checks/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
|
|
|
19
19
|
export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
|
|
20
20
|
|
|
21
21
|
// Code quality checks
|
|
22
|
+
export * as appshellCompliance from './codeQuality/appshell-compliance.js'
|
|
22
23
|
export * as uiTheming from './codeQuality/ui-theming.js'
|
|
23
24
|
export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
|
|
24
25
|
export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
|