design-constraint-validator 1.0.0 → 2.0.1

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.
Files changed (121) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +229 -659
  3. package/adapters/README.md +46 -46
  4. package/adapters/css.ts +116 -116
  5. package/adapters/decisionthemes.d.ts +44 -0
  6. package/adapters/decisionthemes.d.ts.map +1 -0
  7. package/adapters/decisionthemes.js +35 -0
  8. package/adapters/decisionthemes.ts +59 -0
  9. package/adapters/js.ts +14 -14
  10. package/adapters/json.ts +45 -45
  11. package/cli/build-css.ts +32 -32
  12. package/cli/commands/build.ts +65 -65
  13. package/cli/commands/graph.d.ts.map +1 -1
  14. package/cli/commands/graph.js +26 -10
  15. package/cli/commands/graph.ts +180 -137
  16. package/cli/commands/index.ts +7 -7
  17. package/cli/commands/patch-apply.ts +80 -80
  18. package/cli/commands/patch.ts +22 -22
  19. package/cli/commands/set.d.ts.map +1 -1
  20. package/cli/commands/set.js +12 -4
  21. package/cli/commands/set.ts +239 -225
  22. package/cli/commands/utils.ts +50 -50
  23. package/cli/commands/validate.d.ts.map +1 -1
  24. package/cli/commands/validate.js +89 -33
  25. package/cli/commands/validate.ts +180 -115
  26. package/cli/commands/why.d.ts.map +1 -1
  27. package/cli/commands/why.js +86 -20
  28. package/cli/commands/why.ts +158 -46
  29. package/cli/config-schema.ts +27 -27
  30. package/cli/config.ts +35 -35
  31. package/cli/constraint-registry.d.ts +101 -0
  32. package/cli/constraint-registry.d.ts.map +1 -0
  33. package/cli/constraint-registry.js +225 -0
  34. package/cli/constraint-registry.ts +304 -0
  35. package/cli/constraints-loader.d.ts.map +1 -0
  36. package/cli/cross-axis-loader.d.ts +91 -0
  37. package/cli/cross-axis-loader.d.ts.map +1 -0
  38. package/cli/cross-axis-loader.js +222 -0
  39. package/cli/cross-axis-loader.ts +289 -0
  40. package/cli/dcv.js +4 -0
  41. package/cli/dcv.ts +111 -107
  42. package/cli/engine-helpers.d.ts.map +1 -1
  43. package/cli/graph-poset.ts +74 -74
  44. package/cli/json-output.d.ts +69 -0
  45. package/cli/json-output.d.ts.map +1 -0
  46. package/cli/json-output.js +109 -0
  47. package/cli/json-output.ts +184 -0
  48. package/cli/result.ts +27 -27
  49. package/cli/run.ts +54 -54
  50. package/cli/smoke-test.ts +40 -40
  51. package/cli/types.d.ts +6 -0
  52. package/cli/types.d.ts.map +1 -1
  53. package/cli/types.ts +84 -78
  54. package/cli/version-banner.d.ts +20 -0
  55. package/cli/version-banner.d.ts.map +1 -0
  56. package/cli/version-banner.js +49 -0
  57. package/cli/version-banner.ts +61 -0
  58. package/core/breakpoints.ts +50 -50
  59. package/core/cli-format.ts +31 -31
  60. package/core/color.ts +148 -148
  61. package/core/constraints/cross-axis.ts +114 -114
  62. package/core/constraints/monotonic-lightness.ts +38 -38
  63. package/core/constraints/monotonic.ts +74 -74
  64. package/core/constraints/threshold.ts +43 -43
  65. package/core/constraints/wcag.ts +70 -70
  66. package/core/cross-axis-config.d.ts.map +1 -1
  67. package/core/engine.d.ts +95 -0
  68. package/core/engine.d.ts.map +1 -1
  69. package/core/engine.js +22 -0
  70. package/core/engine.ts +167 -65
  71. package/core/flatten.ts +116 -116
  72. package/core/image-export.ts +48 -48
  73. package/core/index.d.ts +9 -30
  74. package/core/index.d.ts.map +1 -1
  75. package/core/index.js +7 -54
  76. package/core/index.ts +10 -72
  77. package/core/patch.ts +134 -134
  78. package/core/poset.ts +311 -311
  79. package/core/why.ts +63 -63
  80. package/package.json +96 -90
  81. package/themes/color.lg.order.json +15 -15
  82. package/themes/color.md.order.json +15 -15
  83. package/themes/color.order.json +15 -15
  84. package/themes/color.sm.order.json +15 -15
  85. package/themes/cross-axis.rules.json +35 -35
  86. package/themes/cross-axis.sm.rules.json +12 -12
  87. package/themes/layout.lg.order.json +18 -18
  88. package/themes/layout.md.order.json +18 -18
  89. package/themes/layout.order.json +18 -18
  90. package/themes/layout.sm.order.json +18 -18
  91. package/themes/spacing.order.json +14 -14
  92. package/themes/typography.lg.order.json +15 -15
  93. package/themes/typography.md.order.json +15 -15
  94. package/themes/typography.order.json +15 -15
  95. package/themes/typography.sm.order.json +15 -15
  96. package/cli/engine-helpers.d.ts +0 -8
  97. package/cli/engine-helpers.js +0 -70
  98. package/cli/engine-helpers.ts +0 -61
  99. package/core/cross-axis-config.d.ts +0 -5
  100. package/core/cross-axis-config.js +0 -144
  101. package/core/cross-axis-config.ts +0 -152
  102. package/dist/test-overrides-removal.json +0 -4
  103. package/dist/tmp.patch.json +0 -35
  104. package/tokens/overrides/base.json +0 -22
  105. package/tokens/overrides/lg.json +0 -20
  106. package/tokens/overrides/md.json +0 -16
  107. package/tokens/overrides/sm.json +0 -16
  108. package/tokens/overrides/viol.color.json +0 -6
  109. package/tokens/overrides/viol.typography.json +0 -6
  110. package/tokens/tokens.demo-violations.json +0 -116
  111. package/tokens/tokens.example.json +0 -128
  112. package/tokens/tokens.json +0 -67
  113. package/tokens/tokens.multi-violations.json +0 -21
  114. package/tokens/tokens.schema.d.ts +0 -2298
  115. package/tokens/tokens.schema.d.ts.map +0 -1
  116. package/tokens/tokens.schema.js +0 -148
  117. package/tokens/tokens.schema.ts +0 -196
  118. package/tokens/tokens.test.json +0 -38
  119. package/tokens/tokens.touch-violation.json +0 -8
  120. package/tokens/typography.classes.css +0 -11
  121. package/tokens/typography.css +0 -20
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Centralized constraint discovery and loading.
3
+ *
4
+ * This module provides a single source of truth for determining which constraints
5
+ * are active for a given validation run. It replaces the scattered constraint-loading
6
+ * logic previously split across engine-helpers.ts and constraints-loader.ts.
7
+ *
8
+ * Design principles:
9
+ * - Constraints are discovered from config and filesystem in ONE place
10
+ * - Core modules receive in-memory data (no filesystem access)
11
+ * - All entry points (validate, set, graph) use this registry for consistency
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import type { Engine } from '../core/engine.js';
17
+ import type { Breakpoint } from '../core/breakpoints.js';
18
+ import type { DcvConfig } from './types.js';
19
+ import { MonotonicPlugin, parseSize as parseSizePx } from '../core/constraints/monotonic.js';
20
+ import { MonotonicLightness } from '../core/constraints/monotonic-lightness.js';
21
+ import { WcagContrastPlugin } from '../core/constraints/wcag.js';
22
+ import { ThresholdPlugin } from '../core/constraints/threshold.js';
23
+ import { CrossAxisPlugin } from '../core/constraints/cross-axis.js';
24
+ import { loadCrossAxisRules } from './cross-axis-loader.js';
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ export type OrderRule = [string, '<=' | '>=', string];
31
+
32
+ export type WcagRule = {
33
+ fg: string;
34
+ bg: string;
35
+ min: number;
36
+ where: string;
37
+ backdrop?: string;
38
+ };
39
+
40
+ export type ThresholdRule = {
41
+ id: string;
42
+ op: '<=' | '>=';
43
+ valuePx: number;
44
+ where?: string;
45
+ level?: 'error' | 'warn';
46
+ };
47
+
48
+ /**
49
+ * Represents a constraint source discovered from config or filesystem.
50
+ */
51
+ export type ConstraintSource =
52
+ | { type: 'builtin-wcag'; enabled: boolean }
53
+ | { type: 'builtin-threshold'; enabled: boolean }
54
+ | { type: 'config-wcag'; rules: WcagRule[] }
55
+ | { type: 'order-file'; axis: string; orders: OrderRule[]; path: string }
56
+ | { type: 'lightness-file'; orders: OrderRule[]; path: string }
57
+ | { type: 'cross-axis-file'; path: string; bp?: Breakpoint }
58
+ | { type: 'custom-threshold'; rules: ThresholdRule[] };
59
+
60
+ export type DiscoveryOptions = {
61
+ config: DcvConfig;
62
+ basePath?: string;
63
+ bp?: Breakpoint;
64
+ constraintsDir?: string;
65
+ };
66
+
67
+ export type AttachOptions = {
68
+ knownIds: Set<string>;
69
+ crossAxisDebug?: boolean;
70
+ };
71
+
72
+ // ============================================================================
73
+ // Discovery
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Discover all constraint sources for a given configuration and breakpoint.
78
+ *
79
+ * This function scans the filesystem and config to determine which constraints
80
+ * should be active, but does not load or attach them yet.
81
+ *
82
+ * @param opts Discovery options (config, basePath, breakpoint)
83
+ * @returns Array of constraint sources
84
+ */
85
+ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[] {
86
+ const { config, basePath = '.', bp, constraintsDir = 'themes' } = opts;
87
+ const sources: ConstraintSource[] = [];
88
+ const constraintsCfg = config.constraints ?? {};
89
+
90
+ // 1. Built-in WCAG defaults
91
+ const enableBuiltInWcag =
92
+ constraintsCfg.enableBuiltInWcagDefaults === undefined ? true : !!constraintsCfg.enableBuiltInWcagDefaults;
93
+
94
+ if (enableBuiltInWcag) {
95
+ sources.push({ type: 'builtin-wcag', enabled: true });
96
+ }
97
+
98
+ // 2. Built-in threshold (44px touch target)
99
+ const enableBuiltInThreshold =
100
+ constraintsCfg.enableBuiltInThreshold === undefined ? true : !!constraintsCfg.enableBuiltInThreshold;
101
+
102
+ if (enableBuiltInThreshold) {
103
+ sources.push({ type: 'builtin-threshold', enabled: true });
104
+ }
105
+
106
+ // 3. Config-defined WCAG rules
107
+ if (constraintsCfg.wcag && Array.isArray(constraintsCfg.wcag) && constraintsCfg.wcag.length > 0) {
108
+ const rules: WcagRule[] = constraintsCfg.wcag.map((r: any) => ({
109
+ fg: r.foreground,
110
+ bg: r.background,
111
+ min: r.ratio || 4.5,
112
+ where: r.description || 'Unknown',
113
+ backdrop: r.backdrop,
114
+ }));
115
+ sources.push({ type: 'config-wcag', rules });
116
+ }
117
+
118
+ // 4. Config-defined threshold rules
119
+ if (constraintsCfg.thresholds && Array.isArray(constraintsCfg.thresholds) && constraintsCfg.thresholds.length > 0) {
120
+ const rules: ThresholdRule[] = constraintsCfg.thresholds.map((r: any) => ({
121
+ id: r.id,
122
+ op: r.op,
123
+ valuePx: r.valuePx,
124
+ where: r.where,
125
+ }));
126
+ sources.push({ type: 'custom-threshold', rules });
127
+ }
128
+
129
+ // 5. Order files (monotonic constraints)
130
+ const axes = ['typography', 'spacing', 'layout'] as const;
131
+ for (const axis of axes) {
132
+ const suffix = bp ? `.${bp}` : '';
133
+ const orderPath = join(basePath, constraintsDir, `${axis}${suffix}.order.json`);
134
+
135
+ if (existsSync(orderPath)) {
136
+ try {
137
+ const data = JSON.parse(readFileSync(orderPath, 'utf8'));
138
+ const orders: OrderRule[] = data.order || [];
139
+ if (orders.length > 0) {
140
+ sources.push({ type: 'order-file', axis, orders, path: orderPath });
141
+ }
142
+ } catch {
143
+ // Silently skip malformed order files (consistent with old behavior)
144
+ }
145
+ }
146
+ }
147
+
148
+ // 6. Color lightness order files
149
+ const suffix = bp ? `.${bp}` : '';
150
+ const colorOrderPath = join(basePath, constraintsDir, `color${suffix}.order.json`);
151
+
152
+ if (existsSync(colorOrderPath)) {
153
+ try {
154
+ const data = JSON.parse(readFileSync(colorOrderPath, 'utf8'));
155
+ const orders: OrderRule[] = data.order || [];
156
+ if (orders.length > 0) {
157
+ sources.push({ type: 'lightness-file', orders, path: colorOrderPath });
158
+ }
159
+ } catch {
160
+ // Silently skip malformed color order files
161
+ }
162
+ }
163
+
164
+ // 7. Cross-axis rules (global)
165
+ const crossAxisPath = join(basePath, constraintsDir, 'cross-axis.rules.json');
166
+ if (existsSync(crossAxisPath)) {
167
+ sources.push({ type: 'cross-axis-file', path: crossAxisPath });
168
+ }
169
+
170
+ // 8. Cross-axis rules (breakpoint-specific)
171
+ if (bp) {
172
+ const crossAxisBpPath = join(basePath, constraintsDir, `cross-axis.${bp}.rules.json`);
173
+ if (existsSync(crossAxisBpPath)) {
174
+ sources.push({ type: 'cross-axis-file', path: crossAxisBpPath, bp });
175
+ }
176
+ }
177
+
178
+ return sources;
179
+ }
180
+
181
+ // ============================================================================
182
+ // Attachment
183
+ // ============================================================================
184
+
185
+ /**
186
+ * Attach constraint plugins to an engine based on discovered sources.
187
+ *
188
+ * This function takes the output of `discoverConstraints()` and registers
189
+ * the appropriate plugins on the engine.
190
+ *
191
+ * @param engine Engine to attach plugins to
192
+ * @param sources Constraint sources (from discoverConstraints)
193
+ * @param opts Attachment options (knownIds, debug flags)
194
+ */
195
+ export function attachConstraints(engine: Engine, sources: ConstraintSource[], opts: AttachOptions): void {
196
+ const { knownIds, crossAxisDebug = false } = opts;
197
+
198
+ for (const source of sources) {
199
+ try {
200
+ switch (source.type) {
201
+ case 'builtin-wcag': {
202
+ if (source.enabled) {
203
+ const defaultWcagPairs: WcagRule[] = [
204
+ {
205
+ fg: 'color.role.text.default',
206
+ bg: 'color.role.bg.surface',
207
+ min: 4.5,
208
+ where: 'Body text on surface',
209
+ },
210
+ {
211
+ fg: 'color.role.accent.default',
212
+ bg: 'color.role.bg.surface',
213
+ min: 3.0,
214
+ where: 'Accent on surface',
215
+ },
216
+ {
217
+ fg: 'color.role.focus.ring',
218
+ bg: 'color.role.bg.surface',
219
+ min: 3.0,
220
+ where: 'Focus ring on surface',
221
+ backdrop: '#ffffff',
222
+ },
223
+ ];
224
+ engine.use(WcagContrastPlugin(defaultWcagPairs));
225
+ }
226
+ break;
227
+ }
228
+
229
+ case 'builtin-threshold': {
230
+ if (source.enabled) {
231
+ const defaultThresholds: ThresholdRule[] = [
232
+ {
233
+ id: 'control.size.min',
234
+ op: '>=',
235
+ valuePx: 44,
236
+ where: 'Touch target (WCAG / Apple HIG)',
237
+ },
238
+ ];
239
+ engine.use(ThresholdPlugin(defaultThresholds, 'threshold'));
240
+ }
241
+ break;
242
+ }
243
+
244
+ case 'config-wcag': {
245
+ engine.use(WcagContrastPlugin(source.rules));
246
+ break;
247
+ }
248
+
249
+ case 'custom-threshold': {
250
+ engine.use(ThresholdPlugin(source.rules, 'custom-threshold'));
251
+ break;
252
+ }
253
+
254
+ case 'order-file': {
255
+ const pluginId = `monotonic-${source.axis}`;
256
+ engine.use(MonotonicPlugin(source.orders, parseSizePx, pluginId));
257
+ break;
258
+ }
259
+
260
+ case 'lightness-file': {
261
+ engine.use(MonotonicLightness(source.orders));
262
+ break;
263
+ }
264
+
265
+ case 'cross-axis-file': {
266
+ // Phase 3B: Load rules from filesystem in CLI layer, pass to core plugin
267
+ const rules = loadCrossAxisRules(source.path, {
268
+ bp: source.bp,
269
+ knownIds,
270
+ debug: crossAxisDebug,
271
+ });
272
+ engine.use(CrossAxisPlugin(rules, source.bp));
273
+ break;
274
+ }
275
+ }
276
+ } catch {
277
+ // Silently skip failed constraint attachments (consistent with old behavior)
278
+ // In the future, we may want to surface these as warnings
279
+ }
280
+ }
281
+ }
282
+
283
+ // ============================================================================
284
+ // Convenience
285
+ // ============================================================================
286
+
287
+ /**
288
+ * Discover and attach constraints in one call.
289
+ *
290
+ * This is the main entry point for most use cases.
291
+ *
292
+ * @param engine Engine to attach plugins to
293
+ * @param discoveryOpts Discovery options
294
+ * @param attachOpts Attachment options
295
+ */
296
+ export function setupConstraints(
297
+ engine: Engine,
298
+ discoveryOpts: DiscoveryOptions,
299
+ attachOpts: AttachOptions,
300
+ ): ConstraintSource[] {
301
+ const sources = discoverConstraints(discoveryOpts);
302
+ attachConstraints(engine, sources, attachOpts);
303
+ return sources;
304
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constraints-loader.d.ts","sourceRoot":"","sources":["constraints-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAGhD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,KAAK,iBAAiB,GAAG;IACvB,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACtB,EAAE,CAAC,EAAE,UAAU,CAAC;IAChB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAkDtF"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Filesystem loader for cross-axis constraint rules.
3
+ *
4
+ * Phase 3B (Filesystem Separation): This module handles reading cross-axis rules
5
+ * from JSON files and parsing them into in-memory data structures.
6
+ *
7
+ * Core modules (core/constraints/cross-axis.ts) accept pre-parsed rules,
8
+ * while CLI modules use this loader to read from filesystem.
9
+ */
10
+ import type { CrossAxisRule } from '../core/constraints/cross-axis.js';
11
+ /**
12
+ * Raw rule format as stored in JSON files.
13
+ */
14
+ export type RawCrossAxisRule = {
15
+ id: string;
16
+ level?: 'error' | 'warn';
17
+ where?: string;
18
+ bp?: string;
19
+ when?: {
20
+ id: string;
21
+ op: '<=' | '>=' | '<' | '>' | '==' | '!=';
22
+ value: number;
23
+ };
24
+ require?: {
25
+ id: string;
26
+ op: '<=' | '>=' | '<' | '>' | '==' | '!=';
27
+ ref?: string;
28
+ fallback?: string | number;
29
+ };
30
+ compare?: {
31
+ a: string;
32
+ op: '<=' | '>=' | '<' | '>' | '==' | '!=';
33
+ b: string;
34
+ delta?: string | number;
35
+ };
36
+ };
37
+ /**
38
+ * Result of loading and parsing cross-axis rules.
39
+ */
40
+ export type LoadCrossAxisResult = {
41
+ rules: CrossAxisRule[];
42
+ unknownIds: Set<string>;
43
+ skipped: Array<{
44
+ id?: string;
45
+ reason: string;
46
+ }>;
47
+ };
48
+ /**
49
+ * Options for loading cross-axis rules.
50
+ */
51
+ export type LoadCrossAxisOptions = {
52
+ /** Breakpoint to filter rules for */
53
+ bp?: string;
54
+ /** Set of known token IDs for validation */
55
+ knownIds?: Set<string>;
56
+ /** Enable debug logging */
57
+ debug?: boolean;
58
+ };
59
+ /**
60
+ * Load raw cross-axis rules from a JSON file.
61
+ *
62
+ * Returns undefined if file doesn't exist or can't be parsed.
63
+ *
64
+ * @param path Path to cross-axis rules JSON file
65
+ * @returns Parsed rules or undefined if file missing/invalid
66
+ */
67
+ export declare function loadCrossAxisRulesFromFile(path: string): RawCrossAxisRule[] | undefined;
68
+ /**
69
+ * Parse raw cross-axis rules into executable constraint rules.
70
+ *
71
+ * This function performs:
72
+ * - Breakpoint filtering (only include rules matching the target breakpoint)
73
+ * - Token ID validation (track unknown IDs)
74
+ * - Rule compilation (convert JSON predicates into executable functions)
75
+ *
76
+ * @param rawRules Raw rules from JSON file
77
+ * @param opts Parsing options (breakpoint, knownIds, debug)
78
+ * @returns Parsed rules with validation info
79
+ */
80
+ export declare function parseCrossAxisRules(rawRules: RawCrossAxisRule[], opts?: LoadCrossAxisOptions): LoadCrossAxisResult;
81
+ /**
82
+ * Load and parse cross-axis rules from a JSON file.
83
+ *
84
+ * This is the main entry point for CLI code that needs to load cross-axis rules.
85
+ *
86
+ * @param path Path to cross-axis rules JSON file
87
+ * @param opts Parsing options
88
+ * @returns Parsed rules (empty array if file doesn't exist)
89
+ */
90
+ export declare function loadCrossAxisRules(path: string, opts?: LoadCrossAxisOptions): CrossAxisRule[];
91
+ //# sourceMappingURL=cross-axis-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cross-axis-loader.d.ts","sourceRoot":"","sources":["cross-axis-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEvE;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAC;QACX,EAAE,EAAE,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;QAC1C,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;IACF,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,EAAE,EAAE,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;QAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KAC5B,CAAC;IACF,OAAO,CAAC,EAAE;QACR,CAAC,EAAE,MAAM,CAAC;QACV,EAAE,EAAE,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;QAC1C,CAAC,EAAE,MAAM,CAAC;QACV,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KACzB,CAAC;CACH,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB,OAAO,EAAE,KAAK,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjD,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,qCAAqC;IACrC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAMF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,SAAS,CAYvF;AAwDD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,EAAE,IAAI,GAAE,oBAAyB,GAAG,mBAAmB,CA6GtH;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,aAAa,EAAE,CAejG"}
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Filesystem loader for cross-axis constraint rules.
3
+ *
4
+ * Phase 3B (Filesystem Separation): This module handles reading cross-axis rules
5
+ * from JSON files and parsing them into in-memory data structures.
6
+ *
7
+ * Core modules (core/constraints/cross-axis.ts) accept pre-parsed rules,
8
+ * while CLI modules use this loader to read from filesystem.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ // ============================================================================
12
+ // Filesystem Loading
13
+ // ============================================================================
14
+ /**
15
+ * Load raw cross-axis rules from a JSON file.
16
+ *
17
+ * Returns undefined if file doesn't exist or can't be parsed.
18
+ *
19
+ * @param path Path to cross-axis rules JSON file
20
+ * @returns Parsed rules or undefined if file missing/invalid
21
+ */
22
+ export function loadCrossAxisRulesFromFile(path) {
23
+ if (!existsSync(path)) {
24
+ return undefined;
25
+ }
26
+ try {
27
+ const data = JSON.parse(readFileSync(path, 'utf8'));
28
+ return data.rules || [];
29
+ }
30
+ catch {
31
+ // Return undefined on parse errors (consistent with silent failure behavior)
32
+ return undefined;
33
+ }
34
+ }
35
+ // ============================================================================
36
+ // Rule Parsing and Validation
37
+ // ============================================================================
38
+ // Helper functions (copied from core/cross-axis-config.ts)
39
+ const px = (v) => typeof v === 'number' ? v : parseFloat(String(v)) * (String(v).trim().endsWith('rem') ? 16 : 1);
40
+ const cmp = (a, b, op) => op === '>=' ? a >= b : op === '>' ? a > b : op === '<=' ? a <= b : op === '<' ? a < b : op === '==' ? a === b : a !== b;
41
+ const prettyFail = (op) => ({ '>=': '<', '>': '≤', '<=': '>', '<': '≥', '==': '≠', '!=': '=' }[op] || '≠');
42
+ const fmt = (v) => (Number.isFinite(Number(v)) ? `${Number(v)}px` : String(v));
43
+ function valueOrRef(ctx, ref, fallback) {
44
+ if (ref) {
45
+ const v = ctx.getPx(ref);
46
+ if (v != null)
47
+ return v;
48
+ }
49
+ return typeof fallback === 'number' ? fallback : px(fallback ?? 0);
50
+ }
51
+ function makeOp(op, rhs) {
52
+ return (v) => cmp(v, rhs, op);
53
+ }
54
+ // Lightweight Levenshtein distance for suggestions
55
+ function levenshtein(a, b) {
56
+ const dp = Array(b.length + 1)
57
+ .fill(0)
58
+ .map((_, j) => j);
59
+ for (let i = 1; i <= a.length; i++) {
60
+ let prev = i - 1, cur = i;
61
+ for (let j = 1; j <= b.length; j++) {
62
+ const tmp = cur;
63
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
64
+ cur = Math.min(dp[j] + 1, cur + 1, prev + cost);
65
+ dp[j] = tmp;
66
+ prev = tmp;
67
+ }
68
+ dp[b.length] = cur;
69
+ }
70
+ return dp[b.length];
71
+ }
72
+ function suggest(id, known, k = 3) {
73
+ return [...known]
74
+ .map((c) => ({ id: c, d: levenshtein(id, c) }))
75
+ .sort((a, b) => a.d - b.d)
76
+ .slice(0, k);
77
+ }
78
+ /**
79
+ * Parse raw cross-axis rules into executable constraint rules.
80
+ *
81
+ * This function performs:
82
+ * - Breakpoint filtering (only include rules matching the target breakpoint)
83
+ * - Token ID validation (track unknown IDs)
84
+ * - Rule compilation (convert JSON predicates into executable functions)
85
+ *
86
+ * @param rawRules Raw rules from JSON file
87
+ * @param opts Parsing options (breakpoint, knownIds, debug)
88
+ * @returns Parsed rules with validation info
89
+ */
90
+ export function parseCrossAxisRules(rawRules, opts = {}) {
91
+ const { bp, knownIds = new Set(), debug = false } = opts;
92
+ const rules = [];
93
+ const unknownIds = new Set();
94
+ const skipped = [];
95
+ const log = (...args) => {
96
+ if (debug)
97
+ console.log('[cross-axis]', ...args);
98
+ };
99
+ const needId = (id) => {
100
+ if (!id)
101
+ return false;
102
+ if (!knownIds.has(id)) {
103
+ unknownIds.add(id);
104
+ }
105
+ return true;
106
+ };
107
+ for (const r of rawRules) {
108
+ // Filter by breakpoint
109
+ if (r.bp && bp && r.bp !== bp) {
110
+ continue;
111
+ }
112
+ if (r.bp && !bp) {
113
+ // Rule targets specific breakpoint; skip in global run
114
+ continue;
115
+ }
116
+ try {
117
+ if (r.when && r.require) {
118
+ // Validate IDs
119
+ needId(r.when.id);
120
+ needId(r.require.id);
121
+ if (r.require.ref)
122
+ needId(r.require.ref);
123
+ rules.push({
124
+ id: r.id,
125
+ level: r.level,
126
+ where: r.where,
127
+ when: { id: r.when.id, test: makeOp(r.when.op, r.when.value) },
128
+ require: {
129
+ id: r.require.id,
130
+ test: (v, ctx) => {
131
+ const rhs = valueOrRef(ctx, r.require.ref, r.require.fallback);
132
+ return cmp(v, rhs, r.require.op);
133
+ },
134
+ msg: (v, ctx) => {
135
+ const rhs = valueOrRef(ctx, r.require.ref, r.require.fallback);
136
+ return `${r.require.id} ${prettyFail(r.require.op)} ${fmt(rhs)} (was ${fmt(v)})`;
137
+ },
138
+ },
139
+ });
140
+ }
141
+ else if (r.compare) {
142
+ needId(r.compare.a);
143
+ needId(r.compare.b);
144
+ rules.push({
145
+ id: r.id,
146
+ level: r.level,
147
+ where: r.where,
148
+ when: { id: r.compare.a, test: () => true },
149
+ require: {
150
+ id: r.compare.a,
151
+ test: (_, ctx) => {
152
+ const a = ctx.getPx(r.compare.a) ?? NaN;
153
+ const b = ctx.getPx(r.compare.b) ?? NaN;
154
+ const delta = px(r.compare.delta ?? 0);
155
+ if (Number.isNaN(a) || Number.isNaN(b))
156
+ return true; // skip check if missing
157
+ return cmp(a, b + delta, r.compare.op);
158
+ },
159
+ msg: (_, ctx) => {
160
+ const a = ctx.getPx(r.compare.a);
161
+ const b = ctx.getPx(r.compare.b);
162
+ const delta = px(r.compare.delta ?? 0);
163
+ return `${r.compare.a} ${prettyFail(r.compare.op)} ${fmt((b ?? 0) + delta)} (was ${fmt(a ?? NaN)})`;
164
+ },
165
+ },
166
+ });
167
+ }
168
+ else {
169
+ skipped.push({ id: r.id, reason: 'neither when+require nor compare present' });
170
+ }
171
+ }
172
+ catch (e) {
173
+ skipped.push({ id: r.id, reason: `exception: ${e?.message ?? e}` });
174
+ }
175
+ }
176
+ // Debug logging
177
+ if (debug) {
178
+ log(`parsed ${rules.length} rule(s)${bp ? ` [bp=${bp}]` : ''}`);
179
+ if (unknownIds.size) {
180
+ log(`unknown ids referenced:`, [...unknownIds].join(', '));
181
+ for (const u of unknownIds) {
182
+ const s = suggest(u, knownIds, 3);
183
+ if (s.length)
184
+ log(` did you mean: ${s.map((x) => `${x.id} (d=${x.d})`).join(', ')}`);
185
+ }
186
+ }
187
+ if (skipped.length) {
188
+ for (const s of skipped)
189
+ log(`skipped rule ${s.id ?? '(no id)'} — ${s.reason}`);
190
+ }
191
+ // Extra hint for common anchor pitfall
192
+ for (const r of rawRules) {
193
+ if (r.require?.ref && !knownIds.has(r.require.ref)) {
194
+ log(`anchor missing: ${r.require.ref} → will use fallback=${JSON.stringify(r.require.fallback)} when evaluating`);
195
+ }
196
+ }
197
+ }
198
+ return { rules, unknownIds, skipped };
199
+ }
200
+ /**
201
+ * Load and parse cross-axis rules from a JSON file.
202
+ *
203
+ * This is the main entry point for CLI code that needs to load cross-axis rules.
204
+ *
205
+ * @param path Path to cross-axis rules JSON file
206
+ * @param opts Parsing options
207
+ * @returns Parsed rules (empty array if file doesn't exist)
208
+ */
209
+ export function loadCrossAxisRules(path, opts = {}) {
210
+ const { debug = false } = opts;
211
+ const log = (...args) => {
212
+ if (debug)
213
+ console.log('[cross-axis]', ...args);
214
+ };
215
+ const rawRules = loadCrossAxisRulesFromFile(path);
216
+ if (!rawRules) {
217
+ log(`no rules file at ${path} (bp=${opts.bp ?? 'global'})`);
218
+ return [];
219
+ }
220
+ const result = parseCrossAxisRules(rawRules, opts);
221
+ return result.rules;
222
+ }