@soulbatical/tetra-dev-toolkit 1.20.25 → 1.20.26
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-smoke.js +237 -0
- package/bin/tetra-style-audit.js +0 -0
- package/lib/checks/codeQuality/env-vars-defined.js +491 -0
- package/lib/checks/codeQuality/next-route-coverage.js +337 -0
- package/lib/checks/codeQuality/tetra-ui-ssr-safe-imports.js +335 -0
- package/lib/checks/index.js +3 -0
- package/package.json +3 -2
- package/templates/playwright-smoke.spec.ts.template +188 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: Next.js Route Coverage
|
|
3
|
+
*
|
|
4
|
+
* Detects gaps in the Next.js App Router page structure that produce silent 404s
|
|
5
|
+
* or unreachable routes — bugs that look like successful builds but serve 404
|
|
6
|
+
* responses or blank pages.
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT CATCHES:
|
|
9
|
+
*
|
|
10
|
+
* 1. Layout without index page — the "/app 404 bug":
|
|
11
|
+
* A layout.tsx exists at a route segment but there is no page.tsx at the
|
|
12
|
+
* same level AND no immediate child segment with a page.tsx.
|
|
13
|
+
* e.g. /app/layout.tsx exists, /app/page.tsx missing → visiting /app gives 404.
|
|
14
|
+
*
|
|
15
|
+
* 2. Navigation links pointing to non-existent pages:
|
|
16
|
+
* Reads appConfig.navigation from frontend/src/app/app/app-config.tsx (or
|
|
17
|
+
* app/app/app-config.tsx), extracts href values, and checks each href maps
|
|
18
|
+
* to a real page.tsx file on disk.
|
|
19
|
+
*
|
|
20
|
+
* HOW IT WORKS:
|
|
21
|
+
* 1. Finds all layout.tsx files under src/app/ or app/
|
|
22
|
+
* 2. For each layout, checks for page.tsx at the same level
|
|
23
|
+
* 3. If missing, checks immediate children for page.tsx (redirect pattern is OK)
|
|
24
|
+
* 4. Reads app-config.tsx, extracts href strings from navigation config
|
|
25
|
+
* 5. Maps each href to a filesystem path and verifies page.tsx exists
|
|
26
|
+
*
|
|
27
|
+
* Severity: high — layout-without-page causes a silent 404 that bypasses all
|
|
28
|
+
* error monitoring (Next.js returns 200 for some 404 variants)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync, existsSync } from 'fs'
|
|
32
|
+
import { join, dirname, relative, basename } from 'path'
|
|
33
|
+
import { glob } from 'glob'
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// META
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
export const meta = {
|
|
40
|
+
id: 'next-route-coverage',
|
|
41
|
+
name: 'Next.js route coverage',
|
|
42
|
+
category: 'codeQuality',
|
|
43
|
+
severity: 'high',
|
|
44
|
+
description: 'Detects layout.tsx files without a reachable page.tsx, and navigation links pointing to non-existent pages'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// CONSTANTS
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
const IGNORE_PATTERNS = [
|
|
52
|
+
'**/node_modules/**',
|
|
53
|
+
'**/.next/**',
|
|
54
|
+
'**/dist/**',
|
|
55
|
+
'**/build/**',
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// HELPERS
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
function rel(projectRoot, absPath) {
|
|
63
|
+
return relative(projectRoot, absPath)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find the Next.js app directory root — either frontend/src/app or src/app or app
|
|
68
|
+
* Returns the absolute path to the app directory, or null if not a Next.js project.
|
|
69
|
+
*/
|
|
70
|
+
function findAppDir(projectRoot) {
|
|
71
|
+
const candidates = [
|
|
72
|
+
join(projectRoot, 'frontend', 'src', 'app'),
|
|
73
|
+
join(projectRoot, 'src', 'app'),
|
|
74
|
+
join(projectRoot, 'app'),
|
|
75
|
+
]
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (existsSync(candidate)) return candidate
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Given an app directory root and a layout.tsx absolute path, return the
|
|
84
|
+
* corresponding route segment path (relative to the app root).
|
|
85
|
+
* e.g. /project/frontend/src/app/app/layout.tsx → /app
|
|
86
|
+
*/
|
|
87
|
+
function layoutToRoutePath(appDir, layoutAbsPath) {
|
|
88
|
+
const segmentDir = dirname(layoutAbsPath)
|
|
89
|
+
const rel = relative(appDir, segmentDir)
|
|
90
|
+
// Normalize to URL-like path
|
|
91
|
+
const urlPath = '/' + rel.replace(/\\/g, '/').replace(/^\.?\/?/, '')
|
|
92
|
+
return urlPath === '/.' || urlPath === '/' ? '/' : urlPath.replace(/\/$/, '')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Given a route path (e.g. "/app/dashboard"), return the expected page.tsx
|
|
97
|
+
* absolute path on disk.
|
|
98
|
+
*/
|
|
99
|
+
function routeToPagePath(appDir, routePath) {
|
|
100
|
+
const segments = routePath.replace(/^\//, '').split('/')
|
|
101
|
+
return join(appDir, ...segments, 'page.tsx')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a directory has any immediate child directories containing page.tsx
|
|
106
|
+
* (catches the redirect pattern: layout.tsx + child/page.tsx, no index page.tsx)
|
|
107
|
+
*/
|
|
108
|
+
function hasChildPage(segmentDir) {
|
|
109
|
+
const childPages = glob.sync('*/page.tsx', {
|
|
110
|
+
cwd: segmentDir,
|
|
111
|
+
absolute: false,
|
|
112
|
+
})
|
|
113
|
+
return childPages.length > 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extract href values from an app-config.tsx navigation array.
|
|
118
|
+
*
|
|
119
|
+
* Looks for patterns like:
|
|
120
|
+
* href: '/app/dashboard'
|
|
121
|
+
* href: "/app/health"
|
|
122
|
+
*
|
|
123
|
+
* Returns array of { href, line } objects.
|
|
124
|
+
*/
|
|
125
|
+
function extractNavHrefs(content) {
|
|
126
|
+
const results = []
|
|
127
|
+
const lines = content.split('\n')
|
|
128
|
+
const hrefRe = /href\s*:\s*['"]([^'"]+)['"]/
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
const m = lines[i].match(hrefRe)
|
|
132
|
+
if (m) {
|
|
133
|
+
results.push({ href: m[1], line: i + 1 })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return results
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find the app-config file (app-config.tsx or app-config.ts) anywhere under
|
|
141
|
+
* the app directory. Returns null if not found.
|
|
142
|
+
*/
|
|
143
|
+
function findAppConfig(projectRoot) {
|
|
144
|
+
const candidates = glob.sync('**/app-config.{tsx,ts}', {
|
|
145
|
+
cwd: projectRoot,
|
|
146
|
+
ignore: IGNORE_PATTERNS,
|
|
147
|
+
absolute: true,
|
|
148
|
+
})
|
|
149
|
+
// Prefer frontend/src/app/app/app-config.tsx
|
|
150
|
+
return candidates[0] ?? null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// MAIN CHECK
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
export async function run(config, projectRoot) {
|
|
158
|
+
const result = {
|
|
159
|
+
passed: true,
|
|
160
|
+
findings: [],
|
|
161
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
162
|
+
details: {
|
|
163
|
+
layoutsChecked: 0,
|
|
164
|
+
layoutsWithoutPage: 0,
|
|
165
|
+
navLinksChecked: 0,
|
|
166
|
+
navLinksBroken: 0,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Step 1: find the Next.js app directory ────────────────────────────────
|
|
171
|
+
|
|
172
|
+
const appDir = findAppDir(projectRoot)
|
|
173
|
+
if (!appDir) {
|
|
174
|
+
result.findings.push({
|
|
175
|
+
file: 'project',
|
|
176
|
+
line: 0,
|
|
177
|
+
severity: 'low',
|
|
178
|
+
message: 'next-route-coverage: no Next.js app directory found (checked src/app, frontend/src/app, app/) — check skipped',
|
|
179
|
+
})
|
|
180
|
+
result.summary.low++
|
|
181
|
+
result.summary.total++
|
|
182
|
+
return result
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Step 2: find all layout.tsx files ─────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
const layoutFiles = glob.sync('**/layout.tsx', {
|
|
188
|
+
cwd: appDir,
|
|
189
|
+
ignore: ['**/node_modules/**'],
|
|
190
|
+
absolute: true,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
result.details.layoutsChecked = layoutFiles.length
|
|
194
|
+
|
|
195
|
+
for (const layoutAbsPath of layoutFiles) {
|
|
196
|
+
const segmentDir = dirname(layoutAbsPath)
|
|
197
|
+
const routePath = layoutToRoutePath(appDir, layoutAbsPath)
|
|
198
|
+
const relLayout = rel(projectRoot, layoutAbsPath)
|
|
199
|
+
|
|
200
|
+
// Check for page.tsx at the same level as this layout
|
|
201
|
+
const pagePath = join(segmentDir, 'page.tsx')
|
|
202
|
+
const hasPage = existsSync(pagePath)
|
|
203
|
+
|
|
204
|
+
if (hasPage) continue
|
|
205
|
+
|
|
206
|
+
// No page.tsx at this level — check for immediate child page.tsx
|
|
207
|
+
// (a layout that only redirects to a child is acceptable)
|
|
208
|
+
const childPages = glob.sync('*/page.tsx', {
|
|
209
|
+
cwd: segmentDir,
|
|
210
|
+
absolute: false,
|
|
211
|
+
})
|
|
212
|
+
const hasImmediateChildPage = childPages.length > 0
|
|
213
|
+
|
|
214
|
+
if (hasImmediateChildPage) {
|
|
215
|
+
// Soft warn: layout exists, no index page but children exist
|
|
216
|
+
// This is fine only if there's an explicit redirect from this route
|
|
217
|
+
// Check if there's a redirect in the layout or if this is a known redirect pattern
|
|
218
|
+
let layoutContent = ''
|
|
219
|
+
try { layoutContent = readFileSync(layoutAbsPath, 'utf-8') } catch { /* ignore */ }
|
|
220
|
+
|
|
221
|
+
const hasRedirect = layoutContent.includes('redirect(') || layoutContent.includes('permanentRedirect(')
|
|
222
|
+
|
|
223
|
+
if (!hasRedirect) {
|
|
224
|
+
result.summary.medium++
|
|
225
|
+
result.summary.total++
|
|
226
|
+
|
|
227
|
+
result.findings.push({
|
|
228
|
+
file: relLayout,
|
|
229
|
+
line: 0,
|
|
230
|
+
severity: 'medium',
|
|
231
|
+
message: [
|
|
232
|
+
`Layout without index page — visiting "${routePath}" returns 404:`,
|
|
233
|
+
` Layout: ${relLayout}`,
|
|
234
|
+
` Route: ${routePath}`,
|
|
235
|
+
` Missing: ${rel(projectRoot, pagePath)}`,
|
|
236
|
+
``,
|
|
237
|
+
` The layout has child segments (${childPages.slice(0, 3).join(', ')}${childPages.length > 3 ? '...' : ''})`,
|
|
238
|
+
` but no page.tsx at "${routePath}" itself.`,
|
|
239
|
+
``,
|
|
240
|
+
` Fix option 1: create ${rel(projectRoot, pagePath)} with a redirect:`,
|
|
241
|
+
` import { redirect } from 'next/navigation'`,
|
|
242
|
+
` export default function Page() { redirect('${routePath}/${childPages[0].replace('/page.tsx', '')}') }`,
|
|
243
|
+
` Fix option 2: add redirect() in the layout component`,
|
|
244
|
+
].join('\n'),
|
|
245
|
+
fix: `Create page.tsx at "${routePath}" with redirect to first child, or add redirect() to layout`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// No page.tsx AND no child pages — this route is completely unreachable
|
|
252
|
+
result.details.layoutsWithoutPage++
|
|
253
|
+
result.passed = false
|
|
254
|
+
result.summary.high++
|
|
255
|
+
result.summary.total++
|
|
256
|
+
|
|
257
|
+
result.findings.push({
|
|
258
|
+
file: relLayout,
|
|
259
|
+
line: 0,
|
|
260
|
+
severity: 'high',
|
|
261
|
+
message: [
|
|
262
|
+
`Layout without any reachable page — this route is a dead end:`,
|
|
263
|
+
` Layout: ${relLayout}`,
|
|
264
|
+
` Route: ${routePath}`,
|
|
265
|
+
` Missing: ${rel(projectRoot, pagePath)}`,
|
|
266
|
+
``,
|
|
267
|
+
` Visiting "${routePath}" will return 404 with no error in logs.`,
|
|
268
|
+
` Next.js renders the layout but has no page component to render.`,
|
|
269
|
+
``,
|
|
270
|
+
` Fix: create ${rel(projectRoot, pagePath)}`,
|
|
271
|
+
` export default function Page() {`,
|
|
272
|
+
` return <div>Welcome to ${routePath}</div>`,
|
|
273
|
+
` }`,
|
|
274
|
+
].join('\n'),
|
|
275
|
+
fix: `Create page.tsx at ${rel(projectRoot, pagePath)}`,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Step 3: check navigation links ────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
const appConfigPath = findAppConfig(projectRoot)
|
|
282
|
+
if (appConfigPath) {
|
|
283
|
+
let content
|
|
284
|
+
try { content = readFileSync(appConfigPath, 'utf-8') } catch { content = null }
|
|
285
|
+
|
|
286
|
+
if (content) {
|
|
287
|
+
const hrefs = extractNavHrefs(content)
|
|
288
|
+
result.details.navLinksChecked = hrefs.length
|
|
289
|
+
|
|
290
|
+
for (const { href, line } of hrefs) {
|
|
291
|
+
// Skip external URLs and anchors
|
|
292
|
+
if (!href.startsWith('/') || href.includes('://')) continue
|
|
293
|
+
|
|
294
|
+
// Remove query strings and hash fragments for path checking
|
|
295
|
+
const cleanHref = href.split('?')[0].split('#')[0]
|
|
296
|
+
|
|
297
|
+
const expectedPage = routeToPagePath(appDir, cleanHref)
|
|
298
|
+
if (!existsSync(expectedPage)) {
|
|
299
|
+
// Also check for dynamic route variants: [id]/page.tsx, [slug]/page.tsx
|
|
300
|
+
const segments = cleanHref.replace(/^\//, '').split('/')
|
|
301
|
+
const parentDir = join(appDir, ...segments.slice(0, -1))
|
|
302
|
+
const dynamicCandidates = glob.sync('[*]/page.tsx', {
|
|
303
|
+
cwd: parentDir,
|
|
304
|
+
absolute: false,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// If dynamic route exists, this is fine
|
|
308
|
+
if (dynamicCandidates.length > 0) continue
|
|
309
|
+
|
|
310
|
+
result.details.navLinksBroken++
|
|
311
|
+
result.passed = false
|
|
312
|
+
result.summary.high++
|
|
313
|
+
result.summary.total++
|
|
314
|
+
|
|
315
|
+
result.findings.push({
|
|
316
|
+
file: rel(projectRoot, appConfigPath),
|
|
317
|
+
line,
|
|
318
|
+
severity: 'high',
|
|
319
|
+
message: [
|
|
320
|
+
`Navigation link points to non-existent page:`,
|
|
321
|
+
` Config: ${rel(projectRoot, appConfigPath)}:${line}`,
|
|
322
|
+
` href: ${href}`,
|
|
323
|
+
` Expected: ${rel(projectRoot, expectedPage)}`,
|
|
324
|
+
``,
|
|
325
|
+
` This nav item will lead to a 404. Either create the page or remove the nav link.`,
|
|
326
|
+
``,
|
|
327
|
+
` Fix: create ${rel(projectRoot, expectedPage)}`,
|
|
328
|
+
].join('\n'),
|
|
329
|
+
fix: `Create ${rel(projectRoot, expectedPage)} or remove href "${href}" from navigation config`,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: Tetra-UI SSR-Safe Imports
|
|
3
|
+
*
|
|
4
|
+
* Next.js server-side rendering (SSR) fails silently when browser-only code
|
|
5
|
+
* is imported in a server context: "window is not defined" at runtime, or
|
|
6
|
+
* hydration mismatches that log warnings but do not crash immediately.
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT CATCHES:
|
|
9
|
+
*
|
|
10
|
+
* 1. FAIL: SSR-unsafe barrel import
|
|
11
|
+
* File imports WeekCalendar, TeamPlanner, AvailabilityEditor,
|
|
12
|
+
* BookingWidget, or usePlannerApi from '@soulbatical/tetra-ui' barrel,
|
|
13
|
+
* AND the file is reachable from server rendering (root layout, providers
|
|
14
|
+
* file, anything not wrapped in dynamic(() => ..., { ssr: false })).
|
|
15
|
+
*
|
|
16
|
+
* 2. WARN: DayPilot webpack alias missing
|
|
17
|
+
* A Next.js project imports from the tetra-ui barrel in a 'use client'
|
|
18
|
+
* providers file, but next.config.ts does NOT contain the DayPilot
|
|
19
|
+
* webpack alias workaround.
|
|
20
|
+
*
|
|
21
|
+
* SSR-unsafe exports (anything that pulls in DayPilot):
|
|
22
|
+
* WeekCalendar, TeamPlanner, AvailabilityEditor, BookingWidget,
|
|
23
|
+
* usePlannerApi
|
|
24
|
+
*
|
|
25
|
+
* HOW IT WORKS:
|
|
26
|
+
* 1. Find all .tsx/.ts files in the project (excluding node_modules).
|
|
27
|
+
* 2. For each file that imports from '@soulbatical/tetra-ui':
|
|
28
|
+
* a. Parse the imported symbol names.
|
|
29
|
+
* b. Check if any are SSR-unsafe.
|
|
30
|
+
* c. Determine if the file is "server-reachable":
|
|
31
|
+
* - Any layout.tsx or page.tsx without 'use client' at top
|
|
32
|
+
* - Any file named providers.tsx / providers.ts
|
|
33
|
+
* - Any file that does NOT have 'use client' as first non-blank line
|
|
34
|
+
* d. Exclude: files containing dynamic(..., { ssr: false })
|
|
35
|
+
* 3. Check next.config.ts for the DayPilot alias.
|
|
36
|
+
*
|
|
37
|
+
* Severity: high — SSR failures can break entire page renders or cause
|
|
38
|
+
* hydration errors that are hard to trace.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFileSync, existsSync } from 'fs'
|
|
42
|
+
import { join, relative } from 'path'
|
|
43
|
+
import { glob } from 'glob'
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// META
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
export const meta = {
|
|
50
|
+
id: 'tetra-ui-ssr-safe-imports',
|
|
51
|
+
name: 'Tetra-UI SSR-safe imports',
|
|
52
|
+
category: 'codeQuality',
|
|
53
|
+
severity: 'high',
|
|
54
|
+
description: 'Detects SSR-unsafe tetra-ui imports (DayPilot planner components) reachable from server-rendered contexts'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// CONSTANTS
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
const IGNORE_PATTERNS = [
|
|
62
|
+
'**/node_modules/**',
|
|
63
|
+
'**/.next/**',
|
|
64
|
+
'**/dist/**',
|
|
65
|
+
'**/build/**',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Exports from the @soulbatical/tetra-ui barrel that pull in DayPilot and are
|
|
70
|
+
* therefore NOT safe to import in server-rendered contexts.
|
|
71
|
+
*/
|
|
72
|
+
const SSR_UNSAFE_EXPORTS = new Set([
|
|
73
|
+
'WeekCalendar',
|
|
74
|
+
'TeamPlanner',
|
|
75
|
+
'AvailabilityEditor',
|
|
76
|
+
'BookingWidget',
|
|
77
|
+
'usePlannerApi',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// HELPERS
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
function relPath(projectRoot, absPath) {
|
|
85
|
+
return relative(projectRoot, absPath)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if a file starts with 'use client' (Next.js client boundary marker).
|
|
90
|
+
* Ignores leading blank lines and block comments.
|
|
91
|
+
*/
|
|
92
|
+
function hasUseClientDirective(content) {
|
|
93
|
+
for (const line of content.split('\n')) {
|
|
94
|
+
const t = line.trim()
|
|
95
|
+
if (t === '') continue
|
|
96
|
+
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) continue
|
|
97
|
+
return t === "'use client'" || t === '"use client"'
|
|
98
|
+
}
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a file contains dynamic(() => ..., { ssr: false }).
|
|
104
|
+
* Files with this pattern are correctly guarded for SSR.
|
|
105
|
+
*/
|
|
106
|
+
function hasDynamicSsrFalse(content) {
|
|
107
|
+
return /dynamic\s*\(/.test(content) && /ssr\s*:\s*false/.test(content)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse named imports from @soulbatical/tetra-ui barrel.
|
|
112
|
+
*
|
|
113
|
+
* Handles:
|
|
114
|
+
* import { WeekCalendar, TeamPlanner } from '@soulbatical/tetra-ui'
|
|
115
|
+
* import { WeekCalendar as WC } from '@soulbatical/tetra-ui'
|
|
116
|
+
* import type { ... } from '...' — type-only, safe, skipped
|
|
117
|
+
*/
|
|
118
|
+
function parseTetraUiBarrelImports(content) {
|
|
119
|
+
const results = []
|
|
120
|
+
|
|
121
|
+
// Match: import [type] { ... } from '@soulbatical/tetra-ui'
|
|
122
|
+
// NOT matching subpaths like '@soulbatical/tetra-ui/planner'
|
|
123
|
+
const importRe = /import\s+(type\s+)?(\{[^}]+\})\s+from\s+['"]@soulbatical\/tetra-ui['"]/g
|
|
124
|
+
let match
|
|
125
|
+
|
|
126
|
+
while ((match = importRe.exec(content)) !== null) {
|
|
127
|
+
const isTypeImport = Boolean(match[1])
|
|
128
|
+
if (isTypeImport) continue
|
|
129
|
+
|
|
130
|
+
const importBlock = match[2]
|
|
131
|
+
const lineNumber = content.slice(0, match.index).split('\n').length
|
|
132
|
+
|
|
133
|
+
// Extract symbol names, stripping "as Alias" and "type" modifiers
|
|
134
|
+
const symbolRe = /(?:type\s+)?([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?/g
|
|
135
|
+
let sym
|
|
136
|
+
const symbols = []
|
|
137
|
+
while ((sym = symbolRe.exec(importBlock)) !== null) {
|
|
138
|
+
const name = sym[1]
|
|
139
|
+
if (name !== 'type') symbols.push(name)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
results.push({ symbols, line: lineNumber, raw: match[0] })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return results
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Determine if a file is server-reachable in Next.js App Router.
|
|
150
|
+
*
|
|
151
|
+
* Server-reachable = Next.js may try to render or import this file on the server.
|
|
152
|
+
*
|
|
153
|
+
* Rules:
|
|
154
|
+
* - layout.tsx: always server-rendered (unless it has 'use client' — unusual)
|
|
155
|
+
* - page.tsx: server-rendered unless it has 'use client'
|
|
156
|
+
* - providers.tsx: commonly used to wrap the app; flagged regardless of directive
|
|
157
|
+
* because consumers often import it from server components
|
|
158
|
+
* - Any other file without 'use client'
|
|
159
|
+
*/
|
|
160
|
+
function isServerReachable(filename, content) {
|
|
161
|
+
const name = filename.toLowerCase()
|
|
162
|
+
|
|
163
|
+
if (name === 'layout.tsx' || name === 'layout.ts') return true
|
|
164
|
+
if (name === 'providers.tsx' || name === 'providers.ts') return true
|
|
165
|
+
|
|
166
|
+
if (name === 'page.tsx' || name === 'page.ts') {
|
|
167
|
+
return !hasUseClientDirective(content)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return !hasUseClientDirective(content)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find the Next.js config file.
|
|
175
|
+
*/
|
|
176
|
+
function findNextConfig(projectRoot) {
|
|
177
|
+
const candidates = [
|
|
178
|
+
join(projectRoot, 'frontend', 'next.config.ts'),
|
|
179
|
+
join(projectRoot, 'frontend', 'next.config.js'),
|
|
180
|
+
join(projectRoot, 'frontend', 'next.config.mjs'),
|
|
181
|
+
join(projectRoot, 'next.config.ts'),
|
|
182
|
+
join(projectRoot, 'next.config.js'),
|
|
183
|
+
join(projectRoot, 'next.config.mjs'),
|
|
184
|
+
]
|
|
185
|
+
return candidates.find(existsSync) ?? null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// MAIN CHECK
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
export async function run(config, projectRoot) {
|
|
193
|
+
const result = {
|
|
194
|
+
passed: true,
|
|
195
|
+
findings: [],
|
|
196
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
197
|
+
details: {
|
|
198
|
+
filesScanned: 0,
|
|
199
|
+
barrelImportsFound: 0,
|
|
200
|
+
ssrUnsafeImports: 0,
|
|
201
|
+
daypilotAliasChecked: false,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Step 1: scan all TS/TSX source files ──────────────────────────────────
|
|
206
|
+
|
|
207
|
+
const sourceFiles = glob.sync('**/*.{ts,tsx}', {
|
|
208
|
+
cwd: projectRoot,
|
|
209
|
+
ignore: IGNORE_PATTERNS,
|
|
210
|
+
absolute: true,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
result.details.filesScanned = sourceFiles.length
|
|
214
|
+
|
|
215
|
+
// Track providers files that import from the barrel (for DayPilot alias check)
|
|
216
|
+
const providersFilesWithBarrelImport = []
|
|
217
|
+
|
|
218
|
+
for (const absPath of sourceFiles) {
|
|
219
|
+
let content
|
|
220
|
+
try { content = readFileSync(absPath, 'utf-8') } catch { continue }
|
|
221
|
+
|
|
222
|
+
const rel = relPath(projectRoot, absPath)
|
|
223
|
+
const filename = absPath.split('/').pop() ?? ''
|
|
224
|
+
|
|
225
|
+
// Quick check: does this file import from the tetra-ui barrel at all?
|
|
226
|
+
const hasBarrelImport = (
|
|
227
|
+
content.includes("'@soulbatical/tetra-ui'") ||
|
|
228
|
+
content.includes('"@soulbatical/tetra-ui"')
|
|
229
|
+
)
|
|
230
|
+
if (!hasBarrelImport) continue
|
|
231
|
+
|
|
232
|
+
const imports = parseTetraUiBarrelImports(content)
|
|
233
|
+
if (imports.length === 0) continue
|
|
234
|
+
|
|
235
|
+
result.details.barrelImportsFound++
|
|
236
|
+
|
|
237
|
+
// Track providers files with 'use client' for DayPilot alias check
|
|
238
|
+
const name = filename.toLowerCase()
|
|
239
|
+
if ((name === 'providers.tsx' || name === 'providers.ts') && hasUseClientDirective(content)) {
|
|
240
|
+
providersFilesWithBarrelImport.push({ absPath, rel })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check each import for SSR-unsafe symbols
|
|
244
|
+
for (const importEntry of imports) {
|
|
245
|
+
const { symbols, line, raw } = importEntry
|
|
246
|
+
const unsafeSymbols = symbols.filter(s => SSR_UNSAFE_EXPORTS.has(s))
|
|
247
|
+
if (unsafeSymbols.length === 0) continue
|
|
248
|
+
|
|
249
|
+
// File must be server-reachable for this to be a problem
|
|
250
|
+
if (!isServerReachable(filename, content)) continue
|
|
251
|
+
|
|
252
|
+
// Correctly guarded with dynamic + ssr:false
|
|
253
|
+
if (hasDynamicSsrFalse(content)) continue
|
|
254
|
+
|
|
255
|
+
result.details.ssrUnsafeImports++
|
|
256
|
+
result.passed = false
|
|
257
|
+
result.summary.high++
|
|
258
|
+
result.summary.total++
|
|
259
|
+
|
|
260
|
+
result.findings.push({
|
|
261
|
+
file: rel,
|
|
262
|
+
line,
|
|
263
|
+
severity: 'high',
|
|
264
|
+
message: [
|
|
265
|
+
`SSR-unsafe tetra-ui import in server-reachable file:`,
|
|
266
|
+
` File: ${rel}:${line}`,
|
|
267
|
+
` Import: ${raw.trim()}`,
|
|
268
|
+
` Unsafe: ${unsafeSymbols.join(', ')} (pull in DayPilot — browser-only)`,
|
|
269
|
+
``,
|
|
270
|
+
` These components use browser globals and will fail during SSR with`,
|
|
271
|
+
` "window is not defined" or cause hydration mismatches.`,
|
|
272
|
+
``,
|
|
273
|
+
` Fix option 1: import from the planner subpath and wrap with dynamic:`,
|
|
274
|
+
` const ${unsafeSymbols[0]} = dynamic(`,
|
|
275
|
+
` () => import('@soulbatical/tetra-ui/planner').then(m => ({ default: m.${unsafeSymbols[0]} })),`,
|
|
276
|
+
` { ssr: false }`,
|
|
277
|
+
` )`,
|
|
278
|
+
``,
|
|
279
|
+
` Fix option 2: add 'use client' at the top of ${rel}`,
|
|
280
|
+
` (only valid if this file is never imported from a server component)`,
|
|
281
|
+
].join('\n'),
|
|
282
|
+
fix: `Import ${unsafeSymbols.join(', ')} from '@soulbatical/tetra-ui/planner' and wrap with dynamic(..., { ssr: false })`,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Step 2: check DayPilot webpack alias ──────────────────────────────────
|
|
288
|
+
|
|
289
|
+
if (providersFilesWithBarrelImport.length > 0) {
|
|
290
|
+
result.details.daypilotAliasChecked = true
|
|
291
|
+
const nextConfigPath = findNextConfig(projectRoot)
|
|
292
|
+
|
|
293
|
+
if (nextConfigPath) {
|
|
294
|
+
let configContent
|
|
295
|
+
try { configContent = readFileSync(nextConfigPath, 'utf-8') } catch { configContent = '' }
|
|
296
|
+
|
|
297
|
+
const hasDayPilotAlias = configContent.includes('@daypilot') || configContent.includes('daypilot')
|
|
298
|
+
|
|
299
|
+
if (!hasDayPilotAlias) {
|
|
300
|
+
result.summary.medium++
|
|
301
|
+
result.summary.total++
|
|
302
|
+
|
|
303
|
+
const relConfig = relPath(projectRoot, nextConfigPath)
|
|
304
|
+
const providersList = providersFilesWithBarrelImport.map(f => f.rel).join(', ')
|
|
305
|
+
|
|
306
|
+
result.findings.push({
|
|
307
|
+
file: relConfig,
|
|
308
|
+
line: 0,
|
|
309
|
+
severity: 'medium',
|
|
310
|
+
message: [
|
|
311
|
+
`DayPilot webpack alias missing from next.config:`,
|
|
312
|
+
` Config: ${relConfig}`,
|
|
313
|
+
` Providers: ${providersList}`,
|
|
314
|
+
``,
|
|
315
|
+
` These files import from the @soulbatical/tetra-ui barrel, which re-exports`,
|
|
316
|
+
` DayPilot planner components. Without a webpack alias, Next.js may try to`,
|
|
317
|
+
` resolve DayPilot in a server context and fail with "window is not defined".`,
|
|
318
|
+
``,
|
|
319
|
+
` Fix: add this webpack config to ${relConfig}:`,
|
|
320
|
+
` webpack(config) {`,
|
|
321
|
+
` config.resolve.alias = {`,
|
|
322
|
+
` ...config.resolve.alias,`,
|
|
323
|
+
` '@daypilot/daypilot-lite-react': false,`,
|
|
324
|
+
` }`,
|
|
325
|
+
` return config`,
|
|
326
|
+
` }`,
|
|
327
|
+
].join('\n'),
|
|
328
|
+
fix: `Add '@daypilot/daypilot-lite-react': false to webpack alias in ${relConfig}`,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return result
|
|
335
|
+
}
|
package/lib/checks/index.js
CHANGED
|
@@ -25,6 +25,9 @@ export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
|
|
|
25
25
|
export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
|
|
26
26
|
export * as mcpToolDocs from './codeQuality/mcp-tool-docs.js'
|
|
27
27
|
export * as tailwindSourcePaths from './codeQuality/tailwind-source-paths.js'
|
|
28
|
+
export * as envVarsDefined from './codeQuality/env-vars-defined.js'
|
|
29
|
+
export * as nextRouteCoverage from './codeQuality/next-route-coverage.js'
|
|
30
|
+
export * as tetraUiSsrSafeImports from './codeQuality/tetra-ui-ssr-safe-imports.js'
|
|
28
31
|
|
|
29
32
|
// Health checks (project ecosystem)
|
|
30
33
|
export * as health from './health/index.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.26",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"tetra-check-pages": "./bin/tetra-check-pages.js",
|
|
45
45
|
"tetra-check-views": "./bin/tetra-check-views.js",
|
|
46
46
|
"tetra-doctor": "./bin/tetra-doctor.js",
|
|
47
|
-
"tetra-style-audit": "./bin/tetra-style-audit.js"
|
|
47
|
+
"tetra-style-audit": "./bin/tetra-style-audit.js",
|
|
48
|
+
"tetra-init-smoke": "./bin/tetra-init-smoke.js"
|
|
48
49
|
},
|
|
49
50
|
"files": [
|
|
50
51
|
"bin/",
|