@soulbatical/tetra-dev-toolkit 1.20.8 → 1.20.10

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,193 @@
1
+ /**
2
+ * Code Quality Check: MCP Tool Documentation
3
+ *
4
+ * Ensures all MCP tool definitions have proper descriptions.
5
+ * Scans backend-mcp/src/ for tool definitions and verifies:
6
+ *
7
+ * 1. Every tool has a non-empty `description` field
8
+ * 2. Descriptions are meaningful (>20 chars, not just the tool name)
9
+ * 3. Tool names follow naming convention (snake_case)
10
+ *
11
+ * Severity: high — tools without descriptions are unusable for AI clients
12
+ */
13
+
14
+ import { readdir, readFile, stat } from 'node:fs/promises'
15
+ import { join, relative, extname } from 'node:path'
16
+
17
+ export const meta = {
18
+ id: 'mcp-tool-docs',
19
+ name: 'MCP Tool Documentation',
20
+ category: 'codeQuality',
21
+ severity: 'high',
22
+ description: 'Ensures all MCP tool definitions have meaningful descriptions and follow naming conventions'
23
+ }
24
+
25
+ // ── Patterns ──────────────────────────────────────────
26
+
27
+ // Match tool definition objects: { name: "...", description: "..." }
28
+ // Handles both inline and multi-line definitions
29
+ const TOOL_NAME_RE = /name:\s*["'`]([^"'`]+)["'`]/g
30
+ const TOOL_DESC_RE = /description:\s*["'`]([^"'`]*)["'`]/g
31
+
32
+ // snake_case check
33
+ const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
34
+
35
+ // ── Scanner ───────────────────────────────────────────
36
+
37
+ async function collectFiles(dir) {
38
+ const files = []
39
+ try {
40
+ const entries = await readdir(dir, { withFileTypes: true })
41
+ for (const entry of entries) {
42
+ const fullPath = join(dir, entry.name)
43
+ if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
44
+ files.push(...await collectFiles(fullPath))
45
+ } else if (['.ts', '.tsx', '.js'].includes(extname(entry.name))) {
46
+ files.push(fullPath)
47
+ }
48
+ }
49
+ } catch {
50
+ // Directory doesn't exist
51
+ }
52
+ return files
53
+ }
54
+
55
+ function extractTools(content) {
56
+ const tools = []
57
+
58
+ // Strategy 1: Find name/description pairs in tool definition objects
59
+ // Look for patterns like: { name: "tool_name", description: "..." }
60
+ const nameMatches = [...content.matchAll(TOOL_NAME_RE)]
61
+
62
+ for (const nameMatch of nameMatches) {
63
+ const name = nameMatch[1]
64
+ const namePos = nameMatch.index
65
+
66
+ // Look for a description within 500 chars of the name
67
+ const searchWindow = content.slice(Math.max(0, namePos - 200), namePos + 500)
68
+ const descMatch = searchWindow.match(/description:\s*["'`]([^"'`]*)["'`]/)
69
+
70
+ tools.push({
71
+ name,
72
+ description: descMatch ? descMatch[1] : null,
73
+ line: content.slice(0, namePos).split('\n').length,
74
+ })
75
+ }
76
+
77
+ return tools
78
+ }
79
+
80
+ function validateTool(tool) {
81
+ const violations = []
82
+
83
+ // Check name is snake_case
84
+ if (!SNAKE_CASE_RE.test(tool.name)) {
85
+ violations.push({
86
+ rule: 'tool-naming',
87
+ severity: 'medium',
88
+ message: `Tool "${tool.name}" should use snake_case naming`,
89
+ })
90
+ }
91
+
92
+ // Check description exists
93
+ if (!tool.description) {
94
+ violations.push({
95
+ rule: 'missing-description',
96
+ severity: 'high',
97
+ message: `Tool "${tool.name}" has no description — AI clients won't know what it does`,
98
+ })
99
+ return violations
100
+ }
101
+
102
+ // Check description is meaningful
103
+ if (tool.description.length < 20) {
104
+ violations.push({
105
+ rule: 'short-description',
106
+ severity: 'high',
107
+ message: `Tool "${tool.name}" description is too short (${tool.description.length} chars, min 20) — "${tool.description}"`,
108
+ })
109
+ }
110
+
111
+ // Check description isn't just the tool name
112
+ if (tool.description.toLowerCase().replace(/[_\s-]/g, '') === tool.name.toLowerCase().replace(/[_\s-]/g, '')) {
113
+ violations.push({
114
+ rule: 'lazy-description',
115
+ severity: 'high',
116
+ message: `Tool "${tool.name}" description just repeats the name — write what it does`,
117
+ })
118
+ }
119
+
120
+ return violations
121
+ }
122
+
123
+ // ── Main check ────────────────────────────────────────
124
+
125
+ export async function run(config, projectRoot) {
126
+ const result = {
127
+ passed: true,
128
+ findings: [],
129
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
130
+ details: { files: {}, totalTools: 0, totalViolations: 0, toolsWithoutDesc: 0 }
131
+ }
132
+
133
+ // Find MCP tool directories
134
+ const mcpDirs = [
135
+ join(projectRoot, 'backend-mcp', 'src', 'tools'),
136
+ join(projectRoot, 'backend-mcp', 'src'),
137
+ join(projectRoot, 'mcp', 'src', 'tools'),
138
+ join(projectRoot, 'src', 'tools'),
139
+ ]
140
+
141
+ let allFiles = []
142
+ for (const dir of mcpDirs) {
143
+ try {
144
+ await stat(dir)
145
+ allFiles.push(...await collectFiles(dir))
146
+ } catch {
147
+ // Directory doesn't exist, skip
148
+ }
149
+ }
150
+
151
+ if (allFiles.length === 0) return result // Not an MCP project
152
+
153
+ for (const filePath of allFiles) {
154
+ const content = await readFile(filePath, 'utf8')
155
+ const tools = extractTools(content)
156
+
157
+ if (tools.length === 0) continue
158
+
159
+ result.details.totalTools += tools.length
160
+ const fileViolations = []
161
+
162
+ for (const tool of tools) {
163
+ const violations = validateTool(tool)
164
+ if (violations.length > 0) {
165
+ fileViolations.push(...violations.map(v => ({ ...v, tool: tool.name, line: tool.line })))
166
+ if (violations.some(v => v.rule === 'missing-description')) {
167
+ result.details.toolsWithoutDesc++
168
+ }
169
+ }
170
+ }
171
+
172
+ if (fileViolations.length > 0) {
173
+ const rel = relative(projectRoot, filePath)
174
+ result.details.files[rel] = fileViolations
175
+ result.details.totalViolations += fileViolations.length
176
+
177
+ for (const v of fileViolations) {
178
+ result.summary[v.severity] = (result.summary[v.severity] || 0) + 1
179
+ result.summary.total++
180
+ }
181
+
182
+ result.findings.push({
183
+ type: `MCP tool docs in ${rel}`,
184
+ severity: fileViolations.some(v => v.severity === 'high') ? 'high' : 'medium',
185
+ message: `${fileViolations.length} issue(s): ${[...new Set(fileViolations.map(v => v.rule))].join(', ')}`,
186
+ files: fileViolations.map(v => `L${v.line}: [${v.rule}] ${v.message}`)
187
+ })
188
+ }
189
+ }
190
+
191
+ result.passed = result.summary.high === 0 && result.summary.critical === 0
192
+ return result
193
+ }
@@ -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,11 @@ 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
+ export * as mcpToolDocs from './codeQuality/mcp-tool-docs.js'
26
+
21
27
  // Health checks (project ecosystem)
22
28
  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.8",
3
+ "version": "1.20.10",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },