@soulbatical/tetra-dev-toolkit 1.20.21 โ†’ 1.20.23

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-init.js CHANGED
@@ -442,8 +442,8 @@ async function initCi(config, options) {
442
442
  // Detect project structure
443
443
  const hasBackend = existsSync(join(projectRoot, 'backend/package.json'))
444
444
  const hasFrontend = existsSync(join(projectRoot, 'frontend/package.json'))
445
- const hasMigrations = existsSync(join(projectRoot, 'backend/supabase/migrations')) ||
446
- existsSync(join(projectRoot, 'supabase/migrations'))
445
+ const hasMigrations = existsSync(join(projectRoot, 'supabase/migrations')) ||
446
+ existsSync(join(projectRoot, 'backend/supabase/migrations'))
447
447
 
448
448
  let workspaces
449
449
  if (hasBackend && hasFrontend) {
@@ -488,6 +488,31 @@ ${withLines.join('\n')}
488
488
  `
489
489
 
490
490
  writeIfMissing(join(workflowDir, 'quality.yml'), workflowContent, options)
491
+
492
+ // Deploy Migrations workflow (only if supabase migrations exist)
493
+ if (hasMigrations) {
494
+ const deployContent = `name: Deploy Migrations
495
+
496
+ on:
497
+ push:
498
+ branches: [main]
499
+ paths:
500
+ - 'supabase/migrations/**'
501
+
502
+ jobs:
503
+ deploy:
504
+ uses: mralbertzwolle/tetra/.github/workflows/deploy-migrations.yml@main
505
+ secrets: inherit
506
+ `
507
+ writeIfMissing(join(workflowDir, 'deploy-migrations.yml'), deployContent, options)
508
+ }
509
+
510
+ // Ensure supabase/migrations/ directory exists at root
511
+ const migrationsDir = join(projectRoot, 'supabase/migrations')
512
+ if (!existsSync(migrationsDir)) {
513
+ mkdirSync(migrationsDir, { recursive: true })
514
+ console.log(` ๐Ÿ“ Created supabase/migrations/`)
515
+ }
491
516
  }
492
517
 
493
518
  function checkCompleteness() {
@@ -529,8 +554,9 @@ function checkCompleteness() {
529
554
  { path: 'frontend/next.config.ts', category: 'frontend', required: true },
530
555
  { path: 'frontend/src/app/layout.tsx', category: 'frontend', required: true },
531
556
 
532
- // Database
533
- { path: 'supabase/migrations', category: 'database', required: false },
557
+ // Database & CI
558
+ { path: 'supabase/migrations', category: 'database', required: true },
559
+ { path: '.github/workflows/deploy-migrations.yml', category: 'database', required: false },
534
560
  ]
535
561
 
536
562
  let currentCategory = ''
@@ -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,528 @@
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
+ // RULE DEFINITIONS
34
+ // ============================================================================
35
+
36
+ /** CRITICAL violations block CI. */
37
+ const CRITICAL_RULES = [
38
+ {
39
+ id: 'hardcoded-page-width',
40
+ label: 'hardcoded page width',
41
+ fixHint: 'Use AppShell config.layout.pageMaxWidth or fullWidthPaths instead',
42
+ pageOnly: true,
43
+ check(line) {
44
+ // max-w-* in className
45
+ 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
+ return line.match(/\bmax-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-\S+)\b/)?.[0] ?? 'max-w-*'
47
+ }
48
+ // container mx-auto in className
49
+ if (/className\s*=/.test(line) && /\bcontainer\b/.test(line) && /\bmx-auto\b/.test(line)) {
50
+ return 'container mx-auto'
51
+ }
52
+ return null
53
+ },
54
+ },
55
+ {
56
+ id: 'hardcoded-page-padding',
57
+ label: 'hardcoded page padding',
58
+ fixHint: 'Use AppShell config.layout.pagePadding preset instead',
59
+ pageOnly: true,
60
+ check(line) {
61
+ if (/className\s*=/.test(line) && /\b(p|px|py|pt|pb|pl|pr)-\d+\b/.test(line)) {
62
+ return line.match(/\b(p|px|py|pt|pb|pl|pr)-\d+\b/)?.[0] ?? 'p-*'
63
+ }
64
+ return null
65
+ },
66
+ },
67
+ {
68
+ id: 'hardcoded-hex-color',
69
+ label: 'hardcoded hex color',
70
+ fixHint: 'Use CSS token var(--tetra-accent) / var(--tetra-bg) etc., or set via AppShell theme config',
71
+ check(line) {
72
+ // Skip tetra-style-audit-disable-next-line handled upstream
73
+ // Inline style attr with hex
74
+ if (/style\s*=/.test(line) && /#[0-9a-fA-F]{3,8}\b/.test(line)) {
75
+ return line.match(/#[0-9a-fA-F]{3,8}\b/)?.[0] ?? '#hex'
76
+ }
77
+ // Tailwind arbitrary hex [#...]
78
+ if (/\[#[0-9a-fA-F]{3,8}\]/.test(line)) {
79
+ return line.match(/\[#[0-9a-fA-F]{3,8}\]/)?.[0] ?? '[#hex]'
80
+ }
81
+ return null
82
+ },
83
+ },
84
+ {
85
+ id: 'raw-tailwind-colors',
86
+ label: 'raw Tailwind color class',
87
+ fixHint: 'Replace with token classes (bg-background, text-foreground, border-border) or var(--tetra-*) tokens',
88
+ check(line) {
89
+ // Whitelist: transparent, current, inherit
90
+ const COLOR_NAMES = [
91
+ 'white', 'black',
92
+ 'gray', 'slate', 'zinc', 'neutral', 'stone',
93
+ 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald',
94
+ 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple',
95
+ 'fuchsia', 'pink', 'rose',
96
+ ]
97
+ const colorRe = new RegExp(
98
+ `\\b(bg|text|border)-(${COLOR_NAMES.join('|')})-(\\d{2,3}|\\d{3}|\\d{2})\\b|\\b(bg|text|border)-(white|black)\\b`
99
+ )
100
+ // Must appear in className context
101
+ if (/className\s*=|class\s*=/.test(line) && colorRe.test(line)) {
102
+ const m = line.match(colorRe)
103
+ return m?.[0] ?? 'raw color class'
104
+ }
105
+ return null
106
+ },
107
+ },
108
+ {
109
+ id: 'custom-globals-override',
110
+ label: 'custom CSS color system overriding Tetra tokens',
111
+ fixHint: 'Set theme via AppShell theme prop; only override tokens in :root { --tetra-accent-subtle: ... } style',
112
+ cssOnly: true,
113
+ check(line, ctx) {
114
+ // Flag when defining core color variables outside the expected Tetra override pattern
115
+ const tetraVars = /--tetra-(accent|bg|text|border|bg-subtle)|--background\b|--foreground\b|--primary\b|--card\b/
116
+ if (tetraVars.test(line) && /:\s/.test(line)) {
117
+ // Allow if inside :root or html.dark (checked by context flag)
118
+ if (ctx && ctx.insideRootBlock) return null
119
+ return line.match(tetraVars)?.[0] ?? '--tetra-*'
120
+ }
121
+ return null
122
+ },
123
+ },
124
+ {
125
+ id: 'custom-card-classes',
126
+ label: 'hand-rolled card pattern',
127
+ fixHint: 'Use AutoCard or Card from @soulbatical/tetra-ui instead',
128
+ check(line) {
129
+ // rounded-xl border bg-card shadow or rounded-lg border border-gray-*
130
+ if (/className\s*=/.test(line)) {
131
+ if (/\brounded-(xl|lg|2xl)\b/.test(line) && /\bborder\b/.test(line) && /\b(shadow|bg-card|bg-white)\b/.test(line)) {
132
+ return 'rounded-* border bg-card/bg-white shadow'
133
+ }
134
+ if (/\brounded-(xl|lg)\b/.test(line) && /\bborder\b/.test(line) && /border-(gray|slate|zinc|neutral)-\d+/.test(line)) {
135
+ return 'rounded-* border border-gray-*'
136
+ }
137
+ }
138
+ return null
139
+ },
140
+ },
141
+ ]
142
+
143
+ /** WARNING violations are reported but don't block CI by default. */
144
+ const WARNING_RULES = [
145
+ {
146
+ id: 'inline-style-attr',
147
+ label: 'inline style attribute',
148
+ fixHint: 'Prefer className; if needed use var(--tetra-*) tokens in style props',
149
+ check(line) {
150
+ if (/style\s*=\s*\{\{/.test(line)) {
151
+ // Allow var(--tetra-*) usage
152
+ if (/var\(--tetra-/.test(line)) return null
153
+ return 'style={{ ... }}'
154
+ }
155
+ return null
156
+ },
157
+ },
158
+ {
159
+ id: 'hex-in-css',
160
+ label: 'hex color in CSS outside :root/html.dark',
161
+ fixHint: 'Move hex values into :root or html.dark CSS token blocks',
162
+ cssOnly: true,
163
+ check(line, ctx) {
164
+ if (ctx && ctx.insideRootBlock) return null
165
+ if (/#[0-9a-fA-F]{3,8}\b/.test(line) && /:\s/.test(line)) {
166
+ return line.match(/#[0-9a-fA-F]{3,8}\b/)?.[0] ?? '#hex'
167
+ }
168
+ return null
169
+ },
170
+ },
171
+ {
172
+ id: 'duplicate-page-header',
173
+ label: 'custom page header (use PageHeader from tetra-ui)',
174
+ fixHint: 'Import PageHeader from @soulbatical/tetra-ui',
175
+ check(line) {
176
+ if (/<h1\b[^>]*className\s*=[^>]*\btext-(2xl|3xl|4xl)\b[^>]*\bfont-bold\b/.test(line) ||
177
+ /<h1\b[^>]*className\s*=[^>]*\bfont-bold\b[^>]*\btext-(2xl|3xl|4xl)\b/.test(line)) {
178
+ return '<h1 className="text-2xl font-bold ...">'
179
+ }
180
+ return null
181
+ },
182
+ },
183
+ ]
184
+
185
+ // ============================================================================
186
+ // HELPERS
187
+ // ============================================================================
188
+
189
+ function isRelaxedPath(filePath) {
190
+ return RELAXED_PATTERNS.some((p) => p.test(filePath))
191
+ }
192
+
193
+ function isSkippedPath(filePath) {
194
+ return SKIP_PATTERNS.some((p) => p.test(filePath))
195
+ }
196
+
197
+ function isPageFile(filePath) {
198
+ return /app\/.*\/(page|layout)\.(tsx|jsx)$/.test(filePath)
199
+ }
200
+
201
+ function isCssFile(filePath) {
202
+ return /\.(css)$/.test(filePath)
203
+ }
204
+
205
+ /**
206
+ * Load extra whitelist entries from .tetra-style-whitelist.json in project root.
207
+ */
208
+ function loadProjectWhitelist(projectRoot) {
209
+ const path = join(projectRoot, '.tetra-style-whitelist.json')
210
+ if (!existsSync(path)) return []
211
+ try {
212
+ const raw = readFileSync(path, 'utf-8')
213
+ return JSON.parse(raw).styleWhitelist || []
214
+ } catch {
215
+ return []
216
+ }
217
+ }
218
+
219
+ function isWhitelisted(filePath, projectWhitelist) {
220
+ for (const entry of projectWhitelist) {
221
+ const prefix = entry.path.replace(/\/\*\*$/, '').replace(/\/\*$/, '')
222
+ if (filePath.includes(prefix)) return true
223
+ }
224
+ return false
225
+ }
226
+
227
+ // ============================================================================
228
+ // FILE SCANNER
229
+ // ============================================================================
230
+
231
+ async function scanFiles(projectRoot, filesGlob) {
232
+ if (filesGlob) {
233
+ return await glob(filesGlob, { cwd: projectRoot, ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'] })
234
+ }
235
+
236
+ const patterns = [
237
+ 'frontend/src/**/*.tsx',
238
+ 'frontend/src/**/*.jsx',
239
+ 'frontend/src/**/*.ts',
240
+ 'frontend/src/**/*.js',
241
+ 'frontend/src/**/*.css',
242
+ ]
243
+
244
+ const files = []
245
+ for (const pattern of patterns) {
246
+ const found = await glob(pattern, {
247
+ cwd: projectRoot,
248
+ ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'],
249
+ })
250
+ files.push(...found)
251
+ }
252
+ return [...new Set(files)]
253
+ }
254
+
255
+ // ============================================================================
256
+ // LINE-BY-LINE AUDIT
257
+ // ============================================================================
258
+
259
+ /**
260
+ * Track :root / html.dark block context for CSS files.
261
+ */
262
+ function buildCssContext(lines, lineIndex) {
263
+ // Walk backwards to find if we're inside a :root or html.dark block
264
+ let braceDepth = 0
265
+ let insideRootBlock = false
266
+ for (let i = lineIndex; i >= 0; i--) {
267
+ const l = lines[i]
268
+ for (const ch of [...l].reverse()) {
269
+ if (ch === '}') braceDepth++
270
+ if (ch === '{') {
271
+ if (braceDepth === 0) {
272
+ // This opening brace owns us โ€” check selector
273
+ if (/:root\b|html\.dark\b/.test(lines[i])) {
274
+ insideRootBlock = true
275
+ }
276
+ break
277
+ }
278
+ braceDepth--
279
+ }
280
+ }
281
+ if (braceDepth === 0 && insideRootBlock !== undefined) break
282
+ }
283
+ return { insideRootBlock }
284
+ }
285
+
286
+ /**
287
+ * Audit the content of a single file. Returns array of findings.
288
+ * Each finding: { id, label, severity, line, lineNumber, match, fixHint }
289
+ */
290
+ function auditFileContent(content, filePath, relaxed) {
291
+ const findings = []
292
+ const lines = content.split('\n')
293
+ const css = isCssFile(filePath)
294
+ const page = isPageFile(filePath)
295
+
296
+ for (let i = 0; i < lines.length; i++) {
297
+ const line = lines[i]
298
+ const lineNumber = i + 1
299
+
300
+ // Disable comment check
301
+ if (i > 0 && /tetra-style-audit-disable-next-line/.test(lines[i - 1])) continue
302
+ if (/tetra-style-audit-disable-next-line/.test(line)) continue
303
+
304
+ const ctx = css ? buildCssContext(lines, i) : null
305
+
306
+ // Determine which rules apply
307
+ const criticalRules = CRITICAL_RULES.filter((r) => {
308
+ if (r.cssOnly && !css) return false
309
+ if (!r.cssOnly && css) return false
310
+ if (r.pageOnly && !page) return false
311
+ if (relaxed && r.pageOnly) return false // skip layout rules for relaxed paths
312
+ return true
313
+ })
314
+
315
+ const warningRules = WARNING_RULES.filter((r) => {
316
+ if (r.cssOnly && !css) return false
317
+ if (!r.cssOnly && css) return false
318
+ if (relaxed) return false // skip warnings for relaxed paths
319
+ return true
320
+ })
321
+
322
+ for (const rule of criticalRules) {
323
+ const match = rule.check(line, ctx)
324
+ if (match) {
325
+ findings.push({
326
+ id: rule.id,
327
+ label: rule.label,
328
+ severity: 'critical',
329
+ line: line.trim(),
330
+ lineNumber,
331
+ match,
332
+ fixHint: rule.fixHint,
333
+ })
334
+ }
335
+ }
336
+
337
+ for (const rule of warningRules) {
338
+ const match = rule.check(line, ctx)
339
+ if (match) {
340
+ findings.push({
341
+ id: rule.id,
342
+ label: rule.label,
343
+ severity: 'warning',
344
+ line: line.trim(),
345
+ lineNumber,
346
+ match,
347
+ fixHint: rule.fixHint,
348
+ })
349
+ }
350
+ }
351
+ }
352
+
353
+ return findings
354
+ }
355
+
356
+ // ============================================================================
357
+ // MAIN AUDIT
358
+ // ============================================================================
359
+
360
+ /**
361
+ * Run the full style compliance audit.
362
+ *
363
+ * @param {string} projectRoot
364
+ * @param {{ filesGlob?: string }} options
365
+ * @returns {Promise<{ results: FileResult[], summary: Summary }>}
366
+ */
367
+ export async function runStyleComplianceAudit(projectRoot, options = {}) {
368
+ const projectWhitelist = loadProjectWhitelist(projectRoot)
369
+ const allFiles = await scanFiles(projectRoot, options.filesGlob)
370
+
371
+ const results = []
372
+
373
+ for (const filePath of allFiles.sort()) {
374
+ if (isSkippedPath(filePath) || isWhitelisted(filePath, projectWhitelist)) {
375
+ results.push({
376
+ file: filePath,
377
+ status: 'skipped',
378
+ reason: isSkippedPath(filePath) ? 'marketing/public path' : 'whitelisted',
379
+ findings: [],
380
+ })
381
+ continue
382
+ }
383
+
384
+ const relaxed = isRelaxedPath(filePath)
385
+ const fullPath = join(projectRoot, filePath)
386
+ let content = ''
387
+ try {
388
+ content = readFileSync(fullPath, 'utf-8')
389
+ } catch {
390
+ results.push({ file: filePath, status: 'error', reason: 'could not read file', findings: [] })
391
+ continue
392
+ }
393
+
394
+ const findings = auditFileContent(content, filePath, relaxed)
395
+ const criticalCount = findings.filter((f) => f.severity === 'critical').length
396
+ const warningCount = findings.filter((f) => f.severity === 'warning').length
397
+
398
+ let status
399
+ if (criticalCount > 0) {
400
+ status = 'violation'
401
+ } else if (warningCount > 0) {
402
+ status = 'warning'
403
+ } else {
404
+ status = 'clean'
405
+ }
406
+
407
+ results.push({ file: filePath, status, reason: null, findings })
408
+ }
409
+
410
+ // Summary
411
+ const scanned = results.filter((r) => r.status !== 'skipped' && r.status !== 'error')
412
+ const totalCritical = scanned.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'critical').length, 0)
413
+ const totalWarnings = scanned.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'warning').length, 0)
414
+ const filesWithViolations = scanned.filter((r) => r.status === 'violation').length
415
+ const filesWithWarnings = scanned.filter((r) => r.status === 'warning').length
416
+ const cleanFiles = scanned.filter((r) => r.status === 'clean').length
417
+
418
+ return {
419
+ results,
420
+ summary: {
421
+ filesScanned: scanned.length,
422
+ filesSkipped: results.filter((r) => r.status === 'skipped').length,
423
+ filesWithViolations,
424
+ filesWithWarnings,
425
+ cleanFiles,
426
+ totalCritical,
427
+ totalWarnings,
428
+ },
429
+ }
430
+ }
431
+
432
+ // ============================================================================
433
+ // FORMATTERS
434
+ // ============================================================================
435
+
436
+ /**
437
+ * Format the report as pretty terminal output.
438
+ *
439
+ * @param {object} report
440
+ * @param {object} chalk
441
+ * @param {{ fixHint?: boolean }} options
442
+ */
443
+ export function formatReport(report, chalk, options = {}) {
444
+ const lines = []
445
+ const { results, summary } = report
446
+
447
+ lines.push('')
448
+ lines.push(chalk.blue.bold(' Style Compliance Audit'))
449
+ lines.push(chalk.blue(' ' + 'โ•'.repeat(24)))
450
+ lines.push('')
451
+
452
+ // Only print files that have findings
453
+ const filesWithFindings = results.filter(
454
+ (r) => r.status === 'violation' || r.status === 'warning'
455
+ )
456
+
457
+ if (filesWithFindings.length === 0) {
458
+ lines.push(chalk.green(' No style violations found.'))
459
+ }
460
+
461
+ for (const r of filesWithFindings) {
462
+ lines.push(chalk.white.bold(` ${r.file}`))
463
+
464
+ for (const f of r.findings) {
465
+ const icon = f.severity === 'critical' ? chalk.red(' โœ—') : chalk.yellow(' โš ')
466
+ const idStr = f.severity === 'critical'
467
+ ? chalk.red(f.id)
468
+ : chalk.yellow(f.id)
469
+ lines.push(`${icon} ${idStr} ${chalk.gray(`(line ${f.lineNumber})`)}`)
470
+ lines.push(chalk.gray(` ${f.match}`))
471
+ if (options.fixHint && f.fixHint) {
472
+ lines.push(chalk.cyan(` โ†’ ${f.fixHint}`))
473
+ }
474
+ }
475
+
476
+ lines.push('')
477
+ }
478
+
479
+ lines.push(chalk.gray(' ' + 'โ”€'.repeat(39)))
480
+ lines.push(
481
+ chalk.white(
482
+ ` ${summary.filesScanned} files scanned ยท ` +
483
+ chalk.red.bold(`${summary.totalCritical} critical`) +
484
+ ` ยท ` +
485
+ chalk.yellow(`${summary.totalWarnings} warnings`)
486
+ )
487
+ )
488
+ lines.push('')
489
+
490
+ if (summary.totalCritical > 0) {
491
+ lines.push(
492
+ chalk.red.bold(
493
+ ` โœ— ${summary.totalCritical} critical violation${summary.totalCritical > 1 ? 's' : ''} found. Fix before merging.`
494
+ )
495
+ )
496
+ lines.push('')
497
+ } else {
498
+ lines.push(chalk.green.bold(' All files pass critical style checks.'))
499
+ lines.push('')
500
+ }
501
+
502
+ return lines.join('\n')
503
+ }
504
+
505
+ /**
506
+ * Format the report as JSON.
507
+ */
508
+ export function formatReportJSON(report) {
509
+ return JSON.stringify(report, null, 2)
510
+ }
511
+
512
+ /**
513
+ * Output GitHub Actions annotations for violations.
514
+ */
515
+ export function formatCIAnnotations(report) {
516
+ const annotations = []
517
+
518
+ for (const r of report.results) {
519
+ for (const f of r.findings) {
520
+ const level = f.severity === 'critical' ? 'error' : 'warning'
521
+ annotations.push(
522
+ `::${level} file=${r.file},line=${f.lineNumber},title=Style Compliance ${f.id}::${f.match} โ€” ${f.fixHint || f.label}`
523
+ )
524
+ }
525
+ }
526
+
527
+ return annotations.join('\n')
528
+ }