@soulbatical/tetra-dev-toolkit 1.20.24 → 1.20.25
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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: Tailwind @source Path Validation
|
|
3
|
+
*
|
|
4
|
+
* Tailwind v4 uses @source directives to tell it which files to scan for
|
|
5
|
+
* utility classes. Invalid @source paths are silently ignored — Tailwind
|
|
6
|
+
* emits no error and just skips them. This means a single wrong `..` can
|
|
7
|
+
* cause every class from a package to vanish at build time.
|
|
8
|
+
*
|
|
9
|
+
* WHAT IT CATCHES:
|
|
10
|
+
* - FAIL: @source path does not exist on disk (wrong number of `..`, typo, etc.)
|
|
11
|
+
* - FAIL: @source points to a node_modules package that is not in package.json
|
|
12
|
+
* - WARN: CSS file imports tetra-ui tokens but has no @source for tetra-ui dist
|
|
13
|
+
* (Tailwind will not pick up tetra-ui utility classes)
|
|
14
|
+
* - WARN: @source path exists on disk but the package is absent from package.json
|
|
15
|
+
* (orphaned path — will break after a clean install)
|
|
16
|
+
*
|
|
17
|
+
* HOW IT WORKS:
|
|
18
|
+
* 1. Scans project for CSS files containing `@import 'tailwindcss'` (v4 entry files).
|
|
19
|
+
* 2. For each file, extracts all @source directives (single-line, any quote style).
|
|
20
|
+
* 3. Resolves each path relative to the CSS file's own directory.
|
|
21
|
+
* 4. For glob paths (e.g. `../src/**\/*.{ts,tsx}`), strips the glob to find the
|
|
22
|
+
* base directory and checks that base exists.
|
|
23
|
+
* 5. For node_modules paths, also cross-checks package.json dependencies.
|
|
24
|
+
*
|
|
25
|
+
* Severity: high — broken @source paths cause silent layout/theming failures
|
|
26
|
+
* that are extremely hard to diagnose.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, existsSync } from 'fs'
|
|
30
|
+
import { join, dirname, resolve, normalize } from 'path'
|
|
31
|
+
import { glob } from 'glob'
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// META
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
export const meta = {
|
|
38
|
+
id: 'tailwind-source-paths',
|
|
39
|
+
name: 'Tailwind @source path validation',
|
|
40
|
+
category: 'codeQuality',
|
|
41
|
+
severity: 'high',
|
|
42
|
+
description: 'Validates that all @source directives in Tailwind v4 CSS entry files point to paths that exist on disk'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// HELPERS
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
const IGNORE_PATTERNS = [
|
|
50
|
+
'**/node_modules/**',
|
|
51
|
+
'**/.next/**',
|
|
52
|
+
'**/dist/**',
|
|
53
|
+
'**/build/**',
|
|
54
|
+
'**/.netlify/**',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read package.json from projectRoot and return a Set of all declared
|
|
59
|
+
* dependency names (dependencies + devDependencies + peerDependencies).
|
|
60
|
+
*/
|
|
61
|
+
function loadDeclaredPackages(projectRoot) {
|
|
62
|
+
const pkgPath = join(projectRoot, 'package.json')
|
|
63
|
+
if (!existsSync(pkgPath)) return new Set()
|
|
64
|
+
try {
|
|
65
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
66
|
+
return new Set([
|
|
67
|
+
...Object.keys(pkg.dependencies || {}),
|
|
68
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
69
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
70
|
+
])
|
|
71
|
+
} catch {
|
|
72
|
+
return new Set()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Also check workspace-level package.json (monorepos where the lockfile lives
|
|
78
|
+
* at the root but the frontend package.json is in a sub-directory).
|
|
79
|
+
*/
|
|
80
|
+
function loadAllDeclaredPackages(projectRoot, cssFilePath) {
|
|
81
|
+
const declared = loadDeclaredPackages(projectRoot)
|
|
82
|
+
|
|
83
|
+
// Walk up from the CSS file's directory to projectRoot, collecting all
|
|
84
|
+
// package.json files we encounter (stops at projectRoot).
|
|
85
|
+
let dir = dirname(cssFilePath)
|
|
86
|
+
while (dir.startsWith(projectRoot) && dir !== projectRoot) {
|
|
87
|
+
const pkgPath = join(dir, 'package.json')
|
|
88
|
+
if (existsSync(pkgPath)) {
|
|
89
|
+
try {
|
|
90
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
91
|
+
for (const dep of Object.keys(pkg.dependencies || {})) declared.add(dep)
|
|
92
|
+
for (const dep of Object.keys(pkg.devDependencies || {})) declared.add(dep)
|
|
93
|
+
for (const dep of Object.keys(pkg.peerDependencies || {})) declared.add(dep)
|
|
94
|
+
} catch { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
dir = dirname(dir)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return declared
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Given a raw @source path (may contain glob patterns), return the base
|
|
104
|
+
* directory to test for existence. We strip everything from the first glob
|
|
105
|
+
* metacharacter onwards.
|
|
106
|
+
*
|
|
107
|
+
* Examples:
|
|
108
|
+
* "../../node_modules/@soulbatical/tetra-ui/dist" → same (no glob)
|
|
109
|
+
* "../src/**\/*.{ts,tsx}" → "../src"
|
|
110
|
+
* "./components/**" → "./components"
|
|
111
|
+
*/
|
|
112
|
+
function getBaseDir(rawPath) {
|
|
113
|
+
// Find first glob metacharacter: * ? { } [ ]
|
|
114
|
+
const globIndex = rawPath.search(/[*?{}\[\]]/)
|
|
115
|
+
if (globIndex === -1) return rawPath
|
|
116
|
+
|
|
117
|
+
// Strip back to the last path separator before the glob
|
|
118
|
+
const beforeGlob = rawPath.slice(0, globIndex)
|
|
119
|
+
const lastSep = Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
|
|
120
|
+
return lastSep === -1 ? '.' : beforeGlob.slice(0, lastSep) || '.'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Given a resolved absolute path, extract a package name if it sits inside
|
|
125
|
+
* a node_modules directory. Handles scoped packages (@scope/name).
|
|
126
|
+
*
|
|
127
|
+
* Returns null when the path is not inside node_modules.
|
|
128
|
+
*/
|
|
129
|
+
function extractPackageName(resolvedPath) {
|
|
130
|
+
const nm = '/node_modules/'
|
|
131
|
+
const idx = resolvedPath.lastIndexOf(nm)
|
|
132
|
+
if (idx === -1) return null
|
|
133
|
+
|
|
134
|
+
const afterNm = resolvedPath.slice(idx + nm.length)
|
|
135
|
+
const parts = afterNm.split('/')
|
|
136
|
+
|
|
137
|
+
if (parts[0].startsWith('@') && parts.length >= 2) {
|
|
138
|
+
return `${parts[0]}/${parts[1]}`
|
|
139
|
+
}
|
|
140
|
+
return parts[0] || null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse all @source directives from CSS content.
|
|
145
|
+
* Returns array of { path, line } objects.
|
|
146
|
+
*
|
|
147
|
+
* Matches:
|
|
148
|
+
* @source "some/path";
|
|
149
|
+
* @source 'some/path';
|
|
150
|
+
* @source "../../../node_modules/@scope/pkg/dist";
|
|
151
|
+
*/
|
|
152
|
+
function parseSourceDirectives(content) {
|
|
153
|
+
const results = []
|
|
154
|
+
const lines = content.split('\n')
|
|
155
|
+
const re = /^\s*@source\s+["']([^"']+)["']\s*;?\s*$/
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < lines.length; i++) {
|
|
158
|
+
const m = lines[i].match(re)
|
|
159
|
+
if (m) {
|
|
160
|
+
results.push({ path: m[1], line: i + 1 })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return results
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check whether a CSS file imports tetra-ui tokens/dark-mode (indicating
|
|
169
|
+
* tetra-ui is in use) but has no @source pointing at tetra-ui's dist.
|
|
170
|
+
*/
|
|
171
|
+
function importsTetraUi(content) {
|
|
172
|
+
return (
|
|
173
|
+
content.includes('@soulbatical/tetra-ui/styles/tokens.css') ||
|
|
174
|
+
content.includes('@soulbatical/tetra-ui/styles/dark-mode.css') ||
|
|
175
|
+
content.includes('@soulbatical/tetra-ui')
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hasTetraUiSource(sources) {
|
|
180
|
+
return sources.some(s => s.path.includes('@soulbatical/tetra-ui'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Produce a human-readable relative path for display (relative to projectRoot).
|
|
185
|
+
*/
|
|
186
|
+
function rel(projectRoot, absPath) {
|
|
187
|
+
return absPath.startsWith(projectRoot)
|
|
188
|
+
? absPath.slice(projectRoot.length).replace(/^\//, '')
|
|
189
|
+
: absPath
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ============================================================================
|
|
193
|
+
// MAIN CHECK
|
|
194
|
+
// ============================================================================
|
|
195
|
+
|
|
196
|
+
export async function run(config, projectRoot) {
|
|
197
|
+
const result = {
|
|
198
|
+
passed: true,
|
|
199
|
+
findings: [],
|
|
200
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
201
|
+
details: {
|
|
202
|
+
cssFilesScanned: 0,
|
|
203
|
+
sourceDirectivesChecked: 0,
|
|
204
|
+
missingPaths: 0,
|
|
205
|
+
orphanedPackages: 0,
|
|
206
|
+
missingTetraUiSource: 0,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Locate all CSS files in the project (excluding node_modules and build dirs)
|
|
211
|
+
const cssFiles = glob.sync('**/*.css', {
|
|
212
|
+
cwd: projectRoot,
|
|
213
|
+
ignore: IGNORE_PATTERNS,
|
|
214
|
+
absolute: true,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Filter down to Tailwind v4 entry files (contain @import "tailwindcss")
|
|
218
|
+
const tailwindEntryFiles = []
|
|
219
|
+
for (const absPath of cssFiles) {
|
|
220
|
+
let content
|
|
221
|
+
try {
|
|
222
|
+
content = readFileSync(absPath, 'utf-8')
|
|
223
|
+
} catch {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (/@import\s+['"]tailwindcss['"]/.test(content)) {
|
|
227
|
+
tailwindEntryFiles.push({ absPath, content })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (tailwindEntryFiles.length === 0) {
|
|
232
|
+
// No Tailwind v4 entry files found — check is not applicable
|
|
233
|
+
result.findings.push({
|
|
234
|
+
file: 'project',
|
|
235
|
+
line: 0,
|
|
236
|
+
severity: 'low',
|
|
237
|
+
message: 'No Tailwind v4 CSS entry file found (no file with @import "tailwindcss") — @source check skipped'
|
|
238
|
+
})
|
|
239
|
+
result.summary.low++
|
|
240
|
+
result.summary.total++
|
|
241
|
+
return result
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
result.details.cssFilesScanned = tailwindEntryFiles.length
|
|
245
|
+
|
|
246
|
+
for (const { absPath, content } of tailwindEntryFiles) {
|
|
247
|
+
const cssDir = dirname(absPath)
|
|
248
|
+
const relCssPath = rel(projectRoot, absPath)
|
|
249
|
+
const declaredPackages = loadAllDeclaredPackages(projectRoot, absPath)
|
|
250
|
+
|
|
251
|
+
const sources = parseSourceDirectives(content)
|
|
252
|
+
result.details.sourceDirectivesChecked += sources.length
|
|
253
|
+
|
|
254
|
+
// ── Check 1: each @source path must exist on disk ──────────────────────
|
|
255
|
+
|
|
256
|
+
for (const { path: sourcePath, line } of sources) {
|
|
257
|
+
const baseDir = getBaseDir(sourcePath)
|
|
258
|
+
const resolvedBase = resolve(cssDir, baseDir)
|
|
259
|
+
const normalizedBase = normalize(resolvedBase)
|
|
260
|
+
const exists = existsSync(normalizedBase)
|
|
261
|
+
|
|
262
|
+
const pkgName = extractPackageName(normalizedBase)
|
|
263
|
+
const isNodeModulesPath = pkgName !== null
|
|
264
|
+
|
|
265
|
+
if (!exists) {
|
|
266
|
+
result.passed = false
|
|
267
|
+
result.details.missingPaths++
|
|
268
|
+
result.summary.high++
|
|
269
|
+
result.summary.total++
|
|
270
|
+
|
|
271
|
+
// Build a "likely fix" hint for node_modules paths with too many `..`
|
|
272
|
+
let likelyFix = ''
|
|
273
|
+
if (isNodeModulesPath) {
|
|
274
|
+
// Try removing one `..` at a time and find the shortest working path
|
|
275
|
+
const parts = sourcePath.split('/')
|
|
276
|
+
for (let skip = 1; skip < parts.length; skip++) {
|
|
277
|
+
if (parts[skip - 1] !== '..') break
|
|
278
|
+
const candidate = parts.slice(skip).join('/')
|
|
279
|
+
const candidateResolved = resolve(cssDir, candidate)
|
|
280
|
+
if (existsSync(candidateResolved)) {
|
|
281
|
+
likelyFix = `\n Likely fix: change to "${candidate}" (one fewer ".." — count directory levels from the CSS file)`
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!likelyFix) {
|
|
286
|
+
likelyFix = `\n Likely fix: count how many directories separate the CSS file from node_modules and adjust the "../" prefix accordingly`
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
result.findings.push({
|
|
291
|
+
file: relCssPath,
|
|
292
|
+
line,
|
|
293
|
+
severity: 'high',
|
|
294
|
+
message: [
|
|
295
|
+
`Tailwind @source path does not exist:`,
|
|
296
|
+
` CSS file: ${relCssPath}:${line}`,
|
|
297
|
+
` @source: ${sourcePath}`,
|
|
298
|
+
` Resolved to: ${normalizedBase} ← MISSING`,
|
|
299
|
+
``,
|
|
300
|
+
` Tailwind v4 silently ignores invalid @source paths.`,
|
|
301
|
+
` If this path targets a UI package, its utility classes will NOT be`,
|
|
302
|
+
` picked up — causing layout, theming, and component breakage.`,
|
|
303
|
+
likelyFix,
|
|
304
|
+
].filter(l => l !== undefined).join('\n'),
|
|
305
|
+
fix: likelyFix.trim() || 'Correct the relative path so it resolves to the actual directory.',
|
|
306
|
+
})
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Path exists — now check package.json registration for node_modules paths
|
|
311
|
+
if (isNodeModulesPath) {
|
|
312
|
+
if (!declaredPackages.has(pkgName)) {
|
|
313
|
+
result.details.orphanedPackages++
|
|
314
|
+
result.summary.medium++
|
|
315
|
+
result.summary.total++
|
|
316
|
+
|
|
317
|
+
result.findings.push({
|
|
318
|
+
file: relCssPath,
|
|
319
|
+
line,
|
|
320
|
+
severity: 'medium',
|
|
321
|
+
message: [
|
|
322
|
+
`Tailwind @source references an undeclared package:`,
|
|
323
|
+
` CSS file: ${relCssPath}:${line}`,
|
|
324
|
+
` @source: ${sourcePath}`,
|
|
325
|
+
` Package: ${pkgName}`,
|
|
326
|
+
` Status: present on disk but NOT in any package.json (dependencies/devDependencies)`,
|
|
327
|
+
``,
|
|
328
|
+
` This path will break after a clean install. Add "${pkgName}" to package.json`,
|
|
329
|
+
` or remove the @source directive if it is no longer needed.`,
|
|
330
|
+
].join('\n'),
|
|
331
|
+
fix: `Add "${pkgName}" to package.json dependencies or devDependencies.`,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Check 2: tetra-ui import without matching @source ──────────────────
|
|
338
|
+
|
|
339
|
+
if (importsTetraUi(content) && !hasTetraUiSource(sources)) {
|
|
340
|
+
result.details.missingTetraUiSource++
|
|
341
|
+
result.summary.medium++
|
|
342
|
+
result.summary.total++
|
|
343
|
+
|
|
344
|
+
result.findings.push({
|
|
345
|
+
file: relCssPath,
|
|
346
|
+
line: 0,
|
|
347
|
+
severity: 'medium',
|
|
348
|
+
message: [
|
|
349
|
+
`CSS file imports @soulbatical/tetra-ui styles but has no @source for tetra-ui dist:`,
|
|
350
|
+
` CSS file: ${relCssPath}`,
|
|
351
|
+
``,
|
|
352
|
+
` Without a @source directive, Tailwind v4 will not scan tetra-ui's built`,
|
|
353
|
+
` CSS/JS for utility classes. AppShell layout, theming tokens, and all`,
|
|
354
|
+
` tetra-ui components will render without their Tailwind classes.`,
|
|
355
|
+
``,
|
|
356
|
+
` Fix: add this line after @import "tailwindcss":`,
|
|
357
|
+
` @source "../../node_modules/@soulbatical/tetra-ui/dist";`,
|
|
358
|
+
` (adjust the "../" prefix to match the depth of this CSS file)`,
|
|
359
|
+
].join('\n'),
|
|
360
|
+
fix: 'Add @source "../../node_modules/@soulbatical/tetra-ui/dist"; (adjust path depth).',
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return result
|
|
366
|
+
}
|
package/lib/checks/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export * as uiTheming from './codeQuality/ui-theming.js'
|
|
|
24
24
|
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
|
+
export * as tailwindSourcePaths from './codeQuality/tailwind-source-paths.js'
|
|
27
28
|
|
|
28
29
|
// Health checks (project ecosystem)
|
|
29
30
|
export * as health from './health/index.js'
|
package/lib/runner.js
CHANGED
|
@@ -25,6 +25,7 @@ import * as fileSize from './checks/codeQuality/file-size.js'
|
|
|
25
25
|
import * as namingConventions from './checks/codeQuality/naming-conventions.js'
|
|
26
26
|
import * as routeSeparation from './checks/codeQuality/route-separation.js'
|
|
27
27
|
import * as darkModeCompliance from './checks/codeQuality/dark-mode-compliance.js'
|
|
28
|
+
import * as tailwindSourcePaths from './checks/codeQuality/tailwind-source-paths.js'
|
|
28
29
|
import * as gitignoreValidation from './checks/security/gitignore-validation.js'
|
|
29
30
|
import * as routeConfigAlignment from './checks/security/route-config-alignment.js'
|
|
30
31
|
import * as rlsLiveAudit from './checks/security/rls-live-audit.js'
|
|
@@ -126,7 +127,8 @@ const ALL_CHECKS = {
|
|
|
126
127
|
fileSize,
|
|
127
128
|
namingConventions,
|
|
128
129
|
routeSeparation,
|
|
129
|
-
darkModeCompliance
|
|
130
|
+
darkModeCompliance,
|
|
131
|
+
tailwindSourcePaths
|
|
130
132
|
],
|
|
131
133
|
supabase: [
|
|
132
134
|
rlsPolicyAudit,
|