@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.
@@ -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
+ }
@@ -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.25",
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/",