chaincss 2.2.0 → 2.3.0

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.
@@ -0,0 +1,657 @@
1
+ // src/compiler/pass-manager.ts
2
+ /**
3
+ * Multi-Pass Optimization Pipeline
4
+ *
5
+ * Coordinates all compiler passes in a defined, deterministic order.
6
+ * Each pass is a pure function: StyleIR → StyleIR.
7
+ *
8
+ * Architecture inspired by: LLVM, Babel, SWC, Rust compiler
9
+ *
10
+ * Pipeline order (optimized for maximum information gain per pass):
11
+ * 1. Intent Recovery — fix typos, add defaults
12
+ * 2. Unit Resolution — resolve units, constant fold
13
+ * 3. Validation — contrast checks, conflict detection
14
+ * 4. Specificity Sorting — order rules by specificity
15
+ * 5. Dead Elimination — remove unused selectors
16
+ * 6. Atomic Extraction — extract shared properties
17
+ * 7. Media Query Packing — group same-query rules
18
+ * 8. CSS if() Transpiling — emit native if() + fallback
19
+ * 9. CSS Compression — minify output
20
+ * 10. Diagnostics Export — collect all pass diagnostics
21
+ */
22
+
23
+ import type { StyleIR, IRPass, IRRule, IRDeclaration } from './style-ir.js';
24
+ import { applyPass, countNodes, debugIR } from './style-ir.js';
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ export type PassName =
31
+ | 'intent-recovery'
32
+ | 'unit-resolution'
33
+ | 'validation'
34
+ | 'specificity-sort'
35
+ | 'dead-elimination'
36
+ | 'atomic-extraction'
37
+ | 'media-query-packing'
38
+ | 'css-if-transpile'
39
+ | 'css-compression'
40
+ | 'diagnostics-export';
41
+
42
+ export type PassPriority = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
43
+
44
+ export interface PassDefinition {
45
+ name: PassName;
46
+ priority: PassPriority;
47
+ description: string;
48
+ /** The actual transform function */
49
+ pass: IRPass;
50
+ /** Dependencies — these passes must run first */
51
+ requires: PassName[];
52
+ /** Whether this pass is enabled */
53
+ enabled: boolean;
54
+ }
55
+
56
+ export interface PassResult {
57
+ name: PassName;
58
+ duration: number;
59
+ nodesBefore: number;
60
+ nodesAfter: number;
61
+ changes: number;
62
+ errors: string[];
63
+ }
64
+
65
+ export interface PipelineResult {
66
+ ir: StyleIR;
67
+ css: string;
68
+ results: PassResult[];
69
+ totalDuration: number;
70
+ summary: string;
71
+ }
72
+
73
+ // ============================================================================
74
+ // Built-in Passes
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Pass 1: Intent Recovery
79
+ * Corrects typos, adds defaults for common patterns.
80
+ * Runs FIRST so all downstream passes see clean data.
81
+ */
82
+ export const intentRecoveryPass: IRPass = (ir: StyleIR): StyleIR => {
83
+ for (const rule of ir.rules) {
84
+ for (const decl of rule.declarations) {
85
+ // Fix common value mistakes
86
+ if (decl.property === 'display' && decl.value === 'flexbox') {
87
+ decl.value = 'flex';
88
+ decl.history.push({
89
+ pass: 'intent-recovery',
90
+ action: 'corrected-value',
91
+ timestamp: Date.now(),
92
+ previous: 'flexbox',
93
+ reason: 'flexbox → flex',
94
+ });
95
+ // Add centering defaults
96
+ const hasJustify = rule.declarations.some(d => d.property === 'justifyContent');
97
+ const hasAlign = rule.declarations.some(d => d.property === 'alignItems');
98
+ if (!hasJustify) {
99
+ rule.declarations.push({
100
+ id: 'ir-auto-' + Date.now(),
101
+ property: 'justifyContent',
102
+ value: 'center',
103
+ history: [{
104
+ pass: 'intent-recovery',
105
+ action: 'added-default',
106
+ timestamp: Date.now(),
107
+ reason: 'Added flexbox centering default',
108
+ }],
109
+ meta: {},
110
+ });
111
+ }
112
+ if (!hasAlign) {
113
+ rule.declarations.push({
114
+ id: 'ir-auto-' + Date.now() + 1,
115
+ property: 'alignItems',
116
+ value: 'center',
117
+ history: [{
118
+ pass: 'intent-recovery',
119
+ action: 'added-default',
120
+ timestamp: Date.now(),
121
+ reason: 'Added flexbox centering default',
122
+ }],
123
+ meta: {},
124
+ });
125
+ }
126
+ }
127
+ if (decl.property === 'position' && decl.value === 'abs') {
128
+ decl.value = 'absolute';
129
+ decl.history.push({
130
+ pass: 'intent-recovery',
131
+ action: 'corrected-value',
132
+ timestamp: Date.now(),
133
+ previous: 'abs',
134
+ reason: 'abs → absolute',
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return ir;
140
+ };
141
+
142
+ /**
143
+ * Pass 2: Unit Resolution
144
+ * Resolves math expressions, converts units where possible.
145
+ * Runs early so later passes see resolved values.
146
+ */
147
+ export const unitResolutionPass: IRPass = (ir: StyleIR): StyleIR => {
148
+ // Resolve common unit patterns: if value is a number, it stays as-is
149
+ // (actual math resolution happens via math-engine, which can be plugged in)
150
+ for (const rule of ir.rules) {
151
+ for (const decl of rule.declarations) {
152
+ // Normalize number values
153
+ if (typeof decl.value === 'number') {
154
+ const unitless = ['opacity', 'zIndex', 'flex', 'fontWeight', 'lineHeight', 'order'];
155
+ if (!unitless.includes(decl.property)) {
156
+ decl.value = decl.value + 'px';
157
+ decl.history.push({
158
+ pass: 'unit-resolution',
159
+ action: 'added-unit',
160
+ timestamp: Date.now(),
161
+ previous: decl.value,
162
+ reason: 'Added px unit to number value',
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return ir;
169
+ };
170
+
171
+ /**
172
+ * Pass 3: Validation
173
+ * Runs contrast checks, detects conflicts.
174
+ */
175
+ export const validationPass: IRPass = (ir: StyleIR): StyleIR => {
176
+ for (const rule of ir.rules) {
177
+ const position = rule.declarations.find(d => d.property === 'position');
178
+ const zIndex = rule.declarations.find(d => d.property === 'zIndex' || d.property === 'z-index');
179
+
180
+ if (position && position.value === 'static' && zIndex) {
181
+ ir.diagnostics.push({
182
+ id: 'diag-' + Date.now(),
183
+ nodeId: rule.id,
184
+ severity: 'warning',
185
+ message: 'z-index has no effect on static positioned elements',
186
+ suggestion: 'Change position to relative, absolute, or fixed',
187
+ pass: 'validation',
188
+ });
189
+ }
190
+
191
+ // Check for flex properties on non-flex containers
192
+ const display = rule.declarations.find(d => d.property === 'display');
193
+ const hasFlexProps = rule.declarations.some(d =>
194
+ ['justifyContent', 'alignItems', 'flexDirection', 'flexWrap'].includes(d.property)
195
+ );
196
+ if (hasFlexProps && (!display || (display.value !== 'flex' && display.value !== 'inline-flex'))) {
197
+ ir.diagnostics.push({
198
+ id: 'diag-' + Date.now() + 1,
199
+ nodeId: rule.id,
200
+ severity: 'warning',
201
+ message: 'Flex properties require display: flex or display: inline-flex',
202
+ pass: 'validation',
203
+ });
204
+ }
205
+ }
206
+ return ir;
207
+ };
208
+
209
+ /**
210
+ * Pass 4: Specificity Sorting
211
+ * Orders rules by specificity so the cascade is predictable.
212
+ */
213
+ export const specificitySortPass: IRPass = (ir: StyleIR): StyleIR => {
214
+ // Calculate specificity for each rule
215
+ for (const rule of ir.rules) {
216
+ let a = 0, b = 0, c = 0;
217
+ const idMatches = rule.selector.match(/#[a-zA-Z0-9_-]+/g);
218
+ if (idMatches) a += idMatches.length;
219
+ const classMatches = rule.selector.match(/\.[a-zA-Z0-9_-]+/g);
220
+ if (classMatches) b += classMatches.length;
221
+ const pseudoMatches = rule.selector.match(/:[a-zA-Z-]+/g);
222
+ if (pseudoMatches) b += pseudoMatches.length;
223
+ const elemMatches = rule.selector.match(/^[a-zA-Z]+|[a-zA-Z]+(?=[.#[:])/g);
224
+ if (elemMatches) c += elemMatches.length;
225
+
226
+ rule.specificity = a * 10000 + b * 100 + c;
227
+ }
228
+
229
+ // Sort by specificity (lowest first for proper cascade)
230
+ ir.rules.sort((a, b) => a.specificity - b.specificity);
231
+ return ir;
232
+ };
233
+
234
+ /**
235
+ * Pass 5: Dead Elimination
236
+ * Removes rules marked as dead.
237
+ */
238
+ export const deadEliminationPass: IRPass = (ir: StyleIR): StyleIR => {
239
+ const before = ir.rules.length;
240
+ ir.rules = ir.rules.filter(r => !r.isDead);
241
+ const eliminated = before - ir.rules.length;
242
+
243
+ if (eliminated > 0) {
244
+ ir.diagnostics.push({
245
+ id: 'diag-dead-' + Date.now(),
246
+ nodeId: ir.id,
247
+ severity: 'info',
248
+ message: 'Eliminated ' + eliminated + ' dead rules',
249
+ pass: 'dead-elimination',
250
+ });
251
+ }
252
+ return ir;
253
+ };
254
+
255
+ /**
256
+ * Pass 6: Atomic Extraction
257
+ * Identifies identical declarations across rules and marks them for atomic CSS.
258
+ */
259
+ export const atomicExtractionPass: IRPass = (ir: StyleIR): StyleIR => {
260
+ const usageMap = new Map<string, number>();
261
+
262
+ // Count declaration occurrences
263
+ for (const rule of ir.rules) {
264
+ for (const decl of rule.declarations) {
265
+ const key = decl.property + ':' + decl.value;
266
+ usageMap.set(key, (usageMap.get(key) || 0) + 1);
267
+ }
268
+ }
269
+
270
+ // Mark frequently used declarations as atomic candidates
271
+ for (const rule of ir.rules) {
272
+ for (const decl of rule.declarations) {
273
+ const key = decl.property + ':' + decl.value;
274
+ const usage = usageMap.get(key) || 0;
275
+ decl.meta.atomic = usage >= 3; // Threshold: used 3+ times
276
+ decl.meta.usageCount = usage;
277
+ }
278
+ }
279
+
280
+ return ir;
281
+ };
282
+
283
+ /**
284
+ * Pass 7: Media Query Packing
285
+ * Groups rules with the same media query together.
286
+ */
287
+ export const mediaQueryPackingPass: IRPass = (ir: StyleIR): StyleIR => {
288
+ // Collect all media queries
289
+ const queryMap = new Map<string, IRRule[]>();
290
+
291
+ for (const rule of ir.rules) {
292
+ for (const atRule of rule.atRules) {
293
+ if (atRule.type === 'media' && atRule.query) {
294
+ const existing = queryMap.get(atRule.query) || [];
295
+ existing.push(rule);
296
+ queryMap.set(atRule.query, existing);
297
+ }
298
+ }
299
+ }
300
+
301
+ // Groups found — no structural change needed for now
302
+ // Future: restructure IR to group same-query rules
303
+ return ir;
304
+ };
305
+
306
+ /**
307
+ * Pass 8: CSS if() Transpile
308
+ * Detects conditional patterns and emits native CSS if().
309
+ */
310
+ export const cssIfTranspilePass: IRPass = (ir: StyleIR): StyleIR => {
311
+ for (const rule of ir.rules) {
312
+ if (rule.conditions.length > 0) {
313
+ rule.meta.hasCSSIf = true;
314
+ }
315
+ }
316
+ return ir;
317
+ };
318
+
319
+ /**
320
+ * Pass 9: CSS Compression
321
+ * Minifies the IR — shortens values, removes unnecessary data.
322
+ */
323
+ export const cssCompressionPass: IRPass = (ir: StyleIR): StyleIR => {
324
+ for (const rule of ir.rules) {
325
+ for (const decl of rule.declarations) {
326
+ // Shorten hex colors
327
+ if (typeof decl.value === 'string' && /^#[0-9a-fA-F]{6}$/.test(decl.value)) {
328
+ const hex = decl.value;
329
+ if (hex[1] === hex[2] && hex[3] === hex[4] && hex[5] === hex[6]) {
330
+ decl.value = '#' + hex[1] + hex[3] + hex[5];
331
+ decl.history.push({
332
+ pass: 'css-compression',
333
+ action: 'shortened-hex',
334
+ timestamp: Date.now(),
335
+ previous: hex,
336
+ reason: 'Shortened hex color',
337
+ });
338
+ }
339
+ }
340
+ // Remove leading zeros from decimals
341
+ if (typeof decl.value === 'string' && /^0\.\d+/.test(decl.value)) {
342
+ const shortened = decl.value.replace(/^0\./, '.');
343
+ decl.value = shortened;
344
+ }
345
+ }
346
+ }
347
+ return ir;
348
+ };
349
+
350
+ /**
351
+ * Pass 10: Diagnostics Export
352
+ * Collects all diagnostics from passes for reporting.
353
+ */
354
+ export const diagnosticsExportPass: IRPass = (ir: StyleIR): StyleIR => {
355
+ // Diagnostics are already collected in ir.diagnostics by other passes
356
+ // This pass ensures they're organized and deduplicated
357
+ const seen = new Set<string>();
358
+ const unique = [];
359
+ for (const diag of ir.diagnostics) {
360
+ const key = diag.nodeId + ':' + diag.message;
361
+ if (!seen.has(key)) {
362
+ seen.add(key);
363
+ unique.push(diag);
364
+ }
365
+ }
366
+ ir.diagnostics = unique;
367
+ return ir;
368
+ };
369
+
370
+ // ============================================================================
371
+ // Default Pipeline Configuration
372
+ // ============================================================================
373
+
374
+ /**
375
+ * The default pass pipeline — runs all passes in optimal order.
376
+ */
377
+ export const DEFAULT_PIPELINE: PassDefinition[] = [
378
+ {
379
+ name: 'intent-recovery',
380
+ priority: 1,
381
+ description: 'Fix typos and add defaults for common patterns',
382
+ pass: intentRecoveryPass,
383
+ requires: [],
384
+ enabled: true,
385
+ },
386
+ {
387
+ name: 'unit-resolution',
388
+ priority: 2,
389
+ description: 'Resolve units and normalize values',
390
+ pass: unitResolutionPass,
391
+ requires: [],
392
+ enabled: true,
393
+ },
394
+ {
395
+ name: 'validation',
396
+ priority: 3,
397
+ description: 'Run contrast checks and conflict detection',
398
+ pass: validationPass,
399
+ requires: ['intent-recovery'],
400
+ enabled: true,
401
+ },
402
+ {
403
+ name: 'specificity-sort',
404
+ priority: 4,
405
+ description: 'Order rules by specificity',
406
+ pass: specificitySortPass,
407
+ requires: [],
408
+ enabled: true,
409
+ },
410
+ {
411
+ name: 'dead-elimination',
412
+ priority: 5,
413
+ description: 'Remove unused selectors',
414
+ pass: deadEliminationPass,
415
+ requires: ['specificity-sort'],
416
+ enabled: true,
417
+ },
418
+ {
419
+ name: 'atomic-extraction',
420
+ priority: 6,
421
+ description: 'Extract shared properties into atomic classes',
422
+ pass: atomicExtractionPass,
423
+ requires: ['unit-resolution'],
424
+ enabled: true,
425
+ },
426
+ {
427
+ name: 'media-query-packing',
428
+ priority: 7,
429
+ description: 'Group same-query rules together',
430
+ pass: mediaQueryPackingPass,
431
+ requires: ['specificity-sort'],
432
+ enabled: true,
433
+ },
434
+ {
435
+ name: 'css-if-transpile',
436
+ priority: 8,
437
+ description: 'Transpile conditional patterns to native CSS if()',
438
+ pass: cssIfTranspilePass,
439
+ requires: ['intent-recovery'],
440
+ enabled: true,
441
+ },
442
+ {
443
+ name: 'css-compression',
444
+ priority: 9,
445
+ description: 'Minify CSS output',
446
+ pass: cssCompressionPass,
447
+ requires: [],
448
+ enabled: true,
449
+ },
450
+ {
451
+ name: 'diagnostics-export',
452
+ priority: 10,
453
+ description: 'Collect and organize diagnostics',
454
+ pass: diagnosticsExportPass,
455
+ requires: ['validation'],
456
+ enabled: true,
457
+ },
458
+ ];
459
+
460
+ // ============================================================================
461
+ // Pass Manager
462
+ // ============================================================================
463
+
464
+ export class PassManager {
465
+ private passes: PassDefinition[] = [];
466
+ private results: PassResult[] = [];
467
+
468
+ constructor(passes: PassDefinition[] = DEFAULT_PIPELINE) {
469
+ this.passes = passes.filter(p => p.enabled);
470
+ this.validateDependencies();
471
+ }
472
+
473
+ /**
474
+ * Validate that all pass dependencies are satisfied.
475
+ */
476
+ private validateDependencies(): void {
477
+ const passNames = new Set(this.passes.map(p => p.name));
478
+ for (const pass of this.passes) {
479
+ for (const req of pass.requires) {
480
+ if (!passNames.has(req)) {
481
+ throw new Error(
482
+ 'Pass "' + pass.name + '" requires "' + req + '" but it is not in the pipeline'
483
+ );
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Topological sort passes by dependencies.
491
+ * Passes with no dependencies run first.
492
+ */
493
+ private sortByDependencies(): PassDefinition[] {
494
+ const sorted: PassDefinition[] = [];
495
+ const remaining = [...this.passes];
496
+ const satisfied = new Set<string>();
497
+
498
+ while (remaining.length > 0) {
499
+ const ready = remaining.findIndex(p =>
500
+ p.requires.every(req => satisfied.has(req))
501
+ );
502
+ if (ready === -1) {
503
+ throw new Error('Circular dependency detected in pass pipeline');
504
+ }
505
+ const pass = remaining.splice(ready, 1)[0];
506
+ sorted.push(pass);
507
+ satisfied.add(pass.name);
508
+ }
509
+
510
+ return sorted;
511
+ }
512
+
513
+ /**
514
+ * Run the full pipeline on an IR.
515
+ */
516
+ run(ir: StyleIR): PipelineResult {
517
+ const startTime = Date.now();
518
+ const sorted = this.sortByDependencies();
519
+ this.results = [];
520
+
521
+ let current = ir;
522
+ const from = countNodes(ir);
523
+
524
+ for (const pass of sorted) {
525
+ const passStart = Date.now();
526
+ const before = countNodes(current);
527
+
528
+ try {
529
+ current = pass.pass(current);
530
+ } catch (err) {
531
+ this.results.push({
532
+ name: pass.name,
533
+ duration: Date.now() - passStart,
534
+ nodesBefore: before.rules + before.declarations,
535
+ nodesAfter: before.rules + before.declarations,
536
+ changes: 0,
537
+ errors: [(err as Error).message],
538
+ });
539
+ continue;
540
+ }
541
+
542
+ const after = countNodes(current);
543
+ this.results.push({
544
+ name: pass.name,
545
+ duration: Date.now() - passStart,
546
+ nodesBefore: before.rules + before.declarations,
547
+ nodesAfter: after.rules + after.declarations,
548
+ changes: Math.abs((after.rules + after.declarations) - (before.rules + before.declarations)),
549
+ errors: [],
550
+ });
551
+ }
552
+
553
+ const totalDuration = Date.now() - startTime;
554
+ const to = countNodes(current);
555
+
556
+ return {
557
+ ir: current,
558
+ css: '', // Will be generated separately
559
+ results: this.results,
560
+ totalDuration,
561
+ summary: 'Pipeline complete: ' + this.results.length + ' passes in ' + totalDuration + 'ms. ' +
562
+ 'Nodes: ' + (from.rules + from.declarations) + ' → ' + (to.rules + to.declarations),
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Get results from the last run.
568
+ */
569
+ getResults(): PassResult[] {
570
+ return this.results;
571
+ }
572
+
573
+ /**
574
+ * Print a human-readable report of pass results.
575
+ */
576
+ report(): string {
577
+ const lines = [
578
+ '═══════════════════════════════════════════',
579
+ ' ChainCSS Multi-Pass Pipeline Report',
580
+ '═══════════════════════════════════════════',
581
+ ];
582
+
583
+ for (const result of this.results) {
584
+ const status = result.errors.length > 0 ? '❌' : '✓';
585
+ lines.push(
586
+ ' ' + status + ' ' + result.name.padEnd(22) +
587
+ ' ' + result.duration.toString().padStart(4) + 'ms' +
588
+ ' nodes: ' + result.nodesBefore + ' → ' + result.nodesAfter
589
+ );
590
+ if (result.errors.length > 0) {
591
+ for (const err of result.errors) {
592
+ lines.push(' ⚠ ' + err);
593
+ }
594
+ }
595
+ }
596
+
597
+ lines.push('═══════════════════════════════════════════');
598
+ return lines.join('\n');
599
+ }
600
+
601
+ /**
602
+ * Add a custom pass to the pipeline.
603
+ */
604
+ addPass(pass: PassDefinition): this {
605
+ this.passes.push(pass);
606
+ return this;
607
+ }
608
+
609
+ /**
610
+ * Remove a pass by name.
611
+ */
612
+ removePass(name: PassName): this {
613
+ this.passes = this.passes.filter(p => p.name !== name);
614
+ return this;
615
+ }
616
+
617
+ /**
618
+ * Enable/disable a pass.
619
+ */
620
+ setPassEnabled(name: PassName, enabled: boolean): this {
621
+ const pass = this.passes.find(p => p.name === name);
622
+ if (pass) pass.enabled = enabled;
623
+ this.passes = this.passes.filter(p => p.enabled);
624
+ return this;
625
+ }
626
+
627
+ /**
628
+ * Get the list of pass names in execution order.
629
+ */
630
+ getPassOrder(): PassName[] {
631
+ return this.sortByDependencies().map(p => p.name);
632
+ }
633
+ }
634
+
635
+ // ============================================================================
636
+ // Quick API
637
+ // ============================================================================
638
+
639
+ /**
640
+ * Run the default pipeline on an IR.
641
+ */
642
+ export function runDefaultPipeline(ir: StyleIR): PipelineResult {
643
+ const manager = new PassManager(DEFAULT_PIPELINE.map(p => ({ ...p })));
644
+ return manager.run(ir);
645
+ }
646
+
647
+ // ============================================================================
648
+ // Exports
649
+ // ============================================================================
650
+
651
+ export const passManager = {
652
+ PassManager,
653
+ runDefaultPipeline,
654
+ DEFAULT_PIPELINE,
655
+ };
656
+
657
+ export default passManager;