@finggujadhav/compiler 0.9.4 → 0.9.6

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/a11y.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * FingguFlux A11y Scanner
3
+ * Validates template ARIA compliance against FingguFlux contracts.
4
+ */
5
+
6
+ export const ARIA_CONTRACTS = {
7
+ 'ff-accordion': {
8
+ required: ['role="button"', 'aria-expanded'],
9
+ recommended: ['aria-controls'],
10
+ stateSync: 'aria-expanded'
11
+ },
12
+ 'ff-tabs': {
13
+ required: ['role="tablist"', 'role="tab"', 'role="tabpanel"'],
14
+ stateSync: 'aria-selected'
15
+ },
16
+ 'ff-modal': {
17
+ required: ['role="dialog"', 'aria-modal="true"']
18
+ },
19
+ 'ff-dropdown': {
20
+ required: ['aria-haspopup="true"'],
21
+ stateSync: 'aria-expanded'
22
+ }
23
+ };
24
+
25
+ export function validateTemplate(content) {
26
+ const issues = [];
27
+
28
+ // Check Accordion patterns
29
+ if (content.includes('ff-accordion-header')) {
30
+ const headers = content.match(/<button[^>]*class="[^"]*ff-accordion-header[^"]*"[^>]*>/g) || [];
31
+ headers.forEach(header => {
32
+ if (!header.includes('aria-expanded')) {
33
+ issues.push({
34
+ component: 'Accordion',
35
+ type: 'Error',
36
+ message: 'Missing aria-expanded on ff-accordion-header'
37
+ });
38
+ }
39
+ if (header.includes('data-ff-state')) {
40
+ const state = header.match(/data-ff-state="([^"]*)"/);
41
+ const aria = header.match(/aria-expanded="([^"]*)"/);
42
+ if (state && aria) {
43
+ const expected = state[1] === 'open' ? 'true' : 'false';
44
+ if (aria[1] !== expected) {
45
+ issues.push({
46
+ component: 'Accordion',
47
+ type: 'Warning',
48
+ message: `aria-expanded ("${aria[1]}") inconsistent with data-ff-state ("${state[1]}")`
49
+ });
50
+ }
51
+ }
52
+ }
53
+ });
54
+ }
55
+
56
+ // Check Modal patterns
57
+ if (content.includes('ff-modal')) {
58
+ const modals = content.match(/<div[^>]*class="[^"]*ff-modal[^"]*"[^>]*>/g) || [];
59
+ modals.forEach(modal => {
60
+ if (!modal.includes('role="dialog"') && !modal.includes('role="alertdialog"')) {
61
+ issues.push({
62
+ component: 'Modal',
63
+ type: 'Error',
64
+ message: 'Missing role="dialog" or "alertdialog" on ff-modal'
65
+ });
66
+ }
67
+ if (!modal.includes('aria-modal="true"')) {
68
+ issues.push({
69
+ component: 'Modal',
70
+ type: 'Warning',
71
+ message: 'Missing aria-modal="true" on ff-modal'
72
+ });
73
+ }
74
+ });
75
+ }
76
+
77
+ return issues;
78
+ }
79
+
80
+ /**
81
+ * Basic Contrast Ratio Calculator (WCAG 2.1)
82
+ * @param {string} hex1
83
+ * @param {string} hex2
84
+ * @returns {number}
85
+ */
86
+ export function getContrastRatio(hex1, hex2) {
87
+ const getL = (c) => {
88
+ let rv = parseInt(c, 16) / 255;
89
+ return rv <= 0.03928 ? rv / 12.92 : Math.pow((rv + 0.055) / 1.055, 2.4);
90
+ };
91
+ const getLuminance = (hex) => {
92
+ const r = hex.slice(1, 3);
93
+ const g = hex.slice(3, 5);
94
+ const b = hex.slice(5, 7);
95
+ return 0.2126 * getL(r) + 0.7152 * getL(g) + 0.0722 * getL(b);
96
+ };
97
+
98
+ const l1 = getLuminance(hex1);
99
+ const l2 = getLuminance(hex2);
100
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
101
+ }
package/cli.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * FingguFlux CLI
4
- * v0.9.4 Intelligence Upgrade
4
+ * v0.9.6 Theme Engine Stabilization & Contract Freeze
5
5
  */
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { scanFiles, getProjectFiles } from './scanner.js';
9
9
  import { CompilerEngine } from './engine.js';
10
+ import { runThemeCheck, printThemeCheckReport } from './theme-check.js';
10
11
 
11
12
  const args = process.argv.slice(2);
12
13
  const command = args[0] || 'build';
@@ -31,9 +32,15 @@ async function main() {
31
32
  case 'doctor':
32
33
  await runDoctor();
33
34
  break;
35
+ case 'a11y':
36
+ await runA11y();
37
+ break;
38
+ case 'theme-check':
39
+ await runThemeCheckCommand();
40
+ break;
34
41
  default:
35
42
  console.error(`Unknown command: ${command}`);
36
- console.log('Available commands: build, analyze, doctor');
43
+ console.log('Available commands: build, analyze, doctor, a11y, theme-check');
37
44
  process.exit(1);
38
45
  }
39
46
  }
@@ -159,6 +166,93 @@ async function runDoctor() {
159
166
  else console.log(`\n❌ Project has issues. See details above.`);
160
167
  }
161
168
 
169
+ async function runA11y() {
170
+ console.log(`\n♿ FingguFlux Accessibility Audit`);
171
+ const { validateTemplate, getContrastRatio } = await import('./a11y.js');
172
+ const files = getProjectFiles(path.resolve(inputDir));
173
+
174
+ let totalIssues = 0;
175
+
176
+ // 1. Template Validation
177
+ files.forEach(file => {
178
+ const content = fs.readFileSync(file, 'utf8');
179
+ const fileIssues = validateTemplate(content);
180
+ if (fileIssues.length > 0) {
181
+ console.log(`\nFile: ${path.relative(process.cwd(), file)}`);
182
+ fileIssues.forEach(issue => {
183
+ const typeColor = issue.type === 'Error' ? '\x1b[31m' : '\x1b[33m';
184
+ console.log(` [${typeColor}${issue.type}\x1b[0m] ${issue.component}: ${issue.message}`);
185
+ totalIssues++;
186
+ });
187
+ }
188
+ });
189
+
190
+ // 2. Token Contrast Check
191
+ console.log(`\n🎨 Semantic Token Contrast Check:`);
192
+ const possibleCorePaths = [
193
+ path.resolve('./packages/core/tokens.css'),
194
+ path.resolve('./node_modules/@finggujadhav/core/tokens.css')
195
+ ];
196
+ const corePath = possibleCorePaths.find(p => fs.existsSync(p));
197
+
198
+ if (corePath) {
199
+ const tokens = fs.readFileSync(corePath, 'utf8');
200
+ const pairings = [
201
+ ['--ff-success-surface', '--ff-success-content', 'Success'],
202
+ ['--ff-warning-surface', '--ff-warning-content', 'Warning'],
203
+ ['--ff-danger-surface', '--ff-danger-content', 'Danger']
204
+ ];
205
+
206
+ pairings.forEach(([bgVar, fgVar, label]) => {
207
+ const bgMatch = tokens.match(new RegExp(`${bgVar}: ([#\\w]+)`));
208
+ const fgMatch = tokens.match(new RegExp(`${fgVar}: ([#\\w]+)`));
209
+ if (bgMatch && fgMatch) {
210
+ const ratio = getContrastRatio(bgMatch[1], fgMatch[1]);
211
+ const pass = ratio >= 4.5;
212
+ const status = pass ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
213
+ console.log(` ${label.padEnd(10)}: ${status} (${ratio.toFixed(2)}:1)`);
214
+ if (!pass) totalIssues++;
215
+ }
216
+ });
217
+ }
218
+
219
+ if (totalIssues === 0) {
220
+ console.log(`\n✨ No accessibility issues found!`);
221
+ } else {
222
+ console.log(`\n❌ Found ${totalIssues} accessibility issues.`);
223
+ }
224
+ }
225
+
226
+ async function runThemeCheckCommand() {
227
+ // Locate the tokens CSS
228
+ const possiblePaths = [
229
+ path.resolve('./packages/core/tokens.css'),
230
+ path.resolve('./node_modules/@finggujadhav/core/tokens.css'),
231
+ path.resolve('../../packages/core/tokens.css'),
232
+ ];
233
+ const tokensPath = possiblePaths.find(p => fs.existsSync(p));
234
+ if (!tokensPath) {
235
+ console.error('❌ tokens.css not found. Searched:');
236
+ possiblePaths.forEach(p => console.error(' ' + p));
237
+ process.exit(1);
238
+ }
239
+
240
+ const css = fs.readFileSync(tokensPath, 'utf8');
241
+ const verbose = args.includes('--verbose');
242
+
243
+ let result;
244
+ try {
245
+ result = runThemeCheck(css);
246
+ } catch (e) {
247
+ console.error('❌ theme-check failed:', e.message);
248
+ process.exit(1);
249
+ }
250
+
251
+ printThemeCheckReport(result, { verbose });
252
+
253
+ if (!result.pass) process.exit(1);
254
+ }
255
+
162
256
  main().catch(err => {
163
257
  console.error('Command failed:', err);
164
258
  process.exit(1);
package/engine.js CHANGED
@@ -158,7 +158,7 @@ export class CompilerEngine {
158
158
  generateReport() {
159
159
  return {
160
160
  timestamp: new Date().toISOString(),
161
- engineVersion: "0.9.4",
161
+ engineVersion: "0.9.6",
162
162
  mode: this.mode,
163
163
  stats: this.getStats(),
164
164
  mapping: this.getMapping()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finggujadhav/compiler",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "The FingguFlux static analysis and selector hardening engine.",
5
5
  "main": "engine.js",
6
6
  "bin": {
@@ -9,9 +9,15 @@
9
9
  "files": [
10
10
  "engine.js",
11
11
  "scanner.js",
12
- "cli.js"
12
+ "cli.js",
13
+ "a11y.js",
14
+ "theme-check.js"
13
15
  ],
14
16
  "type": "module",
17
+ "scripts": {
18
+ "test": "node --test test/theme-check.test.js",
19
+ "test:all": "node --test test/**/*.test.js"
20
+ },
15
21
  "engines": {
16
22
  "node": ">=16.0.0"
17
23
  },
package/theme-check.js ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * FingguFlux Theme-Check Engine
3
+ * v0.9.6 – Theme Engine Stabilization & Contract Freeze
4
+ *
5
+ * ZERO RUNTIME OVERHEAD: This module is a pure build-time static analyser.
6
+ * It reads CSS files and the TOKENS_REGISTRY.json contract, then emits
7
+ * structured diagnostics. It produces NO code that ships to the browser.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+
13
+ // ─── ANSI colour helpers ──────────────────────────────────────────────────────
14
+ const C = {
15
+ reset: '\x1b[0m',
16
+ bold: '\x1b[1m',
17
+ red: '\x1b[31m',
18
+ green: '\x1b[32m',
19
+ yellow: '\x1b[33m',
20
+ cyan: '\x1b[36m',
21
+ grey: '\x1b[90m',
22
+ };
23
+ const clr = (code, text) => `${code}${text}${C.reset}`;
24
+
25
+ // ─── Token extraction ─────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Parse every `--ff-*` custom-property *definition* from a CSS string.
29
+ * Only captures tokens that appear on the LEFT-hand side of a colon,
30
+ * i.e. actual definitions, not var() references.
31
+ *
32
+ * @param {string} css
33
+ * @returns {Set<string>}
34
+ */
35
+ export function extractDefinedTokens(css) {
36
+ const defined = new Set();
37
+ // Match: --ff-something-here : <value>
38
+ const re = /(--ff-[\w-]+)\s*:/g;
39
+ let m;
40
+ while ((m = re.exec(css)) !== null) {
41
+ // Skip tokens that appear inside var(...) — they are references, not definitions
42
+ const before = css.slice(0, m.index);
43
+ const lastVar = before.lastIndexOf('var(');
44
+ const lastClose = before.lastIndexOf(')');
45
+ if (lastVar !== -1 && lastVar > lastClose) continue;
46
+ defined.add(m[1]);
47
+ }
48
+ return defined;
49
+ }
50
+
51
+
52
+ /**
53
+ * Build a context map: token → array of CSS at-rule / selector scopes
54
+ * where the token is defined.
55
+ *
56
+ * Scopes we care about:
57
+ * 'light' — :root or [data-ff-theme="light"]
58
+ * 'dark' — [data-ff-theme="dark"]
59
+ * 'system' — @media (prefers-color-scheme: dark)
60
+ * 'global' — anything else (e.g. root-level without a theme wrapper)
61
+ *
62
+ * @param {string} css
63
+ * @returns {Map<string, Set<string>>} token → scopes
64
+ */
65
+ export function extractTokensByScope(css) {
66
+ /** @type {Map<string, Set<string>>} */
67
+ const map = new Map();
68
+
69
+ /**
70
+ * We parse character-by-character to maintain an accurate scope stack.
71
+ * scopeStack[i] holds the classified scope name for depth i+1.
72
+ *
73
+ * Scopes:
74
+ * 'light' — :root or [data-ff-theme="light"] (or comma-list containing either)
75
+ * 'dark' — [data-ff-theme="dark"]
76
+ * 'system' — @media (prefers-color-scheme: dark) { ... }
77
+ * 'global' — anything else
78
+ *
79
+ * Nesting rule: 'system' dominates — any inner selector inside a system
80
+ * @media block stays classified as 'system', even if it contains ':root'.
81
+ */
82
+ const scopeStack = []; // one entry per open brace depth
83
+ let depth = 0;
84
+
85
+ let pending = ''; // text accumulating before the next '{'
86
+ let inString = false;
87
+ let stringChar = '';
88
+
89
+ /**
90
+ * @param {string} sel - the raw selector / at-rule text
91
+ * @param {string} parentScope - the scope of the enclosing block ('global', 'system', etc.)
92
+ */
93
+ const classifySelector = (sel, parentScope) => {
94
+ const s = sel.toLowerCase().trim();
95
+ if (!s) return parentScope;
96
+
97
+ // @media rule itself
98
+ if (s.includes('@media')) {
99
+ if (s.includes('prefers-color-scheme') && s.includes('dark')) {
100
+ return 'system';
101
+ }
102
+ return 'global';
103
+ }
104
+
105
+ // If we're already inside a system (dark media) block, any inner rule is system too
106
+ if (parentScope === 'system') return 'system';
107
+
108
+ // Explicit dark theme selector
109
+ if (s.includes('[data-ff-theme="dark"]') || s.includes("[data-ff-theme='dark']")) {
110
+ return 'dark';
111
+ }
112
+
113
+ // Light: :root or comma-list like ":root, [data-ff-theme='light']"
114
+ if (
115
+ s.includes(':root') ||
116
+ s.includes('[data-ff-theme="light"]') ||
117
+ s.includes("[data-ff-theme='light']")
118
+ ) {
119
+ return 'light';
120
+ }
121
+
122
+ return 'global';
123
+ };
124
+
125
+ const addToken = (tok, scope) => {
126
+ if (!map.has(tok)) map.set(tok, new Set());
127
+ map.get(tok).add(scope);
128
+ };
129
+
130
+ const extractTokensFromText = (text, scope) => {
131
+ // Match only actual custom-property definitions:
132
+ // --ff-name : <value>
133
+ // We use a regex that matches --ff-name followed by whitespace and colon,
134
+ // then verify it is NOT inside a var() call.
135
+ const re = /(--ff-[\w-]+)\s*:/g;
136
+ let m;
137
+ while ((m = re.exec(text)) !== null) {
138
+ // Exclude matches that are inside var(...) — check what precedes the token
139
+ const before = text.slice(0, m.index);
140
+ const lastVar = before.lastIndexOf('var(');
141
+ const lastClose = before.lastIndexOf(')');
142
+ if (lastVar !== -1 && lastVar > lastClose) {
143
+ continue; // inside a var() call
144
+ }
145
+ addToken(m[1], scope);
146
+ }
147
+ };
148
+
149
+ // Determine effective scope from the stack (outermost non-global wins for system)
150
+ const currentScope = () => {
151
+ // 'system' takes absolute priority (outermost) when present anywhere in stack
152
+ if (scopeStack.includes('system')) return 'system';
153
+ // Otherwise find the innermost meaningful scope
154
+ for (let i = scopeStack.length - 1; i >= 0; i--) {
155
+ if (scopeStack[i] !== 'global') return scopeStack[i];
156
+ }
157
+ return 'global';
158
+ };
159
+
160
+ const parentScope = () => {
161
+ if (scopeStack.length === 0) return 'global';
162
+ // Same logic as currentScope but without the new entry
163
+ if (scopeStack.includes('system')) return 'system';
164
+ for (let i = scopeStack.length - 1; i >= 0; i--) {
165
+ if (scopeStack[i] !== 'global') return scopeStack[i];
166
+ }
167
+ return 'global';
168
+ };
169
+
170
+ for (let i = 0; i < css.length; i++) {
171
+ const ch = css[i];
172
+
173
+ // Basic string tracking (to avoid misinterpreting { } inside strings/comments)
174
+ if (!inString && (ch === '"' || ch === "'")) {
175
+ inString = true;
176
+ stringChar = ch;
177
+ pending += ch;
178
+ continue;
179
+ }
180
+ if (inString && ch === stringChar && css[i - 1] !== '\\') {
181
+ inString = false;
182
+ pending += ch;
183
+ continue;
184
+ }
185
+ if (inString) {
186
+ pending += ch;
187
+ continue;
188
+ }
189
+
190
+ if (ch === '{') {
191
+ const scope = classifySelector(pending, parentScope());
192
+ scopeStack.push(scope);
193
+ depth++;
194
+ pending = '';
195
+ } else if (ch === '}') {
196
+ // Flush any pending text before closing the block
197
+ if (pending.trim()) {
198
+ extractTokensFromText(pending, currentScope());
199
+ }
200
+ scopeStack.pop();
201
+ depth--;
202
+ pending = '';
203
+ } else {
204
+ pending += ch;
205
+ // Flush at semicolons to keep memory usage bounded
206
+ if (ch === ';' && depth > 0) {
207
+ extractTokensFromText(pending, currentScope());
208
+ pending = '';
209
+ }
210
+ }
211
+ }
212
+
213
+ return map;
214
+ }
215
+
216
+
217
+
218
+ // ─── Registry loader ──────────────────────────────────────────────────────────
219
+
220
+ /**
221
+ * Locate and parse TOKENS_REGISTRY.json.
222
+ * Searches: CWD, packages/core, ../../packages/core (monorepo root).
223
+ *
224
+ * @returns {{ registry: object, registryPath: string }}
225
+ */
226
+ export function loadRegistry() {
227
+ const candidates = [
228
+ path.resolve('./packages/core/TOKENS_REGISTRY.json'),
229
+ path.resolve('../../packages/core/TOKENS_REGISTRY.json'),
230
+ path.resolve('./node_modules/@finggujadhav/core/TOKENS_REGISTRY.json'),
231
+ ];
232
+
233
+ for (const p of candidates) {
234
+ if (fs.existsSync(p)) {
235
+ try {
236
+ return { registry: JSON.parse(fs.readFileSync(p, 'utf8')), registryPath: p };
237
+ } catch (e) {
238
+ throw new Error(`TOKENS_REGISTRY.json at ${p} is malformed JSON: ${e.message}`);
239
+ }
240
+ }
241
+ }
242
+
243
+ throw new Error(
244
+ 'TOKENS_REGISTRY.json not found. ' +
245
+ 'Expected at packages/core/TOKENS_REGISTRY.json. ' +
246
+ 'Run the FingguFlux bootstrap task to regenerate it.'
247
+ );
248
+ }
249
+
250
+ /**
251
+ * Returns the flat set of ALL token names from every category in the registry.
252
+ *
253
+ * @param {object} registry
254
+ * @returns {Set<string>}
255
+ */
256
+ export function registryAllTokens(registry) {
257
+ const all = new Set();
258
+ for (const cat of Object.values(registry.categories)) {
259
+ for (const tok of cat.tokens) all.add(tok);
260
+ }
261
+ return all;
262
+ }
263
+
264
+ // ─── Core checks ─────────────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * REMOVAL DETECTION
268
+ * Finds tokens present in the registry but ABSENT from the actual CSS.
269
+ *
270
+ * @param {Set<string>} registryTokens
271
+ * @param {Set<string>} definedTokens
272
+ * @returns {string[]} removed token names
273
+ */
274
+ export function detectRemovals(registryTokens, definedTokens) {
275
+ const removed = [];
276
+ for (const tok of registryTokens) {
277
+ if (!definedTokens.has(tok)) removed.push(tok);
278
+ }
279
+ return removed.sort();
280
+ }
281
+
282
+ /**
283
+ * RENAME DETECTION
284
+ * Checks the registry's `renames` array for tokens that have been moved
285
+ * but whose *old* name still appears as a definition in the CSS.
286
+ *
287
+ * Registry renames format:
288
+ * { "from": "--ff-old-name", "to": "--ff-new-name", "since": "0.9.6" }
289
+ *
290
+ * @param {Array<{from:string,to:string,since:string}>} renames
291
+ * @param {Set<string>} definedTokens
292
+ * @returns {Array<{from:string,to:string,since:string}>}
293
+ */
294
+ export function detectRenames(renames, definedTokens) {
295
+ return renames.filter(r => definedTokens.has(r.from));
296
+ }
297
+
298
+ /**
299
+ * ADDITION DETECTION
300
+ * Tokens defined in CSS that are NOT present in the registry (undocumented additions).
301
+ *
302
+ * @param {Set<string>} registryTokens
303
+ * @param {Set<string>} definedTokens
304
+ * @returns {string[]}
305
+ */
306
+ export function detectAdditions(registryTokens, definedTokens) {
307
+ const added = [];
308
+ for (const tok of definedTokens) {
309
+ if (!registryTokens.has(tok)) added.push(tok);
310
+ }
311
+ return added.sort();
312
+ }
313
+
314
+ /**
315
+ * THEME COMPLETENESS
316
+ * Validates that every token listed under registry.theming[theme].required
317
+ * is defined (has a definition) within the correct CSS scope.
318
+ *
319
+ * @param {object} theming registry.theming
320
+ * @param {Map<string, Set<string>>} tokensByScope
321
+ * @returns {Object.<string, string[]>} theme → missing tokens
322
+ */
323
+ export function validateThemeCompleteness(theming, tokensByScope) {
324
+ const results = {};
325
+
326
+ for (const [theme, config] of Object.entries(theming)) {
327
+ const missing = [];
328
+ const required = config.required || [];
329
+
330
+ for (const tok of required) {
331
+ const scopes = tokensByScope.get(tok);
332
+ if (!scopes) {
333
+ missing.push(tok);
334
+ continue;
335
+ }
336
+
337
+ let satisfied = false;
338
+
339
+ if (theme === 'light') {
340
+ // Light theme: token must be present in 'light' scope (which includes :root)
341
+ satisfied = scopes.has('light');
342
+ } else {
343
+ // Dark / system: token MUST be explicitly overridden in that specific scope.
344
+ // A token that only exists in 'light' scope is NOT considered satisfied
345
+ // for dark/system, because that is precisely what theme-check is enforcing —
346
+ // that the author hasn't forgotten to provide the dark/system overrides.
347
+ satisfied = scopes.has(theme);
348
+ }
349
+
350
+ if (!satisfied) missing.push(tok);
351
+ }
352
+
353
+ results[theme] = missing;
354
+ }
355
+
356
+ return results;
357
+ }
358
+
359
+ // ─── Main runner ──────────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Run the full theme-check suite against a CSS string.
363
+ *
364
+ * @param {string} css Combined tokens CSS content
365
+ * @param {object} [registryOverride] Inject registry directly (used in tests)
366
+ * @returns {{
367
+ * pass: boolean,
368
+ * removals: string[],
369
+ * renames: Array,
370
+ * additions: string[],
371
+ * themeGaps: Object,
372
+ * warnings: string[]
373
+ * }}
374
+ */
375
+ export function runThemeCheck(css, registryOverride) {
376
+ const { registry } = registryOverride
377
+ ? { registry: registryOverride }
378
+ : loadRegistry();
379
+
380
+ const definedTokens = extractDefinedTokens(css);
381
+ const tokensByScope = extractTokensByScope(css);
382
+ const registryTokens = registryAllTokens(registry);
383
+
384
+ const removals = detectRemovals(registryTokens, definedTokens);
385
+ const renames = detectRenames(registry.renames || [], definedTokens);
386
+ const additions = detectAdditions(registryTokens, definedTokens);
387
+ const themeGaps = validateThemeCompleteness(registry.theming || {}, tokensByScope);
388
+
389
+ const warnings = [];
390
+ if (additions.length > 0) {
391
+ warnings.push(
392
+ `${additions.length} token(s) defined in CSS but absent from TOKENS_REGISTRY.json. ` +
393
+ 'Add them to the registry to freeze the contract.'
394
+ );
395
+ }
396
+
397
+ const pass =
398
+ removals.length === 0 &&
399
+ renames.length === 0 &&
400
+ Object.values(themeGaps).every(arr => arr.length === 0);
401
+
402
+ return { pass, removals, renames, additions, themeGaps, warnings };
403
+ }
404
+
405
+ // ─── Pretty-printer ───────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Print a human-readable theme-check report to stdout.
409
+ *
410
+ * @param {{ pass: boolean, removals: string[], renames: Array, additions: string[], themeGaps: Object, warnings: string[] }} result
411
+ * @param {{ verbose?: boolean }} [opts]
412
+ */
413
+ export function printThemeCheckReport(result, opts = {}) {
414
+ const { pass, removals, renames, additions, themeGaps, warnings } = result;
415
+ const verbose = opts.verbose || false;
416
+
417
+ console.log('');
418
+ console.log(clr(C.bold + C.cyan, '🎨 FingguFlux Theme-Check – Contract Freeze Audit'));
419
+ console.log(clr(C.grey, '─'.repeat(54)));
420
+
421
+ // ── Removals ──────────────────────────────────────────────
422
+ if (removals.length === 0) {
423
+ console.log(clr(C.green, '✅ No token removals detected.'));
424
+ } else {
425
+ console.log(clr(C.red, `❌ ${removals.length} token(s) REMOVED (breaking change!):`));
426
+ removals.forEach(t => console.log(clr(C.red, ` – ${t}`)));
427
+ }
428
+
429
+ // ── Renames ───────────────────────────────────────────────
430
+ if (renames.length === 0) {
431
+ console.log(clr(C.green, '✅ No stale renamed tokens in CSS.'));
432
+ } else {
433
+ console.log(clr(C.yellow, `⚠️ ${renames.length} stale rename(s) found:`));
434
+ renames.forEach(r =>
435
+ console.log(clr(C.yellow, ` ${r.from} → ${r.to} (since v${r.since})`))
436
+ );
437
+ }
438
+
439
+ // ── Unregistered additions ────────────────────────────────
440
+ if (additions.length === 0) {
441
+ console.log(clr(C.green, '✅ All defined tokens are registered.'));
442
+ } else if (verbose) {
443
+ console.log(clr(C.yellow, `⚠️ ${additions.length} unregistered token(s):`));
444
+ additions.forEach(t => console.log(clr(C.grey, ` + ${t}`)));
445
+ } else {
446
+ console.log(clr(C.yellow, `⚠️ ${additions.length} unregistered token(s). Use --verbose to list.`));
447
+ }
448
+
449
+ // ── Theme completeness ────────────────────────────────────
450
+ console.log('');
451
+ console.log(clr(C.bold, ' Theme Completeness'));
452
+ console.log(clr(C.grey, ' ' + '─'.repeat(40)));
453
+
454
+ for (const [theme, missing] of Object.entries(themeGaps)) {
455
+ const icon = missing.length === 0 ? clr(C.green, '✅') : clr(C.red, '❌');
456
+ const label = theme.padEnd(8);
457
+ if (missing.length === 0) {
458
+ console.log(` ${icon} ${label} — all required tokens present`);
459
+ } else {
460
+ console.log(` ${icon} ${label} — ${missing.length} missing:`);
461
+ missing.forEach(t => console.log(clr(C.red, ` – ${t}`)));
462
+ }
463
+ }
464
+
465
+ // ── Warnings ──────────────────────────────────────────────
466
+ if (warnings.length > 0) {
467
+ console.log('');
468
+ warnings.forEach(w => console.log(clr(C.yellow, ` ⚡ ${w}`)));
469
+ }
470
+
471
+ // ── Final verdict ─────────────────────────────────────────
472
+ console.log('');
473
+ console.log(clr(C.grey, '─'.repeat(54)));
474
+ if (pass) {
475
+ console.log(clr(C.bold + C.green, '✨ Theme contract STABLE. No issues found.'));
476
+ } else {
477
+ console.log(clr(C.bold + C.red, '💥 Theme contract UNSTABLE. Fix the issues above.'));
478
+ }
479
+ console.log('');
480
+ }