@soulbatical/tetra-dev-toolkit 1.20.7 → 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.
@@ -217,9 +217,10 @@ program
217
217
 
218
218
  if (!resp.ok) {
219
219
  const body = await resp.text()
220
- console.error(chalk.yellow(` Ralph Manager returned ${resp.status}: ${body}`))
221
- console.log(chalk.yellow(' Falling back to PASS (ralph-manager error).\n'))
222
- process.exit(0)
220
+ console.error(chalk.red.bold(`\n PUSH BLOCKED — Ralph Manager returned ${resp.status}`))
221
+ console.error(chalk.red(` ${body}`))
222
+ console.error(chalk.red(` Security-sensitive files detected but review failed.\n`))
223
+ process.exit(1)
223
224
  }
224
225
 
225
226
  const { data } = await resp.json()
@@ -236,10 +237,13 @@ program
236
237
  process.exit(1)
237
238
  }
238
239
  } catch (err) {
239
- // Ralph-manager offline — don't block the push
240
- console.log(chalk.yellow(` Cannot reach ralph-manager at ${baseUrl}`))
241
- console.log(chalk.yellow(' Falling back to PASS (offline fallback).\n'))
242
- process.exit(0)
240
+ // Ralph-manager offline — block the push (security files need review)
241
+ console.error(chalk.red.bold(`\n PUSH BLOCKED — Cannot reach ralph-manager at ${baseUrl}`))
242
+ console.error(chalk.red(` Security-sensitive files detected but review server unreachable.`))
243
+ console.error(chalk.yellow(`\n Options:`))
244
+ console.error(chalk.yellow(` 1. Start ralph-manager: npm run dev:backend`))
245
+ console.error(chalk.yellow(` 2. Check if backend is running on port 3005\n`))
246
+ process.exit(1)
243
247
  }
244
248
 
245
249
  // Step 4: Poll for verdict
@@ -273,20 +277,27 @@ program
273
277
  }
274
278
 
275
279
  if (result.status === 'timeout') {
276
- console.log(chalk.yellow(` Agent did not respond within ${timeout}s.`))
277
- console.log(chalk.yellow(' Falling back to PASS (timeout fallback).\n'))
278
- process.exit(0)
280
+ console.error(chalk.red.bold(`\n ════════════════════════════════════════════════════════════`))
281
+ console.error(chalk.red.bold(` PUSH BLOCKED Security Gate TIMEOUT`))
282
+ console.error(chalk.red.bold(` ════════════════════════════════════════════════════════════`))
283
+ console.error(chalk.red(`\n Agent did not respond within ${timeout}s.`))
284
+ console.error(chalk.red(` Security-sensitive files detected but not reviewed.`))
285
+ console.error(chalk.yellow(`\n Options:`))
286
+ console.error(chalk.yellow(` 1. Ensure ralph-manager backend is running (port 3005)`))
287
+ console.error(chalk.yellow(` 2. Increase timeout: tetra-security-gate --timeout 300`))
288
+ console.error(chalk.yellow(` 3. Dry-run to see what's being reviewed: tetra-security-gate --dry-run\n`))
289
+ process.exit(1)
279
290
  }
280
291
 
281
- // Unknown status — don't block
282
- console.log(chalk.yellow(` Unexpected verdict status: ${result.status}`))
283
- console.log(chalk.yellow(' Falling back to PASS.\n'))
284
- process.exit(0)
292
+ // Unknown status — block (security files were detected but not reviewed)
293
+ console.error(chalk.red.bold(`\n PUSH BLOCKED — Unexpected verdict status: ${result.status}`))
294
+ console.error(chalk.red(` Security-sensitive files detected but review inconclusive.\n`))
295
+ process.exit(1)
285
296
 
286
297
  } catch (err) {
287
- console.error(chalk.red(`\n ERROR: ${err.message}\n`))
288
- // Never block on internal errors
289
- process.exit(0)
298
+ console.error(chalk.red.bold(`\n PUSH BLOCKED — Security gate error: ${err.message}`))
299
+ console.error(chalk.red(` Security-sensitive files detected but review failed.\n`))
300
+ process.exit(1)
290
301
  }
291
302
  })
292
303
 
@@ -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
+ }
@@ -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'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.7",
3
+ "version": "1.20.9",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },