@soulbatical/tetra-dev-toolkit 1.20.14 → 1.20.16
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.
- package/bin/tetra-check-pages.js +83 -0
- package/bin/tetra-check-views.js +80 -0
- package/lib/audits/page-compliance-audit.js +406 -0
- package/lib/audits/view-config-audit.js +289 -0
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/health/shadcn-ui-tokens.js +240 -0
- package/lib/checks/health/types.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Page Compliance — enforce FeaturePage usage across frontend pages.
|
|
5
|
+
*
|
|
6
|
+
* Scans all Next.js / Vite page files and checks whether they use
|
|
7
|
+
* the FeaturePage component from tetra-ui, or contain ad-hoc patterns
|
|
8
|
+
* that violate the Tetra page standard.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* tetra-check-pages # Audit current project
|
|
12
|
+
* tetra-check-pages --path /path/to/project # Audit specific project
|
|
13
|
+
* tetra-check-pages --json # JSON output for CI
|
|
14
|
+
* tetra-check-pages --ci # GitHub Actions annotations
|
|
15
|
+
* tetra-check-pages --strict # Fail on any violation (incl. warnings)
|
|
16
|
+
* tetra-check-pages --fix-hint # Show suggested fix for each violation
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 = all critical checks pass
|
|
20
|
+
* 1 = violations found
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { program } from 'commander'
|
|
24
|
+
import chalk from 'chalk'
|
|
25
|
+
import {
|
|
26
|
+
runPageComplianceAudit,
|
|
27
|
+
formatReport,
|
|
28
|
+
formatReportJSON,
|
|
29
|
+
formatCIAnnotations,
|
|
30
|
+
} from '../lib/audits/page-compliance-audit.js'
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.name('tetra-check-pages')
|
|
34
|
+
.description('Enforce FeaturePage usage across frontend pages')
|
|
35
|
+
.version('1.0.0')
|
|
36
|
+
.option('--path <dir>', 'Project root directory (default: cwd)')
|
|
37
|
+
.option('--json', 'JSON output for CI')
|
|
38
|
+
.option('--ci', 'GitHub Actions annotations for failures')
|
|
39
|
+
.option('--strict', 'Fail on any violation including warnings (default: only critical violations)')
|
|
40
|
+
.option('--fix-hint', 'Show suggested fix for each violation')
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
try {
|
|
43
|
+
const projectRoot = options.path || process.cwd()
|
|
44
|
+
|
|
45
|
+
if (!options.json) {
|
|
46
|
+
console.log(chalk.gray('\n Scanning pages...'))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const report = await runPageComplianceAudit(projectRoot)
|
|
50
|
+
|
|
51
|
+
// Output
|
|
52
|
+
if (options.json) {
|
|
53
|
+
console.log(formatReportJSON(report))
|
|
54
|
+
} else {
|
|
55
|
+
console.log(formatReport(report, chalk, { fixHint: options.fixHint }))
|
|
56
|
+
|
|
57
|
+
if (options.ci) {
|
|
58
|
+
const annotations = formatCIAnnotations(report)
|
|
59
|
+
if (annotations) {
|
|
60
|
+
console.log(annotations)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Exit code
|
|
66
|
+
const { summary } = report
|
|
67
|
+
if (options.strict) {
|
|
68
|
+
// Strict: fail on any violation or warning
|
|
69
|
+
process.exit(summary.violations > 0 || summary.warningOnly > 0 ? 1 : 0)
|
|
70
|
+
} else {
|
|
71
|
+
// Default: only fail on critical violations
|
|
72
|
+
process.exit(summary.criticalViolations > 0 ? 1 : 0)
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
76
|
+
if (!options.json) {
|
|
77
|
+
console.error(chalk.gray(` ${err.stack}`))
|
|
78
|
+
}
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
program.parse()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra View Config — enforce view configuration in FeatureConfig files.
|
|
5
|
+
*
|
|
6
|
+
* Scans all backend FeatureConfig files and verifies they have the properties
|
|
7
|
+
* required for FeaturePage to work: views, restBasePath, and columns.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* tetra-check-views # Audit current project
|
|
11
|
+
* tetra-check-views --path /path/to/project # Audit specific project
|
|
12
|
+
* tetra-check-views --json # JSON output for CI
|
|
13
|
+
* tetra-check-views --ci # GitHub Actions annotations
|
|
14
|
+
* tetra-check-views --strict # Fail on any violation (incl. warnings)
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 = all critical checks pass
|
|
18
|
+
* 1 = violations found
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { program } from 'commander'
|
|
22
|
+
import chalk from 'chalk'
|
|
23
|
+
import {
|
|
24
|
+
runViewConfigAudit,
|
|
25
|
+
formatReport,
|
|
26
|
+
formatReportJSON,
|
|
27
|
+
formatCIAnnotations,
|
|
28
|
+
} from '../lib/audits/view-config-audit.js'
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.name('tetra-check-views')
|
|
32
|
+
.description('Enforce view configuration in FeatureConfig files')
|
|
33
|
+
.version('1.0.0')
|
|
34
|
+
.option('--path <dir>', 'Project root directory (default: cwd)')
|
|
35
|
+
.option('--json', 'JSON output for CI')
|
|
36
|
+
.option('--ci', 'GitHub Actions annotations for failures')
|
|
37
|
+
.option('--strict', 'Fail on any violation including warnings (default: only critical violations)')
|
|
38
|
+
.action(async (options) => {
|
|
39
|
+
try {
|
|
40
|
+
const projectRoot = options.path || process.cwd()
|
|
41
|
+
|
|
42
|
+
if (!options.json) {
|
|
43
|
+
console.log(chalk.gray('\n Scanning feature configs...'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const report = await runViewConfigAudit(projectRoot)
|
|
47
|
+
|
|
48
|
+
// Output
|
|
49
|
+
if (options.json) {
|
|
50
|
+
console.log(formatReportJSON(report))
|
|
51
|
+
} else {
|
|
52
|
+
console.log(formatReport(report, chalk))
|
|
53
|
+
|
|
54
|
+
if (options.ci) {
|
|
55
|
+
const annotations = formatCIAnnotations(report)
|
|
56
|
+
if (annotations) {
|
|
57
|
+
console.log(annotations)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Exit code
|
|
63
|
+
const { summary } = report
|
|
64
|
+
if (options.strict) {
|
|
65
|
+
// Strict: fail on any violation or warning
|
|
66
|
+
process.exit(summary.violations > 0 || summary.warningOnly > 0 ? 1 : 0)
|
|
67
|
+
} else {
|
|
68
|
+
// Default: only fail on critical violations
|
|
69
|
+
process.exit(summary.criticalViolations > 0 ? 1 : 0)
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
73
|
+
if (!options.json) {
|
|
74
|
+
console.error(chalk.gray(` ${err.stack}`))
|
|
75
|
+
}
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
program.parse()
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Compliance Audit — scans a Tetra project to enforce FeaturePage usage.
|
|
3
|
+
*
|
|
4
|
+
* Finds all Next.js / Vite frontend page files and checks whether they use
|
|
5
|
+
* the FeaturePage component from tetra-ui, or have ad-hoc patterns that
|
|
6
|
+
* should be replaced.
|
|
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
|
+
/** Pages that are always whitelisted — they legitimately don't use FeaturePage. */
|
|
18
|
+
const ALWAYS_WHITELISTED = [
|
|
19
|
+
{ pattern: /^frontend\/src\/app\/.*\(auth\)/, reason: 'auth flow' },
|
|
20
|
+
{ pattern: /^frontend\/src\/app\/.*auth\//, reason: 'auth flow' },
|
|
21
|
+
{ pattern: /^frontend\/src\/app\/.*invite\//, reason: 'invite flow' },
|
|
22
|
+
{ pattern: /^frontend\/src\/app\/.*onboarding\//, reason: 'onboarding flow' },
|
|
23
|
+
// The root dashboard page is a summary/overview — not a list page
|
|
24
|
+
{ pattern: /^frontend\/src\/app\/(?:\([^)]+\/\))?page\.tsx$/, reason: 'main dashboard' },
|
|
25
|
+
{ pattern: /^frontend\/src\/app\/\([^)]+\)\/page\.tsx$/, reason: 'main dashboard' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** CRITICAL violations block CI. */
|
|
29
|
+
const CRITICAL_PATTERNS = [
|
|
30
|
+
{
|
|
31
|
+
id: 'useState-filter',
|
|
32
|
+
label: 'ad-hoc filter/search state (useState)',
|
|
33
|
+
regex: /useState[^(]*\([^)]*\)\s*(?:\/\/[^\n]*)?\n[^;]*(?:[Ff]ilter|[Ss]earch|[Ss]ort|[Qq]uery)/,
|
|
34
|
+
// Simpler alternative: catch useState calls where the state name contains filter/search/sort
|
|
35
|
+
regexAlt: /const\s*\[\s*\w*(?:[Ff]ilter|[Ss]earch|[Ss]ort)\w*\s*,/,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'manual-fetch',
|
|
39
|
+
label: 'manual API fetching',
|
|
40
|
+
regex: /apiFetch\s*\(|apiClient\s*\.\s*get\s*\(|fetch\s*\(\s*['"`][^'"`]*\/api\//,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'list-detail-layout',
|
|
44
|
+
label: 'direct ListDetailLayout import (use FeaturePage instead)',
|
|
45
|
+
regex: /import[^;]*ListDetailLayout[^;]*from/,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'hardcoded-layout',
|
|
49
|
+
label: 'hardcoded page shell (min-h-screen + p-8/p-6 wrapper)',
|
|
50
|
+
// Only flag when min-h-screen AND p-8/p-6 appear on the same className attribute —
|
|
51
|
+
// the classic hand-rolled page wrapper pattern.
|
|
52
|
+
regex: /className=["'][^"']*\bmin-h-screen\b[^"']*\bp-[68]\b[^"']*["']|className=["'][^"']*\bp-[68]\b[^"']*\bmin-h-screen\b[^"']*["']/,
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** WARNING violations are reported but don't block CI by default. */
|
|
57
|
+
const WARNING_PATTERNS = [
|
|
58
|
+
{
|
|
59
|
+
id: 'long-page',
|
|
60
|
+
label: 'page file > 50 lines',
|
|
61
|
+
check: (content) => content.split('\n').length > 50,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'missing-feature-page',
|
|
65
|
+
label: 'looks like a list page but missing FeaturePage import',
|
|
66
|
+
check: (content) => {
|
|
67
|
+
// Heuristic: file renders a list-like structure (map, table, grid) but no FeaturePage
|
|
68
|
+
const looksLikeList = /\.map\s*\(/.test(content) || /<table/i.test(content) || /grid-cols/i.test(content)
|
|
69
|
+
const hasFeaturePage = /FeaturePage|GeneratedPage/.test(content)
|
|
70
|
+
return looksLikeList && !hasFeaturePage
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// WHITELIST LOADER
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load extra whitelist entries from .tetra-whitelist.json in project root.
|
|
81
|
+
*/
|
|
82
|
+
function loadProjectWhitelist(projectRoot) {
|
|
83
|
+
const whitelistPath = join(projectRoot, '.tetra-whitelist.json')
|
|
84
|
+
if (!existsSync(whitelistPath)) return []
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const raw = readFileSync(whitelistPath, 'utf-8')
|
|
88
|
+
const parsed = JSON.parse(raw)
|
|
89
|
+
const entries = parsed.pageWhitelist || []
|
|
90
|
+
return entries.map((e) => ({
|
|
91
|
+
// Convert glob-style path to a simple string prefix check
|
|
92
|
+
pattern: e.path,
|
|
93
|
+
reason: e.reason || 'whitelisted',
|
|
94
|
+
}))
|
|
95
|
+
} catch {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check whether a file path matches any whitelist entry.
|
|
102
|
+
* Returns the matching entry or null.
|
|
103
|
+
*/
|
|
104
|
+
function matchWhitelist(filePath, always, project) {
|
|
105
|
+
for (const entry of always) {
|
|
106
|
+
if (entry.pattern instanceof RegExp && entry.pattern.test(filePath)) {
|
|
107
|
+
return entry
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const entry of project) {
|
|
111
|
+
// Simple glob: convert "video-studio/**" → prefix match
|
|
112
|
+
const prefix = entry.pattern.replace(/\/\*\*$/, '').replace(/\/\*$/, '')
|
|
113
|
+
if (filePath.includes(prefix)) {
|
|
114
|
+
return entry
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// PAGE SCANNER
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find all page files in the project.
|
|
126
|
+
*/
|
|
127
|
+
async function scanPageFiles(projectRoot) {
|
|
128
|
+
const patterns = [
|
|
129
|
+
'frontend/src/app/**/page.tsx',
|
|
130
|
+
'frontend/src/pages/**/*.tsx',
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
const files = []
|
|
134
|
+
for (const pattern of patterns) {
|
|
135
|
+
const found = await glob(pattern, { cwd: projectRoot })
|
|
136
|
+
files.push(...found)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [...new Set(files)]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Derive a human-readable page label from the file path.
|
|
144
|
+
* e.g. "frontend/src/app/(dashboard)/campaigns/page.tsx" → "campaigns/page.tsx"
|
|
145
|
+
*/
|
|
146
|
+
function pageLabel(filePath) {
|
|
147
|
+
// Strip common prefix
|
|
148
|
+
return filePath
|
|
149
|
+
.replace(/^frontend\/src\/app\//, '')
|
|
150
|
+
.replace(/^frontend\/src\/pages\//, '')
|
|
151
|
+
// Remove route groups like (dashboard)
|
|
152
|
+
.replace(/\([^)]+\)\//g, '')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// VIOLATION CHECKER
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Scan a single page file for violations.
|
|
161
|
+
* Returns { violations: [], warnings: [], isCompliant: bool }
|
|
162
|
+
*/
|
|
163
|
+
function auditPageContent(content) {
|
|
164
|
+
const violations = []
|
|
165
|
+
const warnings = []
|
|
166
|
+
|
|
167
|
+
// Check CRITICAL patterns
|
|
168
|
+
for (const p of CRITICAL_PATTERNS) {
|
|
169
|
+
let matched = false
|
|
170
|
+
if (p.regex && p.regex.test(content)) {
|
|
171
|
+
matched = true
|
|
172
|
+
} else if (p.regexAlt && p.regexAlt.test(content)) {
|
|
173
|
+
matched = true
|
|
174
|
+
}
|
|
175
|
+
if (matched) {
|
|
176
|
+
violations.push({ id: p.id, label: p.label, severity: 'critical' })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check WARNING patterns
|
|
181
|
+
for (const p of WARNING_PATTERNS) {
|
|
182
|
+
if (p.check(content)) {
|
|
183
|
+
warnings.push({ id: p.id, label: p.label, severity: 'warning' })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for good patterns
|
|
188
|
+
const hasFeaturePage = /import[^;]*FeaturePage[^;]*from[^;]*tetra-ui/.test(content) ||
|
|
189
|
+
/import[^;]*GeneratedPage/.test(content)
|
|
190
|
+
|
|
191
|
+
return { violations, warnings, hasFeaturePage }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a fix hint for a page with violations.
|
|
196
|
+
*/
|
|
197
|
+
function buildFixHint(label, violations) {
|
|
198
|
+
// Strip route suffix to get the feature name
|
|
199
|
+
const featureName = label
|
|
200
|
+
.replace(/\/page\.tsx$/, '')
|
|
201
|
+
.replace(/\.tsx$/, '')
|
|
202
|
+
.split('/')
|
|
203
|
+
.pop()
|
|
204
|
+
|
|
205
|
+
const ids = violations.map((v) => v.id)
|
|
206
|
+
|
|
207
|
+
if (ids.includes('list-detail-layout')) {
|
|
208
|
+
return `Replace with <FeaturePage featureName="${featureName}" views={[{type:'list-detail'}]} />`
|
|
209
|
+
}
|
|
210
|
+
return `Replace with <FeaturePage featureName="${featureName}" />`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// MAIN AUDIT
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run the full page compliance audit.
|
|
219
|
+
*/
|
|
220
|
+
export async function runPageComplianceAudit(projectRoot) {
|
|
221
|
+
const projectWhitelist = loadProjectWhitelist(projectRoot)
|
|
222
|
+
const pageFiles = await scanPageFiles(projectRoot)
|
|
223
|
+
|
|
224
|
+
const results = []
|
|
225
|
+
|
|
226
|
+
for (const filePath of pageFiles) {
|
|
227
|
+
const label = pageLabel(filePath)
|
|
228
|
+
const whitelistMatch = matchWhitelist(filePath, ALWAYS_WHITELISTED, projectWhitelist)
|
|
229
|
+
|
|
230
|
+
if (whitelistMatch) {
|
|
231
|
+
results.push({
|
|
232
|
+
file: filePath,
|
|
233
|
+
label,
|
|
234
|
+
status: 'whitelisted',
|
|
235
|
+
reason: whitelistMatch.reason,
|
|
236
|
+
violations: [],
|
|
237
|
+
warnings: [],
|
|
238
|
+
hasFeaturePage: false,
|
|
239
|
+
fixHint: null,
|
|
240
|
+
})
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const fullPath = join(projectRoot, filePath)
|
|
245
|
+
let content = ''
|
|
246
|
+
try {
|
|
247
|
+
content = readFileSync(fullPath, 'utf-8')
|
|
248
|
+
} catch {
|
|
249
|
+
results.push({
|
|
250
|
+
file: filePath,
|
|
251
|
+
label,
|
|
252
|
+
status: 'error',
|
|
253
|
+
reason: 'could not read file',
|
|
254
|
+
violations: [],
|
|
255
|
+
warnings: [],
|
|
256
|
+
hasFeaturePage: false,
|
|
257
|
+
fixHint: null,
|
|
258
|
+
})
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { violations, warnings, hasFeaturePage } = auditPageContent(content)
|
|
263
|
+
|
|
264
|
+
let status
|
|
265
|
+
if (violations.length > 0) {
|
|
266
|
+
status = 'violation'
|
|
267
|
+
} else if (hasFeaturePage) {
|
|
268
|
+
status = 'compliant'
|
|
269
|
+
} else if (warnings.length > 0) {
|
|
270
|
+
status = 'warning'
|
|
271
|
+
} else {
|
|
272
|
+
// No violations, no FeaturePage, no warnings — treat as OK (may be a simple page)
|
|
273
|
+
status = 'compliant'
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const fixHint = violations.length > 0 ? buildFixHint(label, violations) : null
|
|
277
|
+
|
|
278
|
+
results.push({
|
|
279
|
+
file: filePath,
|
|
280
|
+
label,
|
|
281
|
+
status,
|
|
282
|
+
reason: null,
|
|
283
|
+
violations,
|
|
284
|
+
warnings,
|
|
285
|
+
hasFeaturePage,
|
|
286
|
+
fixHint,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Summary
|
|
291
|
+
const total = results.length
|
|
292
|
+
const whitelisted = results.filter((r) => r.status === 'whitelisted').length
|
|
293
|
+
const compliant = results.filter((r) => r.status === 'compliant').length
|
|
294
|
+
const warningOnly = results.filter((r) => r.status === 'warning').length
|
|
295
|
+
const violations = results.filter((r) => r.status === 'violation').length
|
|
296
|
+
const audited = total - whitelisted
|
|
297
|
+
const score = audited > 0 ? Math.round((compliant / audited) * 100) : 100
|
|
298
|
+
const criticalViolations = violations
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
results,
|
|
302
|
+
summary: {
|
|
303
|
+
total,
|
|
304
|
+
audited,
|
|
305
|
+
compliant,
|
|
306
|
+
warningOnly,
|
|
307
|
+
violations,
|
|
308
|
+
whitelisted,
|
|
309
|
+
score,
|
|
310
|
+
criticalViolations,
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// FORMATTERS
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Format the report as pretty terminal output.
|
|
321
|
+
*/
|
|
322
|
+
export function formatReport(report, chalk, options = {}) {
|
|
323
|
+
const lines = []
|
|
324
|
+
const { results, summary } = report
|
|
325
|
+
|
|
326
|
+
lines.push('')
|
|
327
|
+
lines.push(chalk.blue.bold(' Tetra Page Compliance Audit'))
|
|
328
|
+
lines.push(chalk.blue(' ' + '═'.repeat(27)))
|
|
329
|
+
lines.push('')
|
|
330
|
+
|
|
331
|
+
for (const r of results) {
|
|
332
|
+
if (r.status === 'whitelisted') {
|
|
333
|
+
lines.push(chalk.gray(` ⚠️ ${r.label.padEnd(40)} (whitelisted: ${r.reason})`))
|
|
334
|
+
} else if (r.status === 'compliant') {
|
|
335
|
+
lines.push(chalk.green(` ✅ ${r.label.padEnd(40)} FeaturePage ✓`))
|
|
336
|
+
} else if (r.status === 'warning') {
|
|
337
|
+
const warnLabels = r.warnings.map((w) => w.label).join('; ')
|
|
338
|
+
lines.push(chalk.yellow(` ⚠️ ${r.label.padEnd(40)} WARNING: ${warnLabels}`))
|
|
339
|
+
if (options.fixHint && r.fixHint) {
|
|
340
|
+
lines.push(chalk.yellow(` → Fix: ${r.fixHint}`))
|
|
341
|
+
}
|
|
342
|
+
} else if (r.status === 'violation') {
|
|
343
|
+
const violLabels = r.violations.map((v) => v.label).join('; ')
|
|
344
|
+
lines.push(chalk.red(` ❌ ${r.label.padEnd(40)} VIOLATION: ${violLabels}`))
|
|
345
|
+
if (options.fixHint && r.fixHint) {
|
|
346
|
+
lines.push(chalk.red(` → Fix: ${r.fixHint}`))
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
lines.push(chalk.gray(` ? ${r.label.padEnd(40)} (${r.reason || 'unknown'})`))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
lines.push('')
|
|
354
|
+
lines.push(chalk.gray(' ' + '─'.repeat(39)))
|
|
355
|
+
|
|
356
|
+
const scoreColor =
|
|
357
|
+
summary.score >= 80 ? chalk.green : summary.score >= 50 ? chalk.yellow : chalk.red
|
|
358
|
+
lines.push(scoreColor.bold(` Score: ${summary.score}% (${summary.compliant}/${summary.audited} compliant)`))
|
|
359
|
+
lines.push(
|
|
360
|
+
chalk.white(
|
|
361
|
+
` Pages: ${summary.total} total, ${summary.compliant} compliant, ${summary.violations} violations, ${summary.whitelisted} whitelisted`
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
lines.push('')
|
|
365
|
+
|
|
366
|
+
if (summary.criticalViolations > 0) {
|
|
367
|
+
lines.push(
|
|
368
|
+
chalk.red.bold(` ❌ ${summary.criticalViolations} critical violation${summary.criticalViolations > 1 ? 's' : ''} found. Fix before merging.`)
|
|
369
|
+
)
|
|
370
|
+
lines.push('')
|
|
371
|
+
} else if (summary.violations === 0) {
|
|
372
|
+
lines.push(chalk.green.bold(' All pages compliant.'))
|
|
373
|
+
lines.push('')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return lines.join('\n')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Format the report as JSON.
|
|
381
|
+
*/
|
|
382
|
+
export function formatReportJSON(report) {
|
|
383
|
+
return JSON.stringify(report, null, 2)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Output GitHub Actions annotations for violations.
|
|
388
|
+
*/
|
|
389
|
+
export function formatCIAnnotations(report) {
|
|
390
|
+
const annotations = []
|
|
391
|
+
|
|
392
|
+
for (const r of report.results) {
|
|
393
|
+
for (const v of r.violations) {
|
|
394
|
+
annotations.push(
|
|
395
|
+
`::error file=${r.file},title=Page Compliance Violation::${r.label}: ${v.label}`
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
for (const w of r.warnings) {
|
|
399
|
+
annotations.push(
|
|
400
|
+
`::warning file=${r.file},title=Page Compliance Warning::${r.label}: ${w.label}`
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return annotations.join('\n')
|
|
406
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View Config Audit — scans FeatureConfig files to enforce view configuration.
|
|
3
|
+
*
|
|
4
|
+
* Finds all backend FeatureConfig files and verifies they have the properties
|
|
5
|
+
* required for FeaturePage to work correctly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { glob } from 'glob'
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// SCANNERS
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan all FeatureConfig files and extract relevant properties.
|
|
18
|
+
*/
|
|
19
|
+
async function scanFeatureConfigs(projectRoot) {
|
|
20
|
+
const pattern = 'backend/src/features/**/config/*.config.ts'
|
|
21
|
+
const files = await glob(pattern, { cwd: projectRoot })
|
|
22
|
+
|
|
23
|
+
const configs = []
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const fullPath = join(projectRoot, file)
|
|
26
|
+
let content = ''
|
|
27
|
+
try {
|
|
28
|
+
content = readFileSync(fullPath, 'utf-8')
|
|
29
|
+
} catch {
|
|
30
|
+
configs.push({
|
|
31
|
+
file,
|
|
32
|
+
featureName: deriveFeatureName(file),
|
|
33
|
+
readError: true,
|
|
34
|
+
hasViews: false,
|
|
35
|
+
hasRestBasePath: false,
|
|
36
|
+
hasColumns: false,
|
|
37
|
+
views: [],
|
|
38
|
+
})
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const featureName = deriveFeatureName(file)
|
|
43
|
+
|
|
44
|
+
// Detect presence of views property
|
|
45
|
+
const hasViews = /\bviews\s*:/.test(content)
|
|
46
|
+
|
|
47
|
+
// Detect restBasePath
|
|
48
|
+
const hasRestBasePath = /\brestBasePath\s*:/.test(content)
|
|
49
|
+
|
|
50
|
+
// Detect columns
|
|
51
|
+
const hasColumns = /\bcolumns\s*:/.test(content)
|
|
52
|
+
|
|
53
|
+
// Extract view types listed
|
|
54
|
+
const views = extractViewTypes(content)
|
|
55
|
+
|
|
56
|
+
configs.push({
|
|
57
|
+
file,
|
|
58
|
+
featureName,
|
|
59
|
+
readError: false,
|
|
60
|
+
hasViews,
|
|
61
|
+
hasRestBasePath,
|
|
62
|
+
hasColumns,
|
|
63
|
+
views,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return configs
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Derive a short feature name from the file path.
|
|
72
|
+
* e.g. "backend/src/features/adcampaigns/config/adcampaigns.config.ts" → "adcampaigns"
|
|
73
|
+
*/
|
|
74
|
+
function deriveFeatureName(filePath) {
|
|
75
|
+
const parts = filePath.split('/')
|
|
76
|
+
// features/<featureName>/config/...
|
|
77
|
+
const featIdx = parts.indexOf('features')
|
|
78
|
+
if (featIdx !== -1 && parts[featIdx + 1]) {
|
|
79
|
+
return parts[featIdx + 1]
|
|
80
|
+
}
|
|
81
|
+
return parts[parts.length - 1].replace(/\.config\.ts$/, '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract view type strings from a config file content.
|
|
86
|
+
* Looks for patterns like type: 'list-detail' or type: "cards".
|
|
87
|
+
*/
|
|
88
|
+
function extractViewTypes(content) {
|
|
89
|
+
const types = []
|
|
90
|
+
const typeRegex = /type\s*:\s*['"]([^'"]+)['"]/g
|
|
91
|
+
let match
|
|
92
|
+
while ((match = typeRegex.exec(content)) !== null) {
|
|
93
|
+
// Only include known view types
|
|
94
|
+
const knownViewTypes = ['list-detail', 'cards', 'table', 'kanban', 'calendar', 'timeline']
|
|
95
|
+
if (knownViewTypes.includes(match[1])) {
|
|
96
|
+
types.push(match[1])
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...new Set(types)]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// VIOLATION CHECKER
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Determine violations for a single config.
|
|
108
|
+
*/
|
|
109
|
+
function auditConfig(config) {
|
|
110
|
+
const violations = []
|
|
111
|
+
const warnings = []
|
|
112
|
+
|
|
113
|
+
if (config.readError) {
|
|
114
|
+
violations.push({ id: 'read-error', label: 'could not read config file', severity: 'critical' })
|
|
115
|
+
return { violations, warnings }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// CRITICAL: missing views
|
|
119
|
+
if (!config.hasViews) {
|
|
120
|
+
violations.push({ id: 'missing-views', label: 'MISSING: views property', severity: 'critical' })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// CRITICAL: missing restBasePath
|
|
124
|
+
if (!config.hasRestBasePath) {
|
|
125
|
+
violations.push({ id: 'missing-rest-base-path', label: 'MISSING: restBasePath', severity: 'critical' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// WARNING: missing columns
|
|
129
|
+
if (!config.hasColumns) {
|
|
130
|
+
warnings.push({ id: 'missing-columns', label: 'no columns (table view and AutoDetail disabled)', severity: 'warning' })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// WARNING: list-detail or table view without columns
|
|
134
|
+
if (config.hasViews && !config.hasColumns) {
|
|
135
|
+
const needsColumns = config.views.some((v) => v === 'list-detail' || v === 'table')
|
|
136
|
+
if (needsColumns) {
|
|
137
|
+
warnings.push({
|
|
138
|
+
id: 'view-needs-columns',
|
|
139
|
+
label: `views include ${config.views.filter((v) => v === 'list-detail' || v === 'table').join('/')} but no columns defined`,
|
|
140
|
+
severity: 'warning',
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { violations, warnings }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// MAIN AUDIT
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run the full view config audit.
|
|
154
|
+
*/
|
|
155
|
+
export async function runViewConfigAudit(projectRoot) {
|
|
156
|
+
const configs = await scanFeatureConfigs(projectRoot)
|
|
157
|
+
|
|
158
|
+
const results = []
|
|
159
|
+
|
|
160
|
+
for (const config of configs) {
|
|
161
|
+
const { violations, warnings } = auditConfig(config)
|
|
162
|
+
|
|
163
|
+
let status
|
|
164
|
+
if (violations.length > 0) {
|
|
165
|
+
status = 'violation'
|
|
166
|
+
} else if (warnings.length > 0) {
|
|
167
|
+
status = 'warning'
|
|
168
|
+
} else {
|
|
169
|
+
status = 'ok'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
results.push({
|
|
173
|
+
file: config.file,
|
|
174
|
+
featureName: config.featureName,
|
|
175
|
+
status,
|
|
176
|
+
violations,
|
|
177
|
+
warnings,
|
|
178
|
+
views: config.views,
|
|
179
|
+
hasColumns: config.hasColumns,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Summary
|
|
184
|
+
const total = results.length
|
|
185
|
+
const ok = results.filter((r) => r.status === 'ok').length
|
|
186
|
+
const warningOnly = results.filter((r) => r.status === 'warning').length
|
|
187
|
+
const violations = results.filter((r) => r.status === 'violation').length
|
|
188
|
+
const configured = ok + warningOnly
|
|
189
|
+
const score = total > 0 ? Math.round((configured / total) * 100) : 100
|
|
190
|
+
const criticalViolations = violations
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
results,
|
|
194
|
+
summary: {
|
|
195
|
+
total,
|
|
196
|
+
ok,
|
|
197
|
+
warningOnly,
|
|
198
|
+
violations,
|
|
199
|
+
configured,
|
|
200
|
+
score,
|
|
201
|
+
criticalViolations,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// FORMATTERS
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format the report as pretty terminal output.
|
|
212
|
+
*/
|
|
213
|
+
export function formatReport(report, chalk) {
|
|
214
|
+
const lines = []
|
|
215
|
+
const { results, summary } = report
|
|
216
|
+
|
|
217
|
+
lines.push('')
|
|
218
|
+
lines.push(chalk.blue.bold(' Tetra View Config Audit'))
|
|
219
|
+
lines.push(chalk.blue(' ' + '═'.repeat(23)))
|
|
220
|
+
lines.push('')
|
|
221
|
+
|
|
222
|
+
for (const r of results) {
|
|
223
|
+
const name = r.featureName.padEnd(24)
|
|
224
|
+
|
|
225
|
+
if (r.status === 'ok') {
|
|
226
|
+
const viewList = r.views.length > 0 ? `views: [${r.views.join(', ')}]` : 'views: configured'
|
|
227
|
+
const colNote = r.hasColumns ? ' ✓' : ''
|
|
228
|
+
lines.push(chalk.green(` ✅ ${name} ${viewList}${colNote}`))
|
|
229
|
+
} else if (r.status === 'warning') {
|
|
230
|
+
const viewList = r.views.length > 0 ? `views: [${r.views.join(', ')}]` : 'views: configured'
|
|
231
|
+
const warnLabels = r.warnings.map((w) => w.label).join('; ')
|
|
232
|
+
lines.push(chalk.yellow(` ⚠️ ${name} ${viewList} — WARNING: ${warnLabels}`))
|
|
233
|
+
} else {
|
|
234
|
+
const violLabels = r.violations.map((v) => v.label).join(' + ')
|
|
235
|
+
lines.push(chalk.red(` ❌ ${name} ${violLabels}`))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
lines.push('')
|
|
240
|
+
lines.push(chalk.gray(' ' + '─'.repeat(39)))
|
|
241
|
+
|
|
242
|
+
const scoreColor =
|
|
243
|
+
summary.score >= 80 ? chalk.green : summary.score >= 50 ? chalk.yellow : chalk.red
|
|
244
|
+
lines.push(scoreColor.bold(` Score: ${summary.score}% (${summary.configured}/${summary.total} configured)`))
|
|
245
|
+
lines.push('')
|
|
246
|
+
|
|
247
|
+
if (summary.criticalViolations > 0) {
|
|
248
|
+
lines.push(
|
|
249
|
+
chalk.red.bold(
|
|
250
|
+
` ❌ ${summary.criticalViolations} feature${summary.criticalViolations > 1 ? 's' : ''} missing required config. Fix before merging.`
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
lines.push('')
|
|
254
|
+
} else if (summary.violations === 0) {
|
|
255
|
+
lines.push(chalk.green.bold(' All feature configs valid.'))
|
|
256
|
+
lines.push('')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return lines.join('\n')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Format the report as JSON.
|
|
264
|
+
*/
|
|
265
|
+
export function formatReportJSON(report) {
|
|
266
|
+
return JSON.stringify(report, null, 2)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Output GitHub Actions annotations for violations.
|
|
271
|
+
*/
|
|
272
|
+
export function formatCIAnnotations(report) {
|
|
273
|
+
const annotations = []
|
|
274
|
+
|
|
275
|
+
for (const r of report.results) {
|
|
276
|
+
for (const v of r.violations) {
|
|
277
|
+
annotations.push(
|
|
278
|
+
`::error file=${r.file},title=View Config Violation::${r.featureName}: ${v.label}`
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
for (const w of r.warnings) {
|
|
282
|
+
annotations.push(
|
|
283
|
+
`::warning file=${r.file},title=View Config Warning::${r.featureName}: ${w.label}`
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return annotations.join('\n')
|
|
289
|
+
}
|
|
@@ -42,3 +42,4 @@ export { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
|
42
42
|
export { check as checkReleasePipeline } from './release-pipeline.js'
|
|
43
43
|
export { check as checkTestStructure } from './test-structure.js'
|
|
44
44
|
export { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
45
|
+
export { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
|
|
@@ -38,6 +38,7 @@ import { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
|
38
38
|
import { check as checkReleasePipeline } from './release-pipeline.js'
|
|
39
39
|
import { check as checkTestStructure } from './test-structure.js'
|
|
40
40
|
import { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
41
|
+
import { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
|
|
41
42
|
import { calculateHealthStatus } from './types.js'
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -84,7 +85,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
84
85
|
checkSmokeReadiness(projectPath),
|
|
85
86
|
checkReleasePipeline(projectPath),
|
|
86
87
|
checkTestStructure(projectPath),
|
|
87
|
-
checkSentryMonitoring(projectPath)
|
|
88
|
+
checkSentryMonitoring(projectPath),
|
|
89
|
+
checkShadcnUiTokens(projectPath)
|
|
88
90
|
])
|
|
89
91
|
|
|
90
92
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: shadcn/ui Token Completeness
|
|
3
|
+
*
|
|
4
|
+
* Verifies that a frontend project defines all required shadcn/ui CSS custom properties.
|
|
5
|
+
* These are needed by tetra-ui components and any shadcn/ui primitives.
|
|
6
|
+
*
|
|
7
|
+
* Score: 5 (full) = all tokens defined, 3 = partial, 0 = missing critical tokens
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
11
|
+
import { join, resolve } from 'path'
|
|
12
|
+
import { createCheck } from './types.js'
|
|
13
|
+
|
|
14
|
+
// shadcn/ui required CSS tokens (Tailwind v4 @theme format: --color-*)
|
|
15
|
+
// These map to utility classes like bg-background, text-foreground, border-border, etc.
|
|
16
|
+
const CRITICAL_TOKENS = [
|
|
17
|
+
{ name: 'background', css: '--color-background', usage: 'bg-background' },
|
|
18
|
+
{ name: 'foreground', css: '--color-foreground', usage: 'text-foreground' },
|
|
19
|
+
{ name: 'border', css: '--color-border', usage: 'border-border' },
|
|
20
|
+
{ name: 'primary', css: '--color-primary', usage: 'bg-primary' },
|
|
21
|
+
{ name: 'primary-foreground', css: '--color-primary-foreground', usage: 'text-primary-foreground' },
|
|
22
|
+
{ name: 'accent', css: '--color-accent', usage: 'bg-accent' },
|
|
23
|
+
{ name: 'accent-foreground', css: '--color-accent-foreground', usage: 'text-accent-foreground' },
|
|
24
|
+
{ name: 'muted', css: '--color-muted', usage: 'bg-muted' },
|
|
25
|
+
{ name: 'muted-foreground', css: '--color-muted-foreground', usage: 'text-muted-foreground' },
|
|
26
|
+
{ name: 'destructive', css: '--color-destructive', usage: 'text-destructive, hover:text-destructive' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const RECOMMENDED_TOKENS = [
|
|
30
|
+
{ name: 'card', css: '--color-card', usage: 'bg-card' },
|
|
31
|
+
{ name: 'card-foreground', css: '--color-card-foreground', usage: 'text-card-foreground' },
|
|
32
|
+
{ name: 'popover', css: '--color-popover', usage: 'bg-popover' },
|
|
33
|
+
{ name: 'popover-foreground', css: '--color-popover-foreground', usage: 'text-popover-foreground' },
|
|
34
|
+
{ name: 'secondary', css: '--color-secondary', usage: 'bg-secondary' },
|
|
35
|
+
{ name: 'secondary-foreground', css: '--color-secondary-foreground', usage: 'text-secondary-foreground' },
|
|
36
|
+
{ name: 'input', css: '--color-input', usage: 'border-input' },
|
|
37
|
+
{ name: 'ring', css: '--color-ring', usage: 'ring-ring' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const DARK_MODE_INDICATORS = [
|
|
41
|
+
'.dark',
|
|
42
|
+
'dark:',
|
|
43
|
+
'@custom-variant dark',
|
|
44
|
+
'prefers-color-scheme: dark',
|
|
45
|
+
'data-theme="dark"',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find all CSS files in frontend/src
|
|
50
|
+
*/
|
|
51
|
+
function findCssFiles(projectPath) {
|
|
52
|
+
const frontendSrc = join(projectPath, 'frontend', 'src')
|
|
53
|
+
if (!existsSync(frontendSrc)) return []
|
|
54
|
+
|
|
55
|
+
const files = []
|
|
56
|
+
function walk(dir) {
|
|
57
|
+
try {
|
|
58
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
59
|
+
if (entry.name === 'node_modules') continue
|
|
60
|
+
const full = join(dir, entry.name)
|
|
61
|
+
if (entry.isDirectory()) walk(full)
|
|
62
|
+
else if (entry.name.endsWith('.css')) files.push(full)
|
|
63
|
+
}
|
|
64
|
+
} catch { /* ignore permission errors */ }
|
|
65
|
+
}
|
|
66
|
+
walk(frontendSrc)
|
|
67
|
+
return files
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a CSS var is defined in content (either directly or via @theme)
|
|
72
|
+
*/
|
|
73
|
+
function isTokenDefined(allCss, token) {
|
|
74
|
+
// Direct: --color-background: ...
|
|
75
|
+
if (allCss.includes(token.css + ':')) return true
|
|
76
|
+
if (allCss.includes(token.css + ' :')) return true
|
|
77
|
+
|
|
78
|
+
// @theme block: --color-background: ...
|
|
79
|
+
// Already covered by above
|
|
80
|
+
|
|
81
|
+
// Check for shadcn HSL format: --background: ... (without --color- prefix)
|
|
82
|
+
const hslVar = token.css.replace('--color-', '--')
|
|
83
|
+
if (allCss.includes(hslVar + ':')) return true
|
|
84
|
+
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if shadcn/ui is initialized (has the standard globals.css structure)
|
|
90
|
+
*/
|
|
91
|
+
function hasShadcnInit(allCss) {
|
|
92
|
+
// shadcn init creates a globals.css with @layer base { :root { --background: ... } }
|
|
93
|
+
// or Tailwind v4 style @theme { --color-background: ... }
|
|
94
|
+
return (
|
|
95
|
+
allCss.includes('--background:') ||
|
|
96
|
+
allCss.includes('--color-background:') ||
|
|
97
|
+
allCss.includes('@layer base')
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check tetra-ui version
|
|
103
|
+
*/
|
|
104
|
+
function getTetraUiVersion(projectPath) {
|
|
105
|
+
const paths = [
|
|
106
|
+
join(projectPath, 'frontend', 'package.json'),
|
|
107
|
+
join(projectPath, 'package.json'),
|
|
108
|
+
]
|
|
109
|
+
for (const p of paths) {
|
|
110
|
+
try {
|
|
111
|
+
const pkg = JSON.parse(readFileSync(p, 'utf-8'))
|
|
112
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
113
|
+
return deps['@soulbatical/tetra-ui'] || deps['@vca/tetra-ui'] || null
|
|
114
|
+
} catch { /* skip */ }
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Count shadcn token usage in TSX files
|
|
121
|
+
*/
|
|
122
|
+
function countTokenUsage(projectPath) {
|
|
123
|
+
const frontendSrc = join(projectPath, 'frontend', 'src')
|
|
124
|
+
if (!existsSync(frontendSrc)) return {}
|
|
125
|
+
|
|
126
|
+
const usageCounts = {}
|
|
127
|
+
const allTokens = [...CRITICAL_TOKENS, ...RECOMMENDED_TOKENS]
|
|
128
|
+
|
|
129
|
+
function walk(dir) {
|
|
130
|
+
try {
|
|
131
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
132
|
+
if (entry.name === 'node_modules') continue
|
|
133
|
+
const full = join(dir, entry.name)
|
|
134
|
+
if (entry.isDirectory()) walk(full)
|
|
135
|
+
else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {
|
|
136
|
+
try {
|
|
137
|
+
const content = readFileSync(full, 'utf-8')
|
|
138
|
+
for (const token of allTokens) {
|
|
139
|
+
if (content.includes(token.usage.split(',')[0])) {
|
|
140
|
+
usageCounts[token.name] = (usageCounts[token.name] || 0) + 1
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch { /* skip */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
walk(frontendSrc)
|
|
149
|
+
return usageCounts
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function check(projectPath) {
|
|
153
|
+
const result = createCheck('shadcn-ui-tokens', 5, {
|
|
154
|
+
hasFrontend: false,
|
|
155
|
+
tetraUiVersion: null,
|
|
156
|
+
shadcnInitialized: false,
|
|
157
|
+
hasDarkMode: false,
|
|
158
|
+
missingCritical: [],
|
|
159
|
+
missingRecommended: [],
|
|
160
|
+
definedTokens: [],
|
|
161
|
+
tokenUsage: {},
|
|
162
|
+
})
|
|
163
|
+
result.score = 5
|
|
164
|
+
|
|
165
|
+
// Check if project has a frontend
|
|
166
|
+
const frontendPath = join(projectPath, 'frontend')
|
|
167
|
+
if (!existsSync(frontendPath)) {
|
|
168
|
+
result.score = 5 // N/A — no frontend
|
|
169
|
+
result.details.message = 'No frontend directory — check skipped'
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
result.details.hasFrontend = true
|
|
173
|
+
result.details.tetraUiVersion = getTetraUiVersion(projectPath)
|
|
174
|
+
|
|
175
|
+
// Gather all CSS content (project + imported tetra-ui tokens)
|
|
176
|
+
const cssFiles = findCssFiles(projectPath)
|
|
177
|
+
if (cssFiles.length === 0) {
|
|
178
|
+
result.score = 0
|
|
179
|
+
result.status = 'error'
|
|
180
|
+
result.details.message = 'No CSS files found in frontend/src'
|
|
181
|
+
return result
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Also include tetra-ui tokens.css if imported
|
|
185
|
+
const tetraTokensPaths = [
|
|
186
|
+
join(projectPath, 'frontend', 'node_modules', '@soulbatical', 'tetra-ui', 'src', 'styles', 'tokens.css'),
|
|
187
|
+
join(projectPath, 'node_modules', '@soulbatical', 'tetra-ui', 'src', 'styles', 'tokens.css'),
|
|
188
|
+
]
|
|
189
|
+
for (const p of tetraTokensPaths) {
|
|
190
|
+
if (existsSync(p) && !cssFiles.includes(p)) cssFiles.push(p)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const allCss = cssFiles.map(f => {
|
|
194
|
+
try { return readFileSync(f, 'utf-8') } catch { return '' }
|
|
195
|
+
}).join('\n')
|
|
196
|
+
|
|
197
|
+
result.details.shadcnInitialized = hasShadcnInit(allCss)
|
|
198
|
+
result.details.hasDarkMode = DARK_MODE_INDICATORS.some(i => allCss.includes(i))
|
|
199
|
+
|
|
200
|
+
// Check token definitions
|
|
201
|
+
for (const token of CRITICAL_TOKENS) {
|
|
202
|
+
if (isTokenDefined(allCss, token)) {
|
|
203
|
+
result.details.definedTokens.push(token.name)
|
|
204
|
+
} else {
|
|
205
|
+
result.details.missingCritical.push(token.name)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const token of RECOMMENDED_TOKENS) {
|
|
209
|
+
if (isTokenDefined(allCss, token)) {
|
|
210
|
+
result.details.definedTokens.push(token.name)
|
|
211
|
+
} else {
|
|
212
|
+
result.details.missingRecommended.push(token.name)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check actual usage
|
|
217
|
+
result.details.tokenUsage = countTokenUsage(projectPath)
|
|
218
|
+
|
|
219
|
+
// Score
|
|
220
|
+
if (result.details.missingCritical.length > 5) {
|
|
221
|
+
result.score = 0
|
|
222
|
+
result.status = 'error'
|
|
223
|
+
result.details.message = `shadcn/ui not initialized — ${result.details.missingCritical.length}/10 critical tokens missing: ${result.details.missingCritical.join(', ')}`
|
|
224
|
+
} else if (result.details.missingCritical.length > 0) {
|
|
225
|
+
result.score = 2
|
|
226
|
+
result.status = 'warning'
|
|
227
|
+
result.details.message = `Missing ${result.details.missingCritical.length} critical tokens: ${result.details.missingCritical.join(', ')}`
|
|
228
|
+
} else if (result.details.missingRecommended.length > 0) {
|
|
229
|
+
result.score = 4
|
|
230
|
+
result.status = 'warning'
|
|
231
|
+
result.details.message = `All critical tokens OK. Missing ${result.details.missingRecommended.length} recommended: ${result.details.missingRecommended.join(', ')}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!result.details.hasDarkMode) {
|
|
235
|
+
result.score = Math.max(0, result.score - 1)
|
|
236
|
+
result.details.message = (result.details.message || '') + ' | No dark mode setup detected'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result
|
|
240
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'|'sentry-monitoring'} HealthCheckType
|
|
8
|
+
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'|'sentry-monitoring'|'shadcn-ui-tokens'} HealthCheckType
|
|
9
9
|
*
|
|
10
10
|
* @typedef {'ok'|'warning'|'error'} HealthStatus
|
|
11
11
|
*
|
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.16",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"tetra-security-gate": "./bin/tetra-security-gate.js",
|
|
41
41
|
"tetra-smoke": "./bin/tetra-smoke.js",
|
|
42
42
|
"tetra-init-tests": "./bin/tetra-init-tests.js",
|
|
43
|
-
"tetra-test-audit": "./bin/tetra-test-audit.js"
|
|
43
|
+
"tetra-test-audit": "./bin/tetra-test-audit.js",
|
|
44
|
+
"tetra-check-pages": "./bin/tetra-check-pages.js",
|
|
45
|
+
"tetra-check-views": "./bin/tetra-check-views.js"
|
|
44
46
|
},
|
|
45
47
|
"files": [
|
|
46
48
|
"bin/",
|