@soulbatical/tetra-dev-toolkit 1.20.23 → 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.
|
@@ -29,6 +29,58 @@ const SKIP_PATTERNS = [
|
|
|
29
29
|
/\/landing\//,
|
|
30
30
|
]
|
|
31
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
|
+
|
|
32
84
|
// ============================================================================
|
|
33
85
|
// RULE DEFINITIONS
|
|
34
86
|
// ============================================================================
|
|
@@ -40,8 +92,19 @@ const CRITICAL_RULES = [
|
|
|
40
92
|
label: 'hardcoded page width',
|
|
41
93
|
fixHint: 'Use AppShell config.layout.pageMaxWidth or fullWidthPaths instead',
|
|
42
94
|
pageOnly: true,
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
|
|
45
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)) {
|
|
46
109
|
return line.match(/\bmax-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-\S+)\b/)?.[0] ?? 'max-w-*'
|
|
47
110
|
}
|
|
@@ -57,7 +120,14 @@ const CRITICAL_RULES = [
|
|
|
57
120
|
label: 'hardcoded page padding',
|
|
58
121
|
fixHint: 'Use AppShell config.layout.pagePadding preset instead',
|
|
59
122
|
pageOnly: true,
|
|
60
|
-
check(line) {
|
|
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
|
+
|
|
61
131
|
if (/className\s*=/.test(line) && /\b(p|px|py|pt|pb|pl|pr)-\d+\b/.test(line)) {
|
|
62
132
|
return line.match(/\b(p|px|py|pt|pb|pl|pr)-\d+\b/)?.[0] ?? 'p-*'
|
|
63
133
|
}
|
|
@@ -86,7 +156,9 @@ const CRITICAL_RULES = [
|
|
|
86
156
|
label: 'raw Tailwind color class',
|
|
87
157
|
fixHint: 'Replace with token classes (bg-background, text-foreground, border-border) or var(--tetra-*) tokens',
|
|
88
158
|
check(line) {
|
|
89
|
-
// Whitelist: transparent, current, inherit
|
|
159
|
+
// Whitelist: transparent, current, inherit, and Tetra semantic tokens like text-primary-foreground
|
|
160
|
+
if (/\btext-primary-foreground\b/.test(line)) return null
|
|
161
|
+
|
|
90
162
|
const COLOR_NAMES = [
|
|
91
163
|
'white', 'black',
|
|
92
164
|
'gray', 'slate', 'zinc', 'neutral', 'stone',
|
|
@@ -180,6 +252,23 @@ const WARNING_RULES = [
|
|
|
180
252
|
return null
|
|
181
253
|
},
|
|
182
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
|
+
},
|
|
183
272
|
]
|
|
184
273
|
|
|
185
274
|
// ============================================================================
|
|
@@ -286,13 +375,21 @@ function buildCssContext(lines, lineIndex) {
|
|
|
286
375
|
/**
|
|
287
376
|
* Audit the content of a single file. Returns array of findings.
|
|
288
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
|
|
289
383
|
*/
|
|
290
|
-
function auditFileContent(content, filePath, relaxed) {
|
|
384
|
+
function auditFileContent(content, filePath, relaxed, options = {}) {
|
|
291
385
|
const findings = []
|
|
292
386
|
const lines = content.split('\n')
|
|
293
387
|
const css = isCssFile(filePath)
|
|
294
388
|
const page = isPageFile(filePath)
|
|
295
389
|
|
|
390
|
+
// Find where the default export's return statement is (for page-root scoping)
|
|
391
|
+
const returnLineIndex = page ? findReturnLineIndex(lines) : -1
|
|
392
|
+
|
|
296
393
|
for (let i = 0; i < lines.length; i++) {
|
|
297
394
|
const line = lines[i]
|
|
298
395
|
const lineNumber = i + 1
|
|
@@ -302,6 +399,7 @@ function auditFileContent(content, filePath, relaxed) {
|
|
|
302
399
|
if (/tetra-style-audit-disable-next-line/.test(line)) continue
|
|
303
400
|
|
|
304
401
|
const ctx = css ? buildCssContext(lines, i) : null
|
|
402
|
+
const pageCtx = page ? { returnLineIndex, lineIndex: i } : null
|
|
305
403
|
|
|
306
404
|
// Determine which rules apply
|
|
307
405
|
const criticalRules = CRITICAL_RULES.filter((r) => {
|
|
@@ -316,11 +414,14 @@ function auditFileContent(content, filePath, relaxed) {
|
|
|
316
414
|
if (r.cssOnly && !css) return false
|
|
317
415
|
if (!r.cssOnly && css) return false
|
|
318
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
|
|
319
420
|
return true
|
|
320
421
|
})
|
|
321
422
|
|
|
322
423
|
for (const rule of criticalRules) {
|
|
323
|
-
const match = rule.check(line, ctx)
|
|
424
|
+
const match = rule.check(line, ctx, pageCtx)
|
|
324
425
|
if (match) {
|
|
325
426
|
findings.push({
|
|
326
427
|
id: rule.id,
|
|
@@ -335,7 +436,7 @@ function auditFileContent(content, filePath, relaxed) {
|
|
|
335
436
|
}
|
|
336
437
|
|
|
337
438
|
for (const rule of warningRules) {
|
|
338
|
-
const match = rule.check(line, ctx)
|
|
439
|
+
const match = rule.check(line, ctx, pageCtx)
|
|
339
440
|
if (match) {
|
|
340
441
|
findings.push({
|
|
341
442
|
id: rule.id,
|
|
@@ -361,7 +462,7 @@ function auditFileContent(content, filePath, relaxed) {
|
|
|
361
462
|
* Run the full style compliance audit.
|
|
362
463
|
*
|
|
363
464
|
* @param {string} projectRoot
|
|
364
|
-
* @param {{ filesGlob?: string }} options
|
|
465
|
+
* @param {{ filesGlob?: string, strict?: boolean }} options
|
|
365
466
|
* @returns {Promise<{ results: FileResult[], summary: Summary }>}
|
|
366
467
|
*/
|
|
367
468
|
export async function runStyleComplianceAudit(projectRoot, options = {}) {
|
|
@@ -391,7 +492,7 @@ export async function runStyleComplianceAudit(projectRoot, options = {}) {
|
|
|
391
492
|
continue
|
|
392
493
|
}
|
|
393
494
|
|
|
394
|
-
const findings = auditFileContent(content, filePath, relaxed)
|
|
495
|
+
const findings = auditFileContent(content, filePath, relaxed, options)
|
|
395
496
|
const criticalCount = findings.filter((f) => f.severity === 'critical').length
|
|
396
497
|
const warningCount = findings.filter((f) => f.severity === 'warning').length
|
|
397
498
|
|