@finggujadhav/compiler 0.9.5 → 0.9.7

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,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * FingguFlux CLI
4
- * v0.9.5 Accessibility Hardening
4
+ * v0.9.7-RC Release Candidate Preparation
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';
11
+ import {
12
+ generateSnapshot, writeSnapshot, runSnapshotCompare, printSnapshotReport
13
+ } from './snapshot.js';
10
14
 
11
15
  const args = process.argv.slice(2);
12
16
  const command = args[0] || 'build';
@@ -34,9 +38,15 @@ async function main() {
34
38
  case 'a11y':
35
39
  await runA11y();
36
40
  break;
41
+ case 'theme-check':
42
+ await runThemeCheckCommand();
43
+ break;
44
+ case 'snapshot':
45
+ await runSnapshotCommand();
46
+ break;
37
47
  default:
38
48
  console.error(`Unknown command: ${command}`);
39
- console.log('Available commands: build, analyze, doctor, a11y');
49
+ console.log('Available commands: build, analyze, doctor, a11y, theme-check, snapshot');
40
50
  process.exit(1);
41
51
  }
42
52
  }
@@ -219,6 +229,72 @@ async function runA11y() {
219
229
  }
220
230
  }
221
231
 
232
+ async function runThemeCheckCommand() {
233
+ // Locate the tokens CSS
234
+ const possiblePaths = [
235
+ path.resolve('./packages/core/tokens.css'),
236
+ path.resolve('./node_modules/@finggujadhav/core/tokens.css'),
237
+ path.resolve('../../packages/core/tokens.css'),
238
+ ];
239
+ const tokensPath = possiblePaths.find(p => fs.existsSync(p));
240
+ if (!tokensPath) {
241
+ console.error('❌ tokens.css not found. Searched:');
242
+ possiblePaths.forEach(p => console.error(' ' + p));
243
+ process.exit(1);
244
+ }
245
+
246
+ const css = fs.readFileSync(tokensPath, 'utf8');
247
+ const verbose = args.includes('--verbose');
248
+
249
+ let result;
250
+ try {
251
+ result = runThemeCheck(css);
252
+ } catch (e) {
253
+ console.error('❌ theme-check failed:', e.message);
254
+ process.exit(1);
255
+ }
256
+
257
+ printThemeCheckReport(result, { verbose });
258
+
259
+ if (!result.pass) process.exit(1);
260
+ }
261
+
262
+ async function runSnapshotCommand() {
263
+ const doWrite = args.includes('--write');
264
+ const doCompare = args.includes('--compare');
265
+ const verbose = args.includes('--verbose');
266
+
267
+ if (!doWrite && !doCompare) {
268
+ console.error('Usage: finggu snapshot --write (generate/update baseline)');
269
+ console.error(' finggu snapshot --compare (CI break-guard diff)');
270
+ process.exit(1);
271
+ }
272
+
273
+ if (doWrite) {
274
+ console.log('\n📸 Generating API surface snapshot…');
275
+ const snap = writeSnapshot();
276
+ const dim = snap.surface;
277
+ console.log(` ✅ CSS tokens : ${dim.cssTokens.length}`);
278
+ console.log(` ✅ JS exports : ${dim.jsExports.length}`);
279
+ console.log(` ✅ CLI commands : ${dim.cliCommands.length}`);
280
+ console.log(` ✅ Component CSS : ${dim.componentFiles.length}`);
281
+ console.log(`\n✨ Snapshot written → packages/core/API_SURFACE_SNAPSHOT.json (v${snap.version})\n`);
282
+ return;
283
+ }
284
+
285
+ if (doCompare) {
286
+ let report;
287
+ try {
288
+ report = runSnapshotCompare();
289
+ } catch (e) {
290
+ console.error('\n❌', e.message);
291
+ process.exit(1);
292
+ }
293
+ printSnapshotReport(report, verbose);
294
+ if (!report.pass) process.exit(1);
295
+ }
296
+ }
297
+
222
298
  main().catch(err => {
223
299
  console.error('Command failed:', err);
224
300
  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.7",
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.5",
3
+ "version": "0.9.7",
4
4
  "description": "The FingguFlux static analysis and selector hardening engine.",
5
5
  "main": "engine.js",
6
6
  "bin": {
@@ -9,9 +9,18 @@
9
9
  "files": [
10
10
  "engine.js",
11
11
  "scanner.js",
12
- "cli.js"
12
+ "cli.js",
13
+ "a11y.js",
14
+ "theme-check.js",
15
+ "snapshot.js"
13
16
  ],
14
17
  "type": "module",
18
+ "scripts": {
19
+ "test": "node --test test/theme-check.test.js",
20
+ "test:snapshot": "node --test test/snapshot.test.js",
21
+ "test:all": "node --test test/**/*.test.js",
22
+ "release-check": "node cli.js theme-check && node cli.js snapshot --compare"
23
+ },
15
24
  "engines": {
16
25
  "node": ">=16.0.0"
17
26
  },
package/snapshot.js ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * FingguFlux API Surface Snapshot Engine
3
+ * v0.9.7-RC – Release Candidate Preparation
4
+ *
5
+ * Generates an authoritative, machine-readable snapshot of the ENTIRE
6
+ * public API surface and diffs it against a stored baseline to detect:
7
+ * • BREAKING: removed CSS tokens
8
+ * • BREAKING: removed JS exports
9
+ * • BREAKING: removed CLI commands
10
+ * • BREAKING: removed component CSS files
11
+ * • WARNING: added tokens (unregistered additions)
12
+ * • WARNING: deprecated APIs approaching removal date
13
+ *
14
+ * Zero runtime overhead — this module is a build/CI tool only.
15
+ * It produces no browser-bound code.
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+
24
+ // ─── Paths ────────────────────────────────────────────────────────────────────
25
+
26
+ const CORE_DIR = path.resolve(__dirname, '../core');
27
+ const COMPILER_DIR = path.resolve(__dirname, '../compiler');
28
+ const JS_HELPER_DIR = path.resolve(__dirname, '../js-helper');
29
+ const SNAPSHOT_PATH = path.resolve(CORE_DIR, 'API_SURFACE_SNAPSHOT.json');
30
+ const REGISTRY_PATH = path.resolve(CORE_DIR, 'TOKENS_REGISTRY.json');
31
+ const DEPRECATION_PATH = path.resolve(CORE_DIR, 'DEPRECATION_LOG.json');
32
+
33
+ // ─── CSS token extractor ──────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Extract all --ff-* CSS custom property definitions from a CSS string.
37
+ * Excludes var() references (same logic as theme-check.js).
38
+ * @param {string} css
39
+ * @returns {string[]} sorted token names
40
+ */
41
+ export function extractCSSTokens(css) {
42
+ const tokens = new Set();
43
+ const re = /(--ff-[\w-]+)\s*:/g;
44
+ let m;
45
+ while ((m = re.exec(css)) !== null) {
46
+ const before = css.slice(0, m.index);
47
+ const lastVar = before.lastIndexOf('var(');
48
+ const lastClose = before.lastIndexOf(')');
49
+ if (lastVar !== -1 && lastVar > lastClose) continue;
50
+ tokens.add(m[1]);
51
+ }
52
+ return [...tokens].sort();
53
+ }
54
+
55
+ /**
56
+ * Collect all --ff-* CSS tokens from tokens.css (includes dark overrides).
57
+ * @returns {string[]}
58
+ */
59
+ export function collectCSSTokens() {
60
+ const tokensCss = path.join(CORE_DIR, 'tokens.css');
61
+ if (!fs.existsSync(tokensCss)) return [];
62
+ return extractCSSTokens(fs.readFileSync(tokensCss, 'utf8'));
63
+ }
64
+
65
+ // ─── Component surface collector ─────────────────────────────────────────────
66
+
67
+ /**
68
+ * Collect all component CSS filenames published in @finggujadhav/core.
69
+ * @returns {string[]} sorted list of component file basenames (e.g. "button.css")
70
+ */
71
+ export function collectComponentFiles() {
72
+ const compDir = path.join(CORE_DIR, 'components');
73
+ if (!fs.existsSync(compDir)) return [];
74
+ return fs.readdirSync(compDir)
75
+ .filter(f => f.endsWith('.css'))
76
+ .sort();
77
+ }
78
+
79
+ // ─── JS export collector ─────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Extract named exports from a TypeScript source file.
83
+ * Handles: export function/class/const/type/interface/enum declarations
84
+ * and `export { ... }` re-export blocks.
85
+ * @param {string} src file content
86
+ * @returns {string[]} sorted export names
87
+ */
88
+ export function extractTSExports(src) {
89
+ const names = new Set();
90
+
91
+ // Named declarations: export function foo / export const foo / export type Foo / etc.
92
+ const declRe = /^export\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum)\s+([\w$]+)/gm;
93
+ let m;
94
+ while ((m = declRe.exec(src)) !== null) names.add(m[1]);
95
+
96
+ // Re-export blocks: export { foo, bar as baz }
97
+ const blockRe = /export\s*\{([^}]+)\}/g;
98
+ while ((m = blockRe.exec(src)) !== null) {
99
+ m[1].split(',').forEach(part => {
100
+ const alias = part.trim().split(/\s+as\s+/);
101
+ const name = (alias[1] || alias[0]).trim();
102
+ if (name) names.add(name);
103
+ });
104
+ }
105
+
106
+ return [...names].sort();
107
+ }
108
+
109
+ /**
110
+ * Collect all public JS exports from @finggujadhav/js-helper src.
111
+ * @returns {string[]}
112
+ */
113
+ export function collectJSExports() {
114
+ const srcDir = path.join(JS_HELPER_DIR, 'src');
115
+ if (!fs.existsSync(srcDir)) return [];
116
+
117
+ const allExports = new Set();
118
+ const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.ts') && f !== 'index.ts');
119
+
120
+ for (const file of files) {
121
+ const src = fs.readFileSync(path.join(srcDir, file), 'utf8');
122
+ extractTSExports(src).forEach(e => allExports.add(e));
123
+ }
124
+ return [...allExports].sort();
125
+ }
126
+
127
+ // ─── CLI command surface collector ───────────────────────────────────────────
128
+
129
+ /**
130
+ * Extract CLI command names from cli.js by scanning the switch statement.
131
+ * @returns {string[]} sorted command names
132
+ */
133
+ export function collectCLICommands() {
134
+ const cliPath = path.join(COMPILER_DIR, 'cli.js');
135
+ if (!fs.existsSync(cliPath)) return [];
136
+
137
+ const src = fs.readFileSync(cliPath, 'utf8');
138
+ const commands = new Set();
139
+ const re = /case\s+'([\w:-]+)'\s*:/g;
140
+ let m;
141
+ while ((m = re.exec(src)) !== null) commands.add(m[1]);
142
+ return [...commands].sort();
143
+ }
144
+
145
+ // ─── Snapshot builder ─────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Generate a complete API surface snapshot object.
149
+ * @returns {{ generatedAt: string, version: string, surface: object }}
150
+ */
151
+ export function generateSnapshot() {
152
+ // Read version from core package.json
153
+ const corePkg = JSON.parse(fs.readFileSync(path.join(CORE_DIR, 'package.json'), 'utf8'));
154
+
155
+ return {
156
+ $schema: 'https://fingguflux.dev/schemas/api-surface-snapshot.json',
157
+ generatedAt: new Date().toISOString(),
158
+ version: corePkg.version,
159
+ surface: {
160
+ cssTokens: collectCSSTokens(),
161
+ jsExports: collectJSExports(),
162
+ cliCommands: collectCLICommands(),
163
+ componentFiles: collectComponentFiles(),
164
+ },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Write the snapshot to disk at packages/core/API_SURFACE_SNAPSHOT.json.
170
+ * @param {object} [snapshotOverride] optionally pass a pre-built snapshot
171
+ * @returns {object} the written snapshot
172
+ */
173
+ export function writeSnapshot(snapshotOverride) {
174
+ const snap = snapshotOverride ?? generateSnapshot();
175
+ fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(snap, null, 4), 'utf8');
176
+ return snap;
177
+ }
178
+
179
+ // ─── Snapshot comparator ──────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Compare two API surface sets and produce structured diff.
183
+ * @param {string[]} baseline items in the stored snapshot
184
+ * @param {string[]} current items in the freshly generated snapshot
185
+ * @param {string} label human-readable label for reporting
186
+ * @returns {{ removed: string[], added: string[] }}
187
+ */
188
+ export function diffSurface(baseline, current, label) {
189
+ const baseSet = new Set(baseline);
190
+ const currentSet = new Set(current);
191
+
192
+ const removed = [...baseSet].filter(x => !currentSet.has(x)).sort();
193
+ const added = [...currentSet].filter(x => !baseSet.has(x)).sort();
194
+
195
+ return { label, removed, added };
196
+ }
197
+
198
+ /**
199
+ * Load the deprecation log.
200
+ * @returns {Array<{name:string, type:string, since:string, until:string, replacement:string|null, reason:string}>}
201
+ */
202
+ export function loadDeprecationLog() {
203
+ if (!fs.existsSync(DEPRECATION_PATH)) return [];
204
+ return JSON.parse(fs.readFileSync(DEPRECATION_PATH, 'utf8')).deprecations ?? [];
205
+ }
206
+
207
+ /**
208
+ * Check for deprecated items that are past their `until` version and
209
+ * should have been removed — these are breaking-change guards.
210
+ * @param {string} currentVersion e.g. "0.9.7"
211
+ * @returns {Array} overdue deprecations
212
+ */
213
+ export function detectOverdueDeprecations(currentVersion) {
214
+ const log = loadDeprecationLog();
215
+ return log.filter(d => {
216
+ if (!d.until) return false;
217
+ return compareVersions(d.until, currentVersion) < 0;
218
+ });
219
+ }
220
+
221
+ /** Simple semver comparator (major.minor.patch only). */
222
+ function compareVersions(a, b) {
223
+ const pa = a.split('.').map(Number);
224
+ const pb = b.split('.').map(Number);
225
+ for (let i = 0; i < 3; i++) {
226
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
227
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
228
+ }
229
+ return 0;
230
+ }
231
+
232
+ // ─── Snapshot comparison runner ───────────────────────────────────────────────
233
+
234
+ /**
235
+ * Full snapshot comparison pipeline.
236
+ * Loads the stored baseline, generates a fresh snapshot, diffs all surface
237
+ * dimensions, detects overdue deprecations, and returns a structured report.
238
+ *
239
+ * @param {object} [opts]
240
+ * @param {object} [opts.currentSnapshot] override the live snapshot (for tests)
241
+ * @param {object} [opts.baselineSnapshot] override the stored baseline (for tests)
242
+ * @param {string} [opts.currentVersion] override version string (for tests)
243
+ * @returns {{
244
+ * pass: boolean,
245
+ * breakingChanges: string[],
246
+ * warnings: string[],
247
+ * diffs: object[],
248
+ * overdueDeprecations: object[]
249
+ * }}
250
+ */
251
+ export function runSnapshotCompare(opts = {}) {
252
+ // --- Load baseline ---
253
+ const baseline = opts.baselineSnapshot ?? (() => {
254
+ if (!fs.existsSync(SNAPSHOT_PATH)) {
255
+ throw new Error(
256
+ 'API_SURFACE_SNAPSHOT.json not found. ' +
257
+ "Run 'finggu snapshot --write' to generate the initial baseline."
258
+ );
259
+ }
260
+ return JSON.parse(fs.readFileSync(SNAPSHOT_PATH, 'utf8'));
261
+ })();
262
+
263
+ // --- Generate current state ---
264
+ const current = opts.currentSnapshot ?? generateSnapshot();
265
+
266
+ // --- Diff each surface dimension ---
267
+ const diffs = [
268
+ diffSurface(baseline.surface.cssTokens, current.surface.cssTokens, 'CSS Tokens'),
269
+ diffSurface(baseline.surface.jsExports, current.surface.jsExports, 'JS Exports'),
270
+ diffSurface(baseline.surface.cliCommands, current.surface.cliCommands, 'CLI Commands'),
271
+ diffSurface(baseline.surface.componentFiles, current.surface.componentFiles, 'Component Files'),
272
+ ];
273
+
274
+ const breakingChanges = [];
275
+ const warnings = [];
276
+
277
+ for (const diff of diffs) {
278
+ // Removals are BREAKING
279
+ for (const item of diff.removed) {
280
+ breakingChanges.push(`[BREAKING] ${diff.label}: '${item}' was removed from the public API surface.`);
281
+ }
282
+ // Additions are warnings (undocumented surface growth)
283
+ for (const item of diff.added) {
284
+ warnings.push(`[WARN] ${diff.label}: '${item}' is new — ensure it is intentional and documented.`);
285
+ }
286
+ }
287
+
288
+ // --- Overdue deprecations (items past their `until` date are breaking) ---
289
+ const currentVersion = opts.currentVersion ?? current.version;
290
+ const overdue = detectOverdueDeprecations(currentVersion);
291
+ for (const d of overdue) {
292
+ breakingChanges.push(
293
+ `[BREAKING] Deprecation overdue: '${d.name}' (${d.type}) was scheduled for removal in v${d.until} ` +
294
+ `but is still present. Remove it or extend the deprecation window.`
295
+ );
296
+ }
297
+
298
+ return {
299
+ pass: breakingChanges.length === 0,
300
+ breakingChanges,
301
+ warnings,
302
+ diffs,
303
+ overdueDeprecations: overdue,
304
+ baselineVersion: baseline.version,
305
+ currentVersion,
306
+ };
307
+ }
308
+
309
+ // ─── CLI printer ──────────────────────────────────────────────────────────────
310
+
311
+ const C = {
312
+ reset: '\x1b[0m',
313
+ bold: '\x1b[1m',
314
+ red: '\x1b[31m',
315
+ green: '\x1b[32m',
316
+ yellow: '\x1b[33m',
317
+ cyan: '\x1b[36m',
318
+ gray: '\x1b[90m',
319
+ };
320
+
321
+ const line = (ch = '─', n = 54) => ch.repeat(n);
322
+
323
+ export function printSnapshotReport(report, verbose = false) {
324
+ const { pass, breakingChanges, warnings, diffs, overdueDeprecations,
325
+ baselineVersion, currentVersion } = report;
326
+
327
+ console.log('');
328
+ console.log(`${C.bold}${C.cyan}📸 FingguFlux API Surface Snapshot — Drift Audit${C.reset}`);
329
+ console.log(C.gray + line() + C.reset);
330
+ console.log(` Baseline : ${C.bold}v${baselineVersion}${C.reset}`);
331
+ console.log(` Current : ${C.bold}v${currentVersion}${C.reset}`);
332
+ console.log('');
333
+
334
+ // Surface summary
335
+ for (const diff of diffs) {
336
+ const removedCount = diff.removed.length;
337
+ const addedCount = diff.added.length;
338
+ const icon = removedCount ? C.red + '✖' :
339
+ addedCount ? C.yellow + '⚠' :
340
+ C.green + '✔';
341
+ console.log(` ${icon}${C.reset} ${diff.label.padEnd(18)} ` +
342
+ `${removedCount ? C.red + removedCount + ' removed' + C.reset : '—'} ` +
343
+ `${addedCount ? C.yellow + addedCount + ' added' + C.reset : ''}`);
344
+ }
345
+
346
+ // Breaking changes
347
+ if (breakingChanges.length > 0) {
348
+ console.log('');
349
+ console.log(C.red + C.bold + ` ⛔ ${breakingChanges.length} Breaking Change(s) Detected` + C.reset);
350
+ for (const msg of breakingChanges) {
351
+ console.log(` ${C.red}${msg}${C.reset}`);
352
+ }
353
+ }
354
+
355
+ // Warnings
356
+ if (warnings.length > 0 && (verbose || breakingChanges.length === 0)) {
357
+ console.log('');
358
+ console.log(C.yellow + C.bold + ` ⚠ ${warnings.length} Warning(s)` + C.reset);
359
+ for (const msg of warnings) {
360
+ console.log(` ${C.yellow}${msg}${C.reset}`);
361
+ }
362
+ }
363
+
364
+ // Overdue deprecations
365
+ if (overdueDeprecations.length > 0) {
366
+ console.log('');
367
+ console.log(C.red + C.bold + ` 🚨 ${overdueDeprecations.length} Overdue Deprecation(s)` + C.reset);
368
+ for (const d of overdueDeprecations) {
369
+ console.log(` ${C.red}• ${d.name} (${d.type}) — scheduled removal: v${d.until}${C.reset}`);
370
+ if (d.replacement) console.log(` ${C.gray}→ replacement: ${d.replacement}${C.reset}`);
371
+ }
372
+ }
373
+
374
+ console.log('');
375
+ console.log(C.gray + line() + C.reset);
376
+
377
+ if (pass) {
378
+ console.log(`${C.green}${C.bold}✨ API surface STABLE. No breaking changes detected.${C.reset}`);
379
+ } else {
380
+ console.log(`${C.red}${C.bold}🚫 API surface has BREAKING CHANGES. Release blocked.${C.reset}`);
381
+ console.log(`${C.gray} Fix all [BREAKING] issues before bumping version.${C.reset}`);
382
+ }
383
+ console.log('');
384
+ }
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
+ }