@soulbatical/tetra-dev-toolkit 1.20.8 → 1.20.9
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,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: Barrel Import Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects when consumer projects import from the tetra barrel export (`.`)
|
|
5
|
+
* instead of subpath exports. Barrel imports cause bundle bloat because
|
|
6
|
+
* they pull in the entire package.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* 1. WARN if importing from '@soulbatical/tetra-ui' (should use ./components, ./hooks, etc.)
|
|
10
|
+
* 2. WARN if importing from '@soulbatical/tetra-core' for items available via subpath
|
|
11
|
+
* (billing, storage, mcp, email, planner, affiliate, auth, generators)
|
|
12
|
+
* 3. OK if importing core framework items (authenticateToken, BaseQueryController, etc.)
|
|
13
|
+
*
|
|
14
|
+
* Scope: src/**\/*.ts(x) excluding node_modules
|
|
15
|
+
*
|
|
16
|
+
* Severity: medium — it works, but causes bundle bloat
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
20
|
+
import { join, relative, extname } from 'node:path'
|
|
21
|
+
|
|
22
|
+
export const meta = {
|
|
23
|
+
id: 'barrel-import-detector',
|
|
24
|
+
name: 'Barrel Import Detector',
|
|
25
|
+
category: 'codeQuality',
|
|
26
|
+
severity: 'medium',
|
|
27
|
+
description: 'Detects barrel imports from tetra packages that should use subpath exports to reduce bundle size'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Subpath mappings ─────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const TETRA_CORE_SUBPATH_EXPORTS = {
|
|
33
|
+
billing: ['BillingService', 'addBillingRoutes', 'addBillingWebhookRoutes'],
|
|
34
|
+
storage: ['addStorageProxyRoutes', 'addStorageUploadRoutes', 'StorageProxyService', 'StorageUploadService', 'ImageProcessingService'],
|
|
35
|
+
mcp: ['addMcpRoutes', 'addMcpAuthRoutes', 'addMcpTokenRoutes', 'addMcpUsageRoutes'],
|
|
36
|
+
email: ['EmailService', 'sendMailgunEmail', 'GmailClient', 'addGmailOAuthRoutes'],
|
|
37
|
+
planner: ['PlannerService', 'addPlannerRoutes', 'GoogleCalendarService'],
|
|
38
|
+
affiliate: ['AffiliateAttributionService', 'addAffiliateAdminRoutes', 'addAffiliatePublicRoutes'],
|
|
39
|
+
auth: ['addPublicAuthRoutes'],
|
|
40
|
+
generators: ['RPCGenerator', 'DetailRPCGenerator', 'generateRLS', 'runRLSCheck'],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Flatten for quick lookup: importName -> subpath
|
|
44
|
+
const SUBPATH_LOOKUP = new Map()
|
|
45
|
+
for (const [subpath, names] of Object.entries(TETRA_CORE_SUBPATH_EXPORTS)) {
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
SUBPATH_LOOKUP.set(name, subpath)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Patterns ─────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
// Matches: import { Foo, Bar } from '@soulbatical/tetra-ui'
|
|
54
|
+
// Matches: import { Foo } from "@soulbatical/tetra-ui"
|
|
55
|
+
const TETRA_UI_BARREL_RE = /from\s+['"]@soulbatical\/tetra-ui['"]/
|
|
56
|
+
const TETRA_CORE_BARREL_RE = /from\s+['"]@soulbatical\/tetra-core['"]/
|
|
57
|
+
|
|
58
|
+
// Extract named imports: import { Foo, Bar as Baz } from '...'
|
|
59
|
+
const NAMED_IMPORT_RE = /import\s*\{([^}]+)\}\s*from\s*['"]@soulbatical\/tetra-core['"]/
|
|
60
|
+
|
|
61
|
+
// ── Scanner ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async function collectFiles(dir) {
|
|
64
|
+
const files = []
|
|
65
|
+
try {
|
|
66
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (entry.name === 'node_modules') continue
|
|
69
|
+
const fullPath = join(dir, entry.name)
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
files.push(...await collectFiles(fullPath))
|
|
72
|
+
} else if (['.tsx', '.ts'].includes(extname(entry.name))) {
|
|
73
|
+
files.push(fullPath)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Directory doesn't exist
|
|
78
|
+
}
|
|
79
|
+
return files
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findViolations(content, filePath) {
|
|
83
|
+
const violations = []
|
|
84
|
+
const lines = content.split('\n')
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i]
|
|
88
|
+
const lineNum = i + 1
|
|
89
|
+
|
|
90
|
+
// Skip comments
|
|
91
|
+
const trimmed = line.trim()
|
|
92
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
|
|
93
|
+
|
|
94
|
+
// Rule 1: Any import from @soulbatical/tetra-ui barrel
|
|
95
|
+
if (TETRA_UI_BARREL_RE.test(line)) {
|
|
96
|
+
violations.push({
|
|
97
|
+
line: lineNum,
|
|
98
|
+
rule: 'no-tetra-ui-barrel',
|
|
99
|
+
message: `Barrel import from '@soulbatical/tetra-ui' — use subpath like '@soulbatical/tetra-ui/components' or '@soulbatical/tetra-ui/hooks'`,
|
|
100
|
+
severity: 'medium',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Rule 2: Import from @soulbatical/tetra-core with subpath-available items
|
|
105
|
+
if (TETRA_CORE_BARREL_RE.test(line)) {
|
|
106
|
+
const match = line.match(NAMED_IMPORT_RE)
|
|
107
|
+
if (match) {
|
|
108
|
+
const imports = match[1].split(',').map(s => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean)
|
|
109
|
+
const subpathImports = []
|
|
110
|
+
for (const imp of imports) {
|
|
111
|
+
const subpath = SUBPATH_LOOKUP.get(imp)
|
|
112
|
+
if (subpath) {
|
|
113
|
+
subpathImports.push({ name: imp, subpath })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (subpathImports.length > 0) {
|
|
118
|
+
// Group by subpath for a cleaner message
|
|
119
|
+
const grouped = {}
|
|
120
|
+
for (const { name, subpath } of subpathImports) {
|
|
121
|
+
if (!grouped[subpath]) grouped[subpath] = []
|
|
122
|
+
grouped[subpath].push(name)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const suggestions = Object.entries(grouped)
|
|
126
|
+
.map(([subpath, names]) => `${names.join(', ')} → '@soulbatical/tetra-core/${subpath}'`)
|
|
127
|
+
.join('; ')
|
|
128
|
+
|
|
129
|
+
violations.push({
|
|
130
|
+
line: lineNum,
|
|
131
|
+
rule: 'no-tetra-core-barrel-subpath',
|
|
132
|
+
message: `Barrel import for subpath-available items: ${suggestions}`,
|
|
133
|
+
severity: 'medium',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return violations
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Main check ───────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export async function run(config, projectRoot) {
|
|
146
|
+
const result = {
|
|
147
|
+
passed: true,
|
|
148
|
+
findings: [],
|
|
149
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
150
|
+
details: { files: {}, totalViolations: 0, scannedFiles: 0 }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const srcDir = join(projectRoot, 'src')
|
|
154
|
+
const allFiles = await collectFiles(srcDir)
|
|
155
|
+
result.details.scannedFiles = allFiles.length
|
|
156
|
+
|
|
157
|
+
for (const filePath of allFiles) {
|
|
158
|
+
const content = await readFile(filePath, 'utf8')
|
|
159
|
+
const violations = findViolations(content, filePath)
|
|
160
|
+
|
|
161
|
+
if (violations.length > 0) {
|
|
162
|
+
const rel = relative(projectRoot, filePath)
|
|
163
|
+
result.details.files[rel] = violations
|
|
164
|
+
result.details.totalViolations += violations.length
|
|
165
|
+
|
|
166
|
+
for (const v of violations) {
|
|
167
|
+
result.summary[v.severity] = (result.summary[v.severity] || 0) + 1
|
|
168
|
+
result.summary.total++
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
result.findings.push({
|
|
172
|
+
type: `Barrel import in ${relative(srcDir, filePath)}`,
|
|
173
|
+
severity: 'medium',
|
|
174
|
+
message: `${violations.length} barrel import(s): ${[...new Set(violations.map(v => v.rule))].join(', ')}`,
|
|
175
|
+
files: violations.map(v => `L${v.line}: ${v.message}`)
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
result.passed = result.summary.critical === 0 && result.summary.high === 0
|
|
181
|
+
return result
|
|
182
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: TypeScript Strictness
|
|
3
|
+
*
|
|
4
|
+
* Checks that consumer projects have strict TypeScript enabled and
|
|
5
|
+
* monitors usage of `: any` which undermines type safety.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* 1. CRITICAL if tsconfig.json has "strict": false or strict is missing
|
|
9
|
+
* 2. HIGH if there are more than 10 files with `: any`
|
|
10
|
+
* 3. MEDIUM if there are 1-10 files with `: any`
|
|
11
|
+
*
|
|
12
|
+
* Scope: tsconfig.json + src/**\/*.ts(x) (excludes node_modules, dist, .d.ts)
|
|
13
|
+
*
|
|
14
|
+
* Severity: critical — weak types lead to runtime errors
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
18
|
+
import { join, relative, extname } from 'node:path'
|
|
19
|
+
|
|
20
|
+
export const meta = {
|
|
21
|
+
id: 'typescript-strictness',
|
|
22
|
+
name: 'TypeScript Strictness',
|
|
23
|
+
category: 'codeQuality',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
description: 'Checks that strict TypeScript is enabled and monitors `: any` usage across the codebase'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Patterns ─────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
// Matches `: any` in type positions — colon followed by `any` as a word
|
|
31
|
+
// Covers: param: any, const x: any, as any, <any>, : any[]
|
|
32
|
+
const ANY_TYPE_RE = /:\s*any\b/g
|
|
33
|
+
|
|
34
|
+
// ── Scanner ──────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async function collectFiles(dir) {
|
|
37
|
+
const files = []
|
|
38
|
+
try {
|
|
39
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry.name === 'node_modules' || entry.name === 'dist') continue
|
|
42
|
+
const fullPath = join(dir, entry.name)
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
files.push(...await collectFiles(fullPath))
|
|
45
|
+
} else if (['.tsx', '.ts'].includes(extname(entry.name)) && !entry.name.endsWith('.d.ts')) {
|
|
46
|
+
files.push(fullPath)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Directory doesn't exist
|
|
51
|
+
}
|
|
52
|
+
return files
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Main check ───────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export async function run(config, projectRoot) {
|
|
58
|
+
const result = {
|
|
59
|
+
passed: true,
|
|
60
|
+
findings: [],
|
|
61
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
62
|
+
details: { strictMode: null, anyFiles: [], anyFileCount: 0, scannedFiles: 0 }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Rule 1: Check tsconfig.json strict mode ──
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const tsconfigPath = join(projectRoot, 'tsconfig.json')
|
|
69
|
+
const tsconfigRaw = await readFile(tsconfigPath, 'utf8')
|
|
70
|
+
// Strip comments (// and /* */) for JSON parsing
|
|
71
|
+
const stripped = tsconfigRaw
|
|
72
|
+
.replace(/\/\/.*$/gm, '')
|
|
73
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
74
|
+
// Remove trailing commas before } or ]
|
|
75
|
+
.replace(/,\s*([\]}])/g, '$1')
|
|
76
|
+
const tsconfig = JSON.parse(stripped)
|
|
77
|
+
|
|
78
|
+
const strict = tsconfig?.compilerOptions?.strict
|
|
79
|
+
result.details.strictMode = strict
|
|
80
|
+
|
|
81
|
+
if (strict !== true) {
|
|
82
|
+
const message = strict === false
|
|
83
|
+
? '"strict": false in tsconfig.json — TypeScript strict mode is explicitly disabled'
|
|
84
|
+
: '"strict" is missing from tsconfig.json compilerOptions — strict mode defaults to off'
|
|
85
|
+
|
|
86
|
+
result.findings.push({
|
|
87
|
+
type: 'TypeScript strict mode disabled',
|
|
88
|
+
severity: 'critical',
|
|
89
|
+
message,
|
|
90
|
+
files: ['tsconfig.json']
|
|
91
|
+
})
|
|
92
|
+
result.summary.critical++
|
|
93
|
+
result.summary.total++
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
result.findings.push({
|
|
97
|
+
type: 'Missing tsconfig.json',
|
|
98
|
+
severity: 'critical',
|
|
99
|
+
message: `Could not read tsconfig.json: ${err.message}`,
|
|
100
|
+
files: ['tsconfig.json']
|
|
101
|
+
})
|
|
102
|
+
result.summary.critical++
|
|
103
|
+
result.summary.total++
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Rule 2 & 3: Scan for `: any` usage ──
|
|
107
|
+
|
|
108
|
+
const srcDir = join(projectRoot, 'src')
|
|
109
|
+
const allFiles = await collectFiles(srcDir)
|
|
110
|
+
result.details.scannedFiles = allFiles.length
|
|
111
|
+
|
|
112
|
+
const filesWithAny = []
|
|
113
|
+
|
|
114
|
+
for (const filePath of allFiles) {
|
|
115
|
+
const content = await readFile(filePath, 'utf8')
|
|
116
|
+
const lines = content.split('\n')
|
|
117
|
+
const anyLines = []
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const line = lines[i]
|
|
121
|
+
const trimmed = line.trim()
|
|
122
|
+
// Skip comments
|
|
123
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
|
|
124
|
+
|
|
125
|
+
ANY_TYPE_RE.lastIndex = 0
|
|
126
|
+
if (ANY_TYPE_RE.test(line)) {
|
|
127
|
+
anyLines.push(i + 1)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (anyLines.length > 0) {
|
|
132
|
+
const rel = relative(projectRoot, filePath)
|
|
133
|
+
filesWithAny.push({ file: rel, lines: anyLines, count: anyLines.length })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
result.details.anyFiles = filesWithAny
|
|
138
|
+
result.details.anyFileCount = filesWithAny.length
|
|
139
|
+
|
|
140
|
+
if (filesWithAny.length > 10) {
|
|
141
|
+
result.findings.push({
|
|
142
|
+
type: 'Excessive `: any` usage',
|
|
143
|
+
severity: 'high',
|
|
144
|
+
message: `${filesWithAny.length} files contain ': any' — consider adding proper types to improve type safety`,
|
|
145
|
+
files: filesWithAny.map(f => `${f.file} (${f.count} occurrence${f.count > 1 ? 's' : ''})`)
|
|
146
|
+
})
|
|
147
|
+
result.summary.high++
|
|
148
|
+
result.summary.total++
|
|
149
|
+
} else if (filesWithAny.length > 0) {
|
|
150
|
+
result.findings.push({
|
|
151
|
+
type: '`: any` usage detected',
|
|
152
|
+
severity: 'medium',
|
|
153
|
+
message: `${filesWithAny.length} file${filesWithAny.length > 1 ? 's' : ''} contain${filesWithAny.length === 1 ? 's' : ''} ': any' — consider replacing with proper types`,
|
|
154
|
+
files: filesWithAny.map(f => `${f.file} (${f.count} occurrence${f.count > 1 ? 's' : ''})`)
|
|
155
|
+
})
|
|
156
|
+
result.summary.medium++
|
|
157
|
+
result.summary.total++
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
result.passed = result.summary.critical === 0 && result.summary.high === 0
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: UI Theming Compliance
|
|
3
|
+
*
|
|
4
|
+
* Enforces that tetra-ui components use ONLY --tetra-* CSS tokens for colors,
|
|
5
|
+
* backgrounds, and borders. No hardcoded color values allowed.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* 1. NO Tailwind color classes (bg-blue-500, text-gray-900, border-red-*, dark:bg-*)
|
|
9
|
+
* 2. NO hardcoded hex/rgb/hsl in inline styles
|
|
10
|
+
* 3. NO component-specific CSS vars (--ldl-*, --ft-*, etc.) — only --tetra-*
|
|
11
|
+
* 4. Allowed: Tailwind layout/spacing (flex, p-4, gap-2, rounded-lg, etc.)
|
|
12
|
+
* 5. Allowed: var(--tetra-*) references
|
|
13
|
+
* 6. Allowed: "currentColor", "transparent", "inherit"
|
|
14
|
+
*
|
|
15
|
+
* Scope: packages/ui/src/components/** and packages/ui/src/planner/**
|
|
16
|
+
* (excludes components/ui/** which are shadcn primitives with their own token system)
|
|
17
|
+
*
|
|
18
|
+
* Severity: critical — hardcoded colors break consumer theming
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
22
|
+
import { join, relative, extname } from 'node:path'
|
|
23
|
+
|
|
24
|
+
export const meta = {
|
|
25
|
+
id: 'ui-theming',
|
|
26
|
+
name: 'UI Theming Compliance',
|
|
27
|
+
category: 'codeQuality',
|
|
28
|
+
severity: 'critical',
|
|
29
|
+
description: 'Enforces tetra-ui components use only --tetra-* tokens for colors (no hardcoded Tailwind colors, hex, or component-specific vars)'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Patterns ──────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
// Tailwind color classes to reject (in className strings or template literals)
|
|
35
|
+
// Matches: bg-{color}-{shade}, text-{color}-{shade}, border-{color}-{shade},
|
|
36
|
+
// ring-{color}-{shade}, shadow-{color}-{shade}, divide-{color}-{shade},
|
|
37
|
+
// from-{color}-{shade}, to-{color}-{shade}, via-{color}-{shade},
|
|
38
|
+
// dark:bg-{color}-{shade}, hover:bg-{color}-{shade}, etc.
|
|
39
|
+
const TAILWIND_COLORS = [
|
|
40
|
+
'slate', 'gray', 'zinc', 'neutral', 'stone',
|
|
41
|
+
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal',
|
|
42
|
+
'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose',
|
|
43
|
+
'black', 'white',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const TW_COLOR_PREFIXES = [
|
|
47
|
+
'bg', 'text', 'border', 'ring', 'shadow', 'divide',
|
|
48
|
+
'from', 'to', 'via', 'outline', 'decoration',
|
|
49
|
+
'accent', 'caret', 'fill', 'stroke',
|
|
50
|
+
'border-l', 'border-r', 'border-t', 'border-b',
|
|
51
|
+
'border-x', 'border-y',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
// Build regex: matches things like "bg-blue-500", "dark:text-gray-100", "hover:bg-red-50/30"
|
|
55
|
+
// But NOT "bg-card", "text-foreground" (shadcn semantic classes)
|
|
56
|
+
function buildTailwindColorRegex() {
|
|
57
|
+
const colorPattern = TAILWIND_COLORS.join('|')
|
|
58
|
+
const prefixPattern = TW_COLOR_PREFIXES.join('|')
|
|
59
|
+
// Match optional modifiers (dark:, hover:, focus:, etc.) + prefix + color + optional shade
|
|
60
|
+
return new RegExp(
|
|
61
|
+
`(?:^|\\s|"|'|\`)(?:[a-z]+:)*(?:${prefixPattern})-(?:${colorPattern})(?:-\\d{2,3})?(?:\\/\\d+)?(?=\\s|"|'|\`|$|\\))`,
|
|
62
|
+
'g'
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Hardcoded color values in style objects or CSS
|
|
67
|
+
// Matches: #xxx, #xxxxxx, #xxxxxxxx, rgb(), rgba(), hsl(), hsla()
|
|
68
|
+
const HARDCODED_COLOR_RE = /(?:["'`])(#[0-9a-fA-F]{3,8})(?:["'`])|(?:rgba?\s*\([\d\s,./%]+\))|(?:hsla?\s*\([\d\s,./%°]+\))/g
|
|
69
|
+
|
|
70
|
+
// Allowed hardcoded values (not colors)
|
|
71
|
+
const ALLOWED_VALUES = new Set([
|
|
72
|
+
'transparent', 'currentColor', 'currentcolor', 'inherit', 'none', 'initial', 'unset',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
// Component-specific CSS vars (should be --tetra-* instead)
|
|
76
|
+
const COMPONENT_VAR_RE = /var\(--(?!tetra-)[a-z]+-[a-z]/g
|
|
77
|
+
|
|
78
|
+
// ── Scanner ───────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async function collectFiles(dir) {
|
|
81
|
+
const files = []
|
|
82
|
+
try {
|
|
83
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const fullPath = join(dir, entry.name)
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
files.push(...await collectFiles(fullPath))
|
|
88
|
+
} else if (['.tsx', '.ts'].includes(extname(entry.name))) {
|
|
89
|
+
files.push(fullPath)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Directory doesn't exist
|
|
94
|
+
}
|
|
95
|
+
return files
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findViolations(content, filePath) {
|
|
99
|
+
const violations = []
|
|
100
|
+
const lines = content.split('\n')
|
|
101
|
+
|
|
102
|
+
const twColorRegex = buildTailwindColorRegex()
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i]
|
|
106
|
+
const lineNum = i + 1
|
|
107
|
+
|
|
108
|
+
// Skip comments and brand color exceptions
|
|
109
|
+
const trimmed = line.trim()
|
|
110
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
|
|
111
|
+
if (line.includes('// Brand color')) continue
|
|
112
|
+
|
|
113
|
+
// Rule 1: Tailwind color classes
|
|
114
|
+
twColorRegex.lastIndex = 0
|
|
115
|
+
let match
|
|
116
|
+
while ((match = twColorRegex.exec(line)) !== null) {
|
|
117
|
+
const cls = match[0].trim().replace(/^["'`]/, '')
|
|
118
|
+
violations.push({
|
|
119
|
+
line: lineNum,
|
|
120
|
+
rule: 'no-tailwind-colors',
|
|
121
|
+
message: `Tailwind color class "${cls}" — use --tetra-* token via inline style instead`,
|
|
122
|
+
severity: 'critical',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Rule 2: Hardcoded hex/rgb/hsl in style props or CSS
|
|
127
|
+
// Only check lines that look like style assignments or CSS
|
|
128
|
+
if (line.includes('style') || line.includes('color') || line.includes('background') || line.includes('border')) {
|
|
129
|
+
HARDCODED_COLOR_RE.lastIndex = 0
|
|
130
|
+
while ((match = HARDCODED_COLOR_RE.exec(line)) !== null) {
|
|
131
|
+
const value = (match[1] || match[0]).trim()
|
|
132
|
+
if (ALLOWED_VALUES.has(value.toLowerCase())) continue
|
|
133
|
+
// Allow inside CSS var defaults: var(--tetra-bg, #ffffff) — only in tokens.css
|
|
134
|
+
if (filePath.includes('tokens.css')) continue
|
|
135
|
+
|
|
136
|
+
violations.push({
|
|
137
|
+
line: lineNum,
|
|
138
|
+
rule: 'no-hardcoded-colors',
|
|
139
|
+
message: `Hardcoded color "${value}" — use var(--tetra-*) instead`,
|
|
140
|
+
severity: 'critical',
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Rule 3: Component-specific CSS vars
|
|
146
|
+
COMPONENT_VAR_RE.lastIndex = 0
|
|
147
|
+
while ((match = COMPONENT_VAR_RE.exec(line)) !== null) {
|
|
148
|
+
// Allow standard CSS vars: --tw-*, --radix-*, --background, --foreground, etc.
|
|
149
|
+
const varName = match[0]
|
|
150
|
+
if (varName.includes('--tw-') || varName.includes('--radix-')) continue
|
|
151
|
+
violations.push({
|
|
152
|
+
line: lineNum,
|
|
153
|
+
rule: 'no-component-vars',
|
|
154
|
+
message: `Component-specific CSS var "${varName}" — use var(--tetra-*) instead`,
|
|
155
|
+
severity: 'high',
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return violations
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Main check ────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export async function run(config, projectRoot) {
|
|
166
|
+
const result = {
|
|
167
|
+
passed: true,
|
|
168
|
+
findings: [],
|
|
169
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
170
|
+
details: { files: {}, totalViolations: 0, scannedFiles: 0 }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Find the UI package — could be in monorepo or standalone
|
|
174
|
+
let uiSrcDir = join(projectRoot, 'packages', 'ui', 'src')
|
|
175
|
+
try {
|
|
176
|
+
await stat(uiSrcDir)
|
|
177
|
+
} catch {
|
|
178
|
+
// Not a monorepo — check if we're in the ui package directly
|
|
179
|
+
try {
|
|
180
|
+
uiSrcDir = join(projectRoot, 'src')
|
|
181
|
+
const pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'))
|
|
182
|
+
if (!pkg.name?.includes('tetra-ui')) return result // Not a UI package, skip
|
|
183
|
+
} catch {
|
|
184
|
+
return result // Can't find UI package, skip
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Scan directories (exclude components/ui/ — those are shadcn primitives)
|
|
189
|
+
const scanDirs = [
|
|
190
|
+
join(uiSrcDir, 'components'),
|
|
191
|
+
join(uiSrcDir, 'planner'),
|
|
192
|
+
join(uiSrcDir, 'providers'),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
const excludePaths = [
|
|
196
|
+
join('components', 'ui'), // shadcn primitives have their own token system
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
let allFiles = []
|
|
200
|
+
for (const dir of scanDirs) {
|
|
201
|
+
allFiles.push(...await collectFiles(dir))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Filter out excluded paths
|
|
205
|
+
allFiles = allFiles.filter(f => {
|
|
206
|
+
const rel = relative(uiSrcDir, f)
|
|
207
|
+
return !excludePaths.some(exc => rel.startsWith(exc))
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
result.details.scannedFiles = allFiles.length
|
|
211
|
+
|
|
212
|
+
for (const filePath of allFiles) {
|
|
213
|
+
const content = await readFile(filePath, 'utf8')
|
|
214
|
+
const violations = findViolations(content, filePath)
|
|
215
|
+
|
|
216
|
+
if (violations.length > 0) {
|
|
217
|
+
const rel = relative(projectRoot, filePath)
|
|
218
|
+
result.details.files[rel] = violations
|
|
219
|
+
result.details.totalViolations += violations.length
|
|
220
|
+
|
|
221
|
+
for (const v of violations) {
|
|
222
|
+
result.summary[v.severity] = (result.summary[v.severity] || 0) + 1
|
|
223
|
+
result.summary.total++
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
result.findings.push({
|
|
227
|
+
type: `Theming violations in ${relative(uiSrcDir, filePath)}`,
|
|
228
|
+
severity: violations.some(v => v.severity === 'critical') ? 'critical' : 'high',
|
|
229
|
+
message: `${violations.length} violation(s): ${[...new Set(violations.map(v => v.rule))].join(', ')}`,
|
|
230
|
+
files: violations.map(v => `L${v.line}: ${v.message}`)
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
result.passed = result.summary.critical === 0 && result.summary.high === 0
|
|
236
|
+
return result
|
|
237
|
+
}
|
package/lib/checks/index.js
CHANGED
|
@@ -18,5 +18,10 @@ export * as rlsPolicyAudit from './supabase/rls-policy-audit.js'
|
|
|
18
18
|
export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
|
|
19
19
|
export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
|
|
20
20
|
|
|
21
|
+
// Code quality checks
|
|
22
|
+
export * as uiTheming from './codeQuality/ui-theming.js'
|
|
23
|
+
export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
|
|
24
|
+
export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
|
|
25
|
+
|
|
21
26
|
// Health checks (project ecosystem)
|
|
22
27
|
export * as health from './health/index.js'
|