@soulbatical/tetra-dev-toolkit 1.20.22 → 1.20.24
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,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Style Compliance — detect styling drift in consumer projects.
|
|
5
|
+
*
|
|
6
|
+
* Scans frontend source files and checks for hardcoded layout dimensions,
|
|
7
|
+
* raw Tailwind color classes, hex colors, custom card patterns, and other
|
|
8
|
+
* things Tetra already solves via PageContainer, AppShell theme config, and
|
|
9
|
+
* CSS tokens (--tetra-bg, --tetra-text, --tetra-border, --tetra-accent, etc.).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* tetra-style-audit # Audit current project
|
|
13
|
+
* tetra-style-audit --path /path/to/project # Audit specific project
|
|
14
|
+
* tetra-style-audit --json # JSON output for CI
|
|
15
|
+
* tetra-style-audit --ci # GitHub Actions annotations
|
|
16
|
+
* tetra-style-audit --strict # Fail on warnings too
|
|
17
|
+
* tetra-style-audit --fix-hint # Show suggested fix per violation
|
|
18
|
+
* tetra-style-audit --files "src/**\/*.tsx" # Restrict to specific files
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* 0 = clean (no critical violations)
|
|
22
|
+
* 1 = critical violations found (or any if --strict)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { program } from 'commander'
|
|
26
|
+
import chalk from 'chalk'
|
|
27
|
+
import {
|
|
28
|
+
runStyleComplianceAudit,
|
|
29
|
+
formatReport,
|
|
30
|
+
formatReportJSON,
|
|
31
|
+
formatCIAnnotations,
|
|
32
|
+
} from '../lib/audits/style-compliance-audit.js'
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.name('tetra-style-audit')
|
|
36
|
+
.description('Detect styling drift from the Tetra design system')
|
|
37
|
+
.version('1.0.0')
|
|
38
|
+
.option('--path <dir>', 'Project root directory (default: cwd)')
|
|
39
|
+
.option('--json', 'JSON output for CI')
|
|
40
|
+
.option('--ci', 'GitHub Actions annotations for failures')
|
|
41
|
+
.option('--strict', 'Fail on any violation including warnings (default: only critical)')
|
|
42
|
+
.option('--fix-hint', 'Show suggested fix for each violation')
|
|
43
|
+
.option('--files <glob>', 'Restrict scan to specific files (glob pattern)')
|
|
44
|
+
.action(async (options) => {
|
|
45
|
+
try {
|
|
46
|
+
const projectRoot = options.path || process.cwd()
|
|
47
|
+
|
|
48
|
+
if (!options.json) {
|
|
49
|
+
console.log(chalk.gray('\n Scanning for style drift...'))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const report = await runStyleComplianceAudit(projectRoot, {
|
|
53
|
+
filesGlob: options.files,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Output
|
|
57
|
+
if (options.json) {
|
|
58
|
+
console.log(formatReportJSON(report))
|
|
59
|
+
} else {
|
|
60
|
+
console.log(formatReport(report, chalk, { fixHint: options.fixHint }))
|
|
61
|
+
|
|
62
|
+
if (options.ci) {
|
|
63
|
+
const annotations = formatCIAnnotations(report)
|
|
64
|
+
if (annotations) {
|
|
65
|
+
console.log(annotations)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Exit code
|
|
71
|
+
const { summary } = report
|
|
72
|
+
if (options.strict) {
|
|
73
|
+
process.exit(summary.totalCritical > 0 || summary.totalWarnings > 0 ? 1 : 0)
|
|
74
|
+
} else {
|
|
75
|
+
process.exit(summary.totalCritical > 0 ? 1 : 0)
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
79
|
+
if (!options.json) {
|
|
80
|
+
console.error(chalk.gray(` ${err.stack}`))
|
|
81
|
+
}
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
program.parse()
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style Compliance Audit — scans a Tetra consumer project for styling drift.
|
|
3
|
+
*
|
|
4
|
+
* Detects hardcoded layout dimensions, raw Tailwind color classes, hex colors,
|
|
5
|
+
* custom card patterns, and other things Tetra already solves via PageContainer,
|
|
6
|
+
* AppShell theme config, and CSS tokens (--tetra-bg, --tetra-text, etc.).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'fs'
|
|
10
|
+
import { join, relative } from 'path'
|
|
11
|
+
import { glob } from 'glob'
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// CONSTANTS
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** Files under these paths get relaxed rules (only critical #3 and #4). */
|
|
18
|
+
const RELAXED_PATTERNS = [
|
|
19
|
+
/\/(auth)\//,
|
|
20
|
+
/\/\(auth\)\//,
|
|
21
|
+
/\/invite\//,
|
|
22
|
+
/\/onboarding\//,
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/** Files under these paths are skipped entirely. */
|
|
26
|
+
const SKIP_PATTERNS = [
|
|
27
|
+
/\/marketing\//,
|
|
28
|
+
/\/public\//,
|
|
29
|
+
/\/landing\//,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Inline element tags whose padding classes are always legitimate UI-level
|
|
34
|
+
* padding (badges, inputs, buttons, tabs, links, labels).
|
|
35
|
+
*/
|
|
36
|
+
const INLINE_ELEMENT_TAGS = ['span', 'button', 'input', 'textarea', 'select', 'label', 'a']
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// HELPERS — page-root detection
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find the line index of the `return` statement inside the default export
|
|
44
|
+
* function body. Returns -1 if not found (non-page file).
|
|
45
|
+
*
|
|
46
|
+
* Strategy: scan for `export default function` or `export default (`, then
|
|
47
|
+
* find the first `return` keyword that follows it.
|
|
48
|
+
*/
|
|
49
|
+
function findReturnLineIndex(lines) {
|
|
50
|
+
let inDefaultExport = false
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i]
|
|
53
|
+
if (!inDefaultExport) {
|
|
54
|
+
if (/export\s+default\s+(function|\()/.test(line) || /export\s+default\s+\w/.test(line)) {
|
|
55
|
+
inDefaultExport = true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (inDefaultExport) {
|
|
59
|
+
// Match `return (` or `return <` (start of JSX return)
|
|
60
|
+
if (/\breturn\s*[\(<]/.test(line) || (/\breturn\b/.test(line) && /<[A-Za-z]/.test(lines[i + 1] ?? ''))) {
|
|
61
|
+
return i
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return -1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Return true when the given line contains a JSX tag that is one of the
|
|
70
|
+
* known inline/form element tags (whitelisted for padding).
|
|
71
|
+
*/
|
|
72
|
+
function lineHasInlineElementTag(line) {
|
|
73
|
+
return INLINE_ELEMENT_TAGS.some((tag) => new RegExp(`<${tag}[\\s/>]`).test(line))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Return true when the line looks like a legitimate prose width constraint:
|
|
78
|
+
* element has both a prose/article context AND a max-w-* class.
|
|
79
|
+
*/
|
|
80
|
+
function lineIsProseWidth(line) {
|
|
81
|
+
return /\bprose\b/.test(line) || /<article[\s>]/.test(line)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// RULE DEFINITIONS
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
/** CRITICAL violations block CI. */
|
|
89
|
+
const CRITICAL_RULES = [
|
|
90
|
+
{
|
|
91
|
+
id: 'hardcoded-page-width',
|
|
92
|
+
label: 'hardcoded page width',
|
|
93
|
+
fixHint: 'Use AppShell config.layout.pageMaxWidth or fullWidthPaths instead',
|
|
94
|
+
pageOnly: true,
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} line
|
|
97
|
+
* @param {object|null} ctx
|
|
98
|
+
* @param {{ returnLineIndex: number, lineIndex: number }} pageCtx
|
|
99
|
+
*/
|
|
100
|
+
check(line, ctx, pageCtx) {
|
|
101
|
+
// Only flag within first 30 lines after return (page-root area)
|
|
102
|
+
if (pageCtx && pageCtx.returnLineIndex >= 0) {
|
|
103
|
+
if (pageCtx.lineIndex > pageCtx.returnLineIndex + 30) return null
|
|
104
|
+
}
|
|
105
|
+
// Whitelist prose / article contexts
|
|
106
|
+
if (lineIsProseWidth(line)) return null
|
|
107
|
+
|
|
108
|
+
if (/className\s*=/.test(line) && /\bmax-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-\S+)\b/.test(line)) {
|
|
109
|
+
return line.match(/\bmax-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-\S+)\b/)?.[0] ?? 'max-w-*'
|
|
110
|
+
}
|
|
111
|
+
// container mx-auto in className
|
|
112
|
+
if (/className\s*=/.test(line) && /\bcontainer\b/.test(line) && /\bmx-auto\b/.test(line)) {
|
|
113
|
+
return 'container mx-auto'
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'hardcoded-page-padding',
|
|
120
|
+
label: 'hardcoded page padding',
|
|
121
|
+
fixHint: 'Use AppShell config.layout.pagePadding preset instead',
|
|
122
|
+
pageOnly: true,
|
|
123
|
+
check(line, ctx, pageCtx) {
|
|
124
|
+
// Only flag within first 30 lines after return (page-root area)
|
|
125
|
+
if (pageCtx && pageCtx.returnLineIndex >= 0) {
|
|
126
|
+
if (pageCtx.lineIndex > pageCtx.returnLineIndex + 30) return null
|
|
127
|
+
}
|
|
128
|
+
// Whitelist inline/form element tags — their padding is component-level
|
|
129
|
+
if (lineHasInlineElementTag(line)) return null
|
|
130
|
+
|
|
131
|
+
if (/className\s*=/.test(line) && /\b(p|px|py|pt|pb|pl|pr)-\d+\b/.test(line)) {
|
|
132
|
+
return line.match(/\b(p|px|py|pt|pb|pl|pr)-\d+\b/)?.[0] ?? 'p-*'
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'hardcoded-hex-color',
|
|
139
|
+
label: 'hardcoded hex color',
|
|
140
|
+
fixHint: 'Use CSS token var(--tetra-accent) / var(--tetra-bg) etc., or set via AppShell theme config',
|
|
141
|
+
check(line) {
|
|
142
|
+
// Skip tetra-style-audit-disable-next-line handled upstream
|
|
143
|
+
// Inline style attr with hex
|
|
144
|
+
if (/style\s*=/.test(line) && /#[0-9a-fA-F]{3,8}\b/.test(line)) {
|
|
145
|
+
return line.match(/#[0-9a-fA-F]{3,8}\b/)?.[0] ?? '#hex'
|
|
146
|
+
}
|
|
147
|
+
// Tailwind arbitrary hex [#...]
|
|
148
|
+
if (/\[#[0-9a-fA-F]{3,8}\]/.test(line)) {
|
|
149
|
+
return line.match(/\[#[0-9a-fA-F]{3,8}\]/)?.[0] ?? '[#hex]'
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'raw-tailwind-colors',
|
|
156
|
+
label: 'raw Tailwind color class',
|
|
157
|
+
fixHint: 'Replace with token classes (bg-background, text-foreground, border-border) or var(--tetra-*) tokens',
|
|
158
|
+
check(line) {
|
|
159
|
+
// Whitelist: transparent, current, inherit, and Tetra semantic tokens like text-primary-foreground
|
|
160
|
+
if (/\btext-primary-foreground\b/.test(line)) return null
|
|
161
|
+
|
|
162
|
+
const COLOR_NAMES = [
|
|
163
|
+
'white', 'black',
|
|
164
|
+
'gray', 'slate', 'zinc', 'neutral', 'stone',
|
|
165
|
+
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald',
|
|
166
|
+
'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple',
|
|
167
|
+
'fuchsia', 'pink', 'rose',
|
|
168
|
+
]
|
|
169
|
+
const colorRe = new RegExp(
|
|
170
|
+
`\\b(bg|text|border)-(${COLOR_NAMES.join('|')})-(\\d{2,3}|\\d{3}|\\d{2})\\b|\\b(bg|text|border)-(white|black)\\b`
|
|
171
|
+
)
|
|
172
|
+
// Must appear in className context
|
|
173
|
+
if (/className\s*=|class\s*=/.test(line) && colorRe.test(line)) {
|
|
174
|
+
const m = line.match(colorRe)
|
|
175
|
+
return m?.[0] ?? 'raw color class'
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'custom-globals-override',
|
|
182
|
+
label: 'custom CSS color system overriding Tetra tokens',
|
|
183
|
+
fixHint: 'Set theme via AppShell theme prop; only override tokens in :root { --tetra-accent-subtle: ... } style',
|
|
184
|
+
cssOnly: true,
|
|
185
|
+
check(line, ctx) {
|
|
186
|
+
// Flag when defining core color variables outside the expected Tetra override pattern
|
|
187
|
+
const tetraVars = /--tetra-(accent|bg|text|border|bg-subtle)|--background\b|--foreground\b|--primary\b|--card\b/
|
|
188
|
+
if (tetraVars.test(line) && /:\s/.test(line)) {
|
|
189
|
+
// Allow if inside :root or html.dark (checked by context flag)
|
|
190
|
+
if (ctx && ctx.insideRootBlock) return null
|
|
191
|
+
return line.match(tetraVars)?.[0] ?? '--tetra-*'
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: 'custom-card-classes',
|
|
198
|
+
label: 'hand-rolled card pattern',
|
|
199
|
+
fixHint: 'Use AutoCard or Card from @soulbatical/tetra-ui instead',
|
|
200
|
+
check(line) {
|
|
201
|
+
// rounded-xl border bg-card shadow or rounded-lg border border-gray-*
|
|
202
|
+
if (/className\s*=/.test(line)) {
|
|
203
|
+
if (/\brounded-(xl|lg|2xl)\b/.test(line) && /\bborder\b/.test(line) && /\b(shadow|bg-card|bg-white)\b/.test(line)) {
|
|
204
|
+
return 'rounded-* border bg-card/bg-white shadow'
|
|
205
|
+
}
|
|
206
|
+
if (/\brounded-(xl|lg)\b/.test(line) && /\bborder\b/.test(line) && /border-(gray|slate|zinc|neutral)-\d+/.test(line)) {
|
|
207
|
+
return 'rounded-* border border-gray-*'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
/** WARNING violations are reported but don't block CI by default. */
|
|
216
|
+
const WARNING_RULES = [
|
|
217
|
+
{
|
|
218
|
+
id: 'inline-style-attr',
|
|
219
|
+
label: 'inline style attribute',
|
|
220
|
+
fixHint: 'Prefer className; if needed use var(--tetra-*) tokens in style props',
|
|
221
|
+
check(line) {
|
|
222
|
+
if (/style\s*=\s*\{\{/.test(line)) {
|
|
223
|
+
// Allow var(--tetra-*) usage
|
|
224
|
+
if (/var\(--tetra-/.test(line)) return null
|
|
225
|
+
return 'style={{ ... }}'
|
|
226
|
+
}
|
|
227
|
+
return null
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'hex-in-css',
|
|
232
|
+
label: 'hex color in CSS outside :root/html.dark',
|
|
233
|
+
fixHint: 'Move hex values into :root or html.dark CSS token blocks',
|
|
234
|
+
cssOnly: true,
|
|
235
|
+
check(line, ctx) {
|
|
236
|
+
if (ctx && ctx.insideRootBlock) return null
|
|
237
|
+
if (/#[0-9a-fA-F]{3,8}\b/.test(line) && /:\s/.test(line)) {
|
|
238
|
+
return line.match(/#[0-9a-fA-F]{3,8}\b/)?.[0] ?? '#hex'
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'duplicate-page-header',
|
|
245
|
+
label: 'custom page header (use PageHeader from tetra-ui)',
|
|
246
|
+
fixHint: 'Import PageHeader from @soulbatical/tetra-ui',
|
|
247
|
+
check(line) {
|
|
248
|
+
if (/<h1\b[^>]*className\s*=[^>]*\btext-(2xl|3xl|4xl)\b[^>]*\bfont-bold\b/.test(line) ||
|
|
249
|
+
/<h1\b[^>]*className\s*=[^>]*\bfont-bold\b[^>]*\btext-(2xl|3xl|4xl)\b/.test(line)) {
|
|
250
|
+
return '<h1 className="text-2xl font-bold ...">'
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: 'nested-padding-consider-component',
|
|
257
|
+
label: 'nested padding — consider extracting to a component',
|
|
258
|
+
fixHint: 'This padding is deep inside a page; consider extracting to a reusable component',
|
|
259
|
+
strictOnly: true,
|
|
260
|
+
check(line, ctx, pageCtx) {
|
|
261
|
+
// Only fires on page files, for lines BEYOND the first 30 lines after return
|
|
262
|
+
if (!pageCtx || pageCtx.returnLineIndex < 0) return null
|
|
263
|
+
if (pageCtx.lineIndex <= pageCtx.returnLineIndex + 30) return null
|
|
264
|
+
// Skip inline/form elements — their padding is always legitimate
|
|
265
|
+
if (lineHasInlineElementTag(line)) return null
|
|
266
|
+
if (/className\s*=/.test(line) && /\b(p|px|py|pt|pb|pl|pr)-\d+\b/.test(line)) {
|
|
267
|
+
return line.match(/\b(p|px|py|pt|pb|pl|pr)-\d+\b/)?.[0] ?? 'p-*'
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// HELPERS
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
function isRelaxedPath(filePath) {
|
|
279
|
+
return RELAXED_PATTERNS.some((p) => p.test(filePath))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isSkippedPath(filePath) {
|
|
283
|
+
return SKIP_PATTERNS.some((p) => p.test(filePath))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isPageFile(filePath) {
|
|
287
|
+
return /app\/.*\/(page|layout)\.(tsx|jsx)$/.test(filePath)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isCssFile(filePath) {
|
|
291
|
+
return /\.(css)$/.test(filePath)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Load extra whitelist entries from .tetra-style-whitelist.json in project root.
|
|
296
|
+
*/
|
|
297
|
+
function loadProjectWhitelist(projectRoot) {
|
|
298
|
+
const path = join(projectRoot, '.tetra-style-whitelist.json')
|
|
299
|
+
if (!existsSync(path)) return []
|
|
300
|
+
try {
|
|
301
|
+
const raw = readFileSync(path, 'utf-8')
|
|
302
|
+
return JSON.parse(raw).styleWhitelist || []
|
|
303
|
+
} catch {
|
|
304
|
+
return []
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isWhitelisted(filePath, projectWhitelist) {
|
|
309
|
+
for (const entry of projectWhitelist) {
|
|
310
|
+
const prefix = entry.path.replace(/\/\*\*$/, '').replace(/\/\*$/, '')
|
|
311
|
+
if (filePath.includes(prefix)) return true
|
|
312
|
+
}
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ============================================================================
|
|
317
|
+
// FILE SCANNER
|
|
318
|
+
// ============================================================================
|
|
319
|
+
|
|
320
|
+
async function scanFiles(projectRoot, filesGlob) {
|
|
321
|
+
if (filesGlob) {
|
|
322
|
+
return await glob(filesGlob, { cwd: projectRoot, ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'] })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const patterns = [
|
|
326
|
+
'frontend/src/**/*.tsx',
|
|
327
|
+
'frontend/src/**/*.jsx',
|
|
328
|
+
'frontend/src/**/*.ts',
|
|
329
|
+
'frontend/src/**/*.js',
|
|
330
|
+
'frontend/src/**/*.css',
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
const files = []
|
|
334
|
+
for (const pattern of patterns) {
|
|
335
|
+
const found = await glob(pattern, {
|
|
336
|
+
cwd: projectRoot,
|
|
337
|
+
ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'],
|
|
338
|
+
})
|
|
339
|
+
files.push(...found)
|
|
340
|
+
}
|
|
341
|
+
return [...new Set(files)]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// LINE-BY-LINE AUDIT
|
|
346
|
+
// ============================================================================
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Track :root / html.dark block context for CSS files.
|
|
350
|
+
*/
|
|
351
|
+
function buildCssContext(lines, lineIndex) {
|
|
352
|
+
// Walk backwards to find if we're inside a :root or html.dark block
|
|
353
|
+
let braceDepth = 0
|
|
354
|
+
let insideRootBlock = false
|
|
355
|
+
for (let i = lineIndex; i >= 0; i--) {
|
|
356
|
+
const l = lines[i]
|
|
357
|
+
for (const ch of [...l].reverse()) {
|
|
358
|
+
if (ch === '}') braceDepth++
|
|
359
|
+
if (ch === '{') {
|
|
360
|
+
if (braceDepth === 0) {
|
|
361
|
+
// This opening brace owns us — check selector
|
|
362
|
+
if (/:root\b|html\.dark\b/.test(lines[i])) {
|
|
363
|
+
insideRootBlock = true
|
|
364
|
+
}
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
braceDepth--
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (braceDepth === 0 && insideRootBlock !== undefined) break
|
|
371
|
+
}
|
|
372
|
+
return { insideRootBlock }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Audit the content of a single file. Returns array of findings.
|
|
377
|
+
* Each finding: { id, label, severity, line, lineNumber, match, fixHint }
|
|
378
|
+
*
|
|
379
|
+
* @param {string} content
|
|
380
|
+
* @param {string} filePath
|
|
381
|
+
* @param {boolean} relaxed
|
|
382
|
+
* @param {{ strict?: boolean }} options
|
|
383
|
+
*/
|
|
384
|
+
function auditFileContent(content, filePath, relaxed, options = {}) {
|
|
385
|
+
const findings = []
|
|
386
|
+
const lines = content.split('\n')
|
|
387
|
+
const css = isCssFile(filePath)
|
|
388
|
+
const page = isPageFile(filePath)
|
|
389
|
+
|
|
390
|
+
// Find where the default export's return statement is (for page-root scoping)
|
|
391
|
+
const returnLineIndex = page ? findReturnLineIndex(lines) : -1
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < lines.length; i++) {
|
|
394
|
+
const line = lines[i]
|
|
395
|
+
const lineNumber = i + 1
|
|
396
|
+
|
|
397
|
+
// Disable comment check
|
|
398
|
+
if (i > 0 && /tetra-style-audit-disable-next-line/.test(lines[i - 1])) continue
|
|
399
|
+
if (/tetra-style-audit-disable-next-line/.test(line)) continue
|
|
400
|
+
|
|
401
|
+
const ctx = css ? buildCssContext(lines, i) : null
|
|
402
|
+
const pageCtx = page ? { returnLineIndex, lineIndex: i } : null
|
|
403
|
+
|
|
404
|
+
// Determine which rules apply
|
|
405
|
+
const criticalRules = CRITICAL_RULES.filter((r) => {
|
|
406
|
+
if (r.cssOnly && !css) return false
|
|
407
|
+
if (!r.cssOnly && css) return false
|
|
408
|
+
if (r.pageOnly && !page) return false
|
|
409
|
+
if (relaxed && r.pageOnly) return false // skip layout rules for relaxed paths
|
|
410
|
+
return true
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const warningRules = WARNING_RULES.filter((r) => {
|
|
414
|
+
if (r.cssOnly && !css) return false
|
|
415
|
+
if (!r.cssOnly && css) return false
|
|
416
|
+
if (relaxed) return false // skip warnings for relaxed paths
|
|
417
|
+
if (r.strictOnly && !options.strict) return false
|
|
418
|
+
// nested-padding-consider-component only applies to page files
|
|
419
|
+
if (r.id === 'nested-padding-consider-component' && !page) return false
|
|
420
|
+
return true
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
for (const rule of criticalRules) {
|
|
424
|
+
const match = rule.check(line, ctx, pageCtx)
|
|
425
|
+
if (match) {
|
|
426
|
+
findings.push({
|
|
427
|
+
id: rule.id,
|
|
428
|
+
label: rule.label,
|
|
429
|
+
severity: 'critical',
|
|
430
|
+
line: line.trim(),
|
|
431
|
+
lineNumber,
|
|
432
|
+
match,
|
|
433
|
+
fixHint: rule.fixHint,
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const rule of warningRules) {
|
|
439
|
+
const match = rule.check(line, ctx, pageCtx)
|
|
440
|
+
if (match) {
|
|
441
|
+
findings.push({
|
|
442
|
+
id: rule.id,
|
|
443
|
+
label: rule.label,
|
|
444
|
+
severity: 'warning',
|
|
445
|
+
line: line.trim(),
|
|
446
|
+
lineNumber,
|
|
447
|
+
match,
|
|
448
|
+
fixHint: rule.fixHint,
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return findings
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ============================================================================
|
|
458
|
+
// MAIN AUDIT
|
|
459
|
+
// ============================================================================
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Run the full style compliance audit.
|
|
463
|
+
*
|
|
464
|
+
* @param {string} projectRoot
|
|
465
|
+
* @param {{ filesGlob?: string, strict?: boolean }} options
|
|
466
|
+
* @returns {Promise<{ results: FileResult[], summary: Summary }>}
|
|
467
|
+
*/
|
|
468
|
+
export async function runStyleComplianceAudit(projectRoot, options = {}) {
|
|
469
|
+
const projectWhitelist = loadProjectWhitelist(projectRoot)
|
|
470
|
+
const allFiles = await scanFiles(projectRoot, options.filesGlob)
|
|
471
|
+
|
|
472
|
+
const results = []
|
|
473
|
+
|
|
474
|
+
for (const filePath of allFiles.sort()) {
|
|
475
|
+
if (isSkippedPath(filePath) || isWhitelisted(filePath, projectWhitelist)) {
|
|
476
|
+
results.push({
|
|
477
|
+
file: filePath,
|
|
478
|
+
status: 'skipped',
|
|
479
|
+
reason: isSkippedPath(filePath) ? 'marketing/public path' : 'whitelisted',
|
|
480
|
+
findings: [],
|
|
481
|
+
})
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const relaxed = isRelaxedPath(filePath)
|
|
486
|
+
const fullPath = join(projectRoot, filePath)
|
|
487
|
+
let content = ''
|
|
488
|
+
try {
|
|
489
|
+
content = readFileSync(fullPath, 'utf-8')
|
|
490
|
+
} catch {
|
|
491
|
+
results.push({ file: filePath, status: 'error', reason: 'could not read file', findings: [] })
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const findings = auditFileContent(content, filePath, relaxed, options)
|
|
496
|
+
const criticalCount = findings.filter((f) => f.severity === 'critical').length
|
|
497
|
+
const warningCount = findings.filter((f) => f.severity === 'warning').length
|
|
498
|
+
|
|
499
|
+
let status
|
|
500
|
+
if (criticalCount > 0) {
|
|
501
|
+
status = 'violation'
|
|
502
|
+
} else if (warningCount > 0) {
|
|
503
|
+
status = 'warning'
|
|
504
|
+
} else {
|
|
505
|
+
status = 'clean'
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
results.push({ file: filePath, status, reason: null, findings })
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Summary
|
|
512
|
+
const scanned = results.filter((r) => r.status !== 'skipped' && r.status !== 'error')
|
|
513
|
+
const totalCritical = scanned.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'critical').length, 0)
|
|
514
|
+
const totalWarnings = scanned.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'warning').length, 0)
|
|
515
|
+
const filesWithViolations = scanned.filter((r) => r.status === 'violation').length
|
|
516
|
+
const filesWithWarnings = scanned.filter((r) => r.status === 'warning').length
|
|
517
|
+
const cleanFiles = scanned.filter((r) => r.status === 'clean').length
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
results,
|
|
521
|
+
summary: {
|
|
522
|
+
filesScanned: scanned.length,
|
|
523
|
+
filesSkipped: results.filter((r) => r.status === 'skipped').length,
|
|
524
|
+
filesWithViolations,
|
|
525
|
+
filesWithWarnings,
|
|
526
|
+
cleanFiles,
|
|
527
|
+
totalCritical,
|
|
528
|
+
totalWarnings,
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ============================================================================
|
|
534
|
+
// FORMATTERS
|
|
535
|
+
// ============================================================================
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Format the report as pretty terminal output.
|
|
539
|
+
*
|
|
540
|
+
* @param {object} report
|
|
541
|
+
* @param {object} chalk
|
|
542
|
+
* @param {{ fixHint?: boolean }} options
|
|
543
|
+
*/
|
|
544
|
+
export function formatReport(report, chalk, options = {}) {
|
|
545
|
+
const lines = []
|
|
546
|
+
const { results, summary } = report
|
|
547
|
+
|
|
548
|
+
lines.push('')
|
|
549
|
+
lines.push(chalk.blue.bold(' Style Compliance Audit'))
|
|
550
|
+
lines.push(chalk.blue(' ' + '═'.repeat(24)))
|
|
551
|
+
lines.push('')
|
|
552
|
+
|
|
553
|
+
// Only print files that have findings
|
|
554
|
+
const filesWithFindings = results.filter(
|
|
555
|
+
(r) => r.status === 'violation' || r.status === 'warning'
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if (filesWithFindings.length === 0) {
|
|
559
|
+
lines.push(chalk.green(' No style violations found.'))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
for (const r of filesWithFindings) {
|
|
563
|
+
lines.push(chalk.white.bold(` ${r.file}`))
|
|
564
|
+
|
|
565
|
+
for (const f of r.findings) {
|
|
566
|
+
const icon = f.severity === 'critical' ? chalk.red(' ✗') : chalk.yellow(' ⚠')
|
|
567
|
+
const idStr = f.severity === 'critical'
|
|
568
|
+
? chalk.red(f.id)
|
|
569
|
+
: chalk.yellow(f.id)
|
|
570
|
+
lines.push(`${icon} ${idStr} ${chalk.gray(`(line ${f.lineNumber})`)}`)
|
|
571
|
+
lines.push(chalk.gray(` ${f.match}`))
|
|
572
|
+
if (options.fixHint && f.fixHint) {
|
|
573
|
+
lines.push(chalk.cyan(` → ${f.fixHint}`))
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
lines.push('')
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
lines.push(chalk.gray(' ' + '─'.repeat(39)))
|
|
581
|
+
lines.push(
|
|
582
|
+
chalk.white(
|
|
583
|
+
` ${summary.filesScanned} files scanned · ` +
|
|
584
|
+
chalk.red.bold(`${summary.totalCritical} critical`) +
|
|
585
|
+
` · ` +
|
|
586
|
+
chalk.yellow(`${summary.totalWarnings} warnings`)
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
lines.push('')
|
|
590
|
+
|
|
591
|
+
if (summary.totalCritical > 0) {
|
|
592
|
+
lines.push(
|
|
593
|
+
chalk.red.bold(
|
|
594
|
+
` ✗ ${summary.totalCritical} critical violation${summary.totalCritical > 1 ? 's' : ''} found. Fix before merging.`
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
lines.push('')
|
|
598
|
+
} else {
|
|
599
|
+
lines.push(chalk.green.bold(' All files pass critical style checks.'))
|
|
600
|
+
lines.push('')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return lines.join('\n')
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Format the report as JSON.
|
|
608
|
+
*/
|
|
609
|
+
export function formatReportJSON(report) {
|
|
610
|
+
return JSON.stringify(report, null, 2)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Output GitHub Actions annotations for violations.
|
|
615
|
+
*/
|
|
616
|
+
export function formatCIAnnotations(report) {
|
|
617
|
+
const annotations = []
|
|
618
|
+
|
|
619
|
+
for (const r of report.results) {
|
|
620
|
+
for (const f of r.findings) {
|
|
621
|
+
const level = f.severity === 'critical' ? 'error' : 'warning'
|
|
622
|
+
annotations.push(
|
|
623
|
+
`::${level} file=${r.file},line=${f.lineNumber},title=Style Compliance ${f.id}::${f.match} — ${f.fixHint || f.label}`
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return annotations.join('\n')
|
|
629
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.24",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"tetra-test-audit": "./bin/tetra-test-audit.js",
|
|
44
44
|
"tetra-check-pages": "./bin/tetra-check-pages.js",
|
|
45
45
|
"tetra-check-views": "./bin/tetra-check-views.js",
|
|
46
|
-
"tetra-doctor": "./bin/tetra-doctor.js"
|
|
46
|
+
"tetra-doctor": "./bin/tetra-doctor.js",
|
|
47
|
+
"tetra-style-audit": "./bin/tetra-style-audit.js"
|
|
47
48
|
},
|
|
48
49
|
"files": [
|
|
49
50
|
"bin/",
|