design-constraint-validator 2.0.1 → 2.2.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.
Files changed (118) hide show
  1. package/README.md +89 -23
  2. package/cli/commands/build.d.ts.map +1 -1
  3. package/cli/commands/build.js +32 -24
  4. package/cli/commands/build.ts +26 -17
  5. package/cli/commands/graph.d.ts.map +1 -1
  6. package/cli/commands/graph.js +35 -18
  7. package/cli/commands/graph.ts +30 -17
  8. package/cli/commands/patch-apply.d.ts.map +1 -1
  9. package/cli/commands/patch-apply.js +4 -1
  10. package/cli/commands/patch-apply.ts +4 -1
  11. package/cli/commands/set.d.ts.map +1 -1
  12. package/cli/commands/set.js +18 -19
  13. package/cli/commands/set.ts +19 -19
  14. package/cli/commands/utils.d.ts +1 -0
  15. package/cli/commands/utils.d.ts.map +1 -1
  16. package/cli/commands/utils.js +20 -1
  17. package/cli/commands/utils.ts +23 -1
  18. package/cli/commands/validate.d.ts.map +1 -1
  19. package/cli/commands/validate.js +45 -23
  20. package/cli/commands/validate.ts +47 -26
  21. package/cli/commands/why.d.ts.map +1 -1
  22. package/cli/commands/why.js +22 -10
  23. package/cli/commands/why.ts +20 -9
  24. package/cli/config-schema.d.ts +171 -166
  25. package/cli/config-schema.d.ts.map +1 -1
  26. package/cli/config-schema.js +29 -7
  27. package/cli/config-schema.ts +31 -7
  28. package/cli/config.d.ts.map +1 -1
  29. package/cli/config.js +8 -2
  30. package/cli/config.ts +8 -2
  31. package/cli/constraint-registry.d.ts +16 -0
  32. package/cli/constraint-registry.d.ts.map +1 -1
  33. package/cli/constraint-registry.js +115 -44
  34. package/cli/constraint-registry.ts +118 -47
  35. package/cli/cross-axis-loader.d.ts +62 -0
  36. package/cli/cross-axis-loader.d.ts.map +1 -1
  37. package/cli/cross-axis-loader.js +186 -31
  38. package/cli/cross-axis-loader.ts +199 -24
  39. package/cli/dcv.js +31 -25
  40. package/cli/dcv.ts +31 -21
  41. package/cli/json-output.d.ts +3 -1
  42. package/cli/json-output.d.ts.map +1 -1
  43. package/cli/json-output.js +11 -4
  44. package/cli/json-output.ts +13 -4
  45. package/cli/types.d.ts +21 -9
  46. package/cli/types.d.ts.map +1 -1
  47. package/cli/types.ts +25 -10
  48. package/cli/validate-api.d.ts +40 -0
  49. package/cli/validate-api.d.ts.map +1 -0
  50. package/cli/validate-api.js +90 -0
  51. package/cli/validate-api.ts +131 -0
  52. package/core/breakpoints.d.ts +8 -2
  53. package/core/breakpoints.d.ts.map +1 -1
  54. package/core/breakpoints.js +24 -3
  55. package/core/breakpoints.ts +22 -3
  56. package/core/color.js +4 -4
  57. package/core/color.ts +4 -4
  58. package/core/constraints/cross-axis.d.ts.map +1 -1
  59. package/core/constraints/cross-axis.js +37 -9
  60. package/core/constraints/cross-axis.ts +37 -9
  61. package/core/constraints/monotonic-lightness.d.ts.map +1 -1
  62. package/core/constraints/monotonic-lightness.js +9 -5
  63. package/core/constraints/monotonic-lightness.ts +9 -4
  64. package/core/constraints/monotonic.d.ts.map +1 -1
  65. package/core/constraints/monotonic.js +32 -8
  66. package/core/constraints/monotonic.ts +29 -8
  67. package/core/constraints/threshold.d.ts.map +1 -1
  68. package/core/constraints/threshold.js +24 -4
  69. package/core/constraints/threshold.ts +23 -4
  70. package/core/constraints/wcag.d.ts.map +1 -1
  71. package/core/constraints/wcag.js +7 -1
  72. package/core/constraints/wcag.ts +7 -1
  73. package/core/dtcg.d.ts +38 -0
  74. package/core/dtcg.d.ts.map +1 -0
  75. package/core/dtcg.js +88 -0
  76. package/core/dtcg.ts +102 -0
  77. package/core/engine.d.ts +6 -0
  78. package/core/engine.d.ts.map +1 -1
  79. package/core/engine.ts +7 -0
  80. package/core/flatten.d.ts +5 -3
  81. package/core/flatten.d.ts.map +1 -1
  82. package/core/flatten.js +32 -10
  83. package/core/flatten.ts +48 -16
  84. package/core/image-export.d.ts.map +1 -1
  85. package/core/image-export.js +10 -7
  86. package/core/image-export.ts +9 -6
  87. package/core/index.d.ts +2 -0
  88. package/core/index.d.ts.map +1 -1
  89. package/core/index.js +4 -0
  90. package/core/index.ts +6 -0
  91. package/core/poset.d.ts +6 -1
  92. package/core/poset.d.ts.map +1 -1
  93. package/core/poset.js +7 -2
  94. package/core/poset.ts +7 -2
  95. package/core/why.d.ts +1 -1
  96. package/core/why.d.ts.map +1 -1
  97. package/core/why.ts +1 -1
  98. package/mcp/contracts.d.ts +1561 -0
  99. package/mcp/contracts.d.ts.map +1 -0
  100. package/mcp/contracts.js +74 -0
  101. package/mcp/contracts.ts +105 -0
  102. package/mcp/index.d.ts +11 -0
  103. package/mcp/index.d.ts.map +1 -0
  104. package/mcp/index.js +35 -0
  105. package/mcp/index.ts +97 -0
  106. package/mcp/insights.d.ts +94 -0
  107. package/mcp/insights.d.ts.map +1 -0
  108. package/mcp/insights.js +445 -0
  109. package/mcp/insights.ts +541 -0
  110. package/mcp/tools.d.ts +63 -0
  111. package/mcp/tools.d.ts.map +1 -0
  112. package/mcp/tools.js +299 -0
  113. package/mcp/tools.ts +431 -0
  114. package/package.json +36 -26
  115. package/server.json +21 -0
  116. package/cli/constraints-loader.d.ts.map +0 -1
  117. package/cli/engine-helpers.d.ts.map +0 -1
  118. package/core/cross-axis-config.d.ts.map +0 -1
@@ -8,35 +8,117 @@
8
8
  * while CLI modules use this loader to read from filesystem.
9
9
  */
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
- // ============================================================================
12
- // Filesystem Loading
13
- // ============================================================================
11
+ import { z } from 'zod';
12
+ export function readCrossAxisRulesFile(path) {
13
+ if (!existsSync(path))
14
+ return { status: 'missing' };
15
+ let data;
16
+ try {
17
+ data = JSON.parse(readFileSync(path, 'utf8'));
18
+ }
19
+ catch (e) {
20
+ return { status: 'invalid', reason: `invalid JSON (${e?.message ?? e})` };
21
+ }
22
+ const rules = data?.rules;
23
+ if (!Array.isArray(rules)) {
24
+ return { status: 'invalid', reason: 'expected a top-level "rules" array' };
25
+ }
26
+ return { status: 'ok', rules: rules };
27
+ }
14
28
  /**
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
29
+ * Back-compat shim: returns the rule array, or `undefined` for both missing and
30
+ * present-but-unusable files. Prefer {@link readCrossAxisRulesFile} when the
31
+ * caller needs to surface the unusable case.
21
32
  */
22
33
  export function loadCrossAxisRulesFromFile(path) {
23
- if (!existsSync(path)) {
24
- return undefined;
34
+ const res = readCrossAxisRulesFile(path);
35
+ return res.status === 'ok' ? res.rules : undefined;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Rule shape validation (TASK-037)
39
+ //
40
+ // A cross-axis rule that fails to compile must be SKIPPED WITH A REASON, never
41
+ // compiled into an always-true predicate or a NaN comparison. Examples the old
42
+ // code admitted silently: a `when` missing `op`/`value` (always-true), a
43
+ // `require` with no RHS (compared against 0), a non-numeric `fallback` (NaN).
44
+ // ---------------------------------------------------------------------------
45
+ const opEnum = z.enum(['<=', '>=', '<', '>', '==', '!=']);
46
+ const sizeConst = z.union([z.number(), z.string()]);
47
+ const RawCrossAxisRuleSchema = z
48
+ .object({
49
+ id: z.string(),
50
+ level: z.enum(['error', 'warn']).optional(),
51
+ where: z.string().optional(),
52
+ bp: z.string().optional(),
53
+ when: z.object({ id: z.string(), op: opEnum, value: z.number().finite() }).strict().optional(),
54
+ require: z
55
+ .object({ id: z.string(), op: opEnum, ref: z.string().optional(), fallback: sizeConst.optional() })
56
+ .strict()
57
+ .optional(),
58
+ compare: z.object({ a: z.string(), op: opEnum, b: z.string(), delta: sizeConst.optional() }).strict().optional(),
59
+ })
60
+ .strict();
61
+ export function validateRawRule(raw) {
62
+ const parsed = RawCrossAxisRuleSchema.safeParse(raw);
63
+ if (!parsed.success) {
64
+ const id = raw && typeof raw === 'object' && 'id' in raw && typeof raw.id === 'string'
65
+ ? raw.id
66
+ : undefined;
67
+ const reason = parsed.error.issues.map((e) => `${e.path.join('.') || '<root>'}: ${e.message}`).join('; ');
68
+ return { ok: false, id, reason };
25
69
  }
26
- try {
27
- const data = JSON.parse(readFileSync(path, 'utf8'));
28
- return data.rules || [];
70
+ const rule = parsed.data;
71
+ const hasWhenReq = !!(rule.when && rule.require);
72
+ const hasCompare = !!rule.compare;
73
+ if (!hasWhenReq && !hasCompare) {
74
+ return { ok: false, id: rule.id, reason: 'must define when+require or compare' };
29
75
  }
30
- catch {
31
- // Return undefined on parse errors (consistent with silent failure behavior)
32
- return undefined;
76
+ if (hasWhenReq && hasCompare) {
77
+ return { ok: false, id: rule.id, reason: 'must define exactly one of when+require or compare' };
33
78
  }
79
+ // Size constants must parse, or the compiled predicate degrades to NaN/0.
80
+ if (rule.require?.fallback !== undefined && px(rule.require.fallback) === null) {
81
+ return { ok: false, id: rule.id, reason: `require.fallback is not a parseable size: ${JSON.stringify(rule.require.fallback)}` };
82
+ }
83
+ if (rule.compare?.delta !== undefined && px(rule.compare.delta) === null) {
84
+ return { ok: false, id: rule.id, reason: `compare.delta is not a parseable size: ${JSON.stringify(rule.compare.delta)}` };
85
+ }
86
+ return { ok: true, rule };
87
+ }
88
+ /**
89
+ * Whether a rule is active for a given breakpoint scope. Shared by rule
90
+ * compilation and coverage enumeration so the two never drift (TASK-037): a
91
+ * rule that did not run must not be able to make coverage look "matched".
92
+ */
93
+ export function ruleMatchesBp(r, bp) {
94
+ if (r?.bp && bp && r.bp !== bp)
95
+ return false; // targets a different bp
96
+ if (r?.bp && !bp)
97
+ return false; // bp-specific rule in a global run
98
+ return true;
34
99
  }
35
100
  // ============================================================================
36
101
  // Rule Parsing and Validation
37
102
  // ============================================================================
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);
103
+ // Parse a cross-axis size CONSTANT (rule `fallback`/`delta`), not a token value.
104
+ // Mirrors the hardened finite-size policy (TASK-037): real numbers only, `rem`/
105
+ // `em` 16px-relative, non-finite rejected — but allows a leading `-` because a
106
+ // `compare.delta` is a signed offset (e.g. "-560px"). Returns null on garbage so
107
+ // callers reject the rule instead of silently degrading to NaN/0.
108
+ const px = (v) => {
109
+ if (typeof v === 'number')
110
+ return Number.isFinite(v) ? v : null;
111
+ if (typeof v !== 'string')
112
+ return null;
113
+ const m = v.trim().match(/^(-?\d*\.?\d+)\s*(px|rem|em)?$/i);
114
+ if (!m)
115
+ return null;
116
+ const n = parseFloat(m[1]);
117
+ if (!Number.isFinite(n))
118
+ return null;
119
+ const unit = (m[2] || 'px').toLowerCase();
120
+ return unit === 'rem' || unit === 'em' ? n * 16 : n;
121
+ };
40
122
  const cmp = (a, b, op) => op === '>=' ? a >= b : op === '>' ? a > b : op === '<=' ? a <= b : op === '<' ? a < b : op === '==' ? a === b : a !== b;
41
123
  const prettyFail = (op) => ({ '>=': '<', '>': '≤', '<=': '>', '<': '≥', '==': '≠', '!=': '=' }[op] || '≠');
42
124
  const fmt = (v) => (Number.isFinite(Number(v)) ? `${Number(v)}px` : String(v));
@@ -46,7 +128,11 @@ function valueOrRef(ctx, ref, fallback) {
46
128
  if (v != null)
47
129
  return v;
48
130
  }
49
- return typeof fallback === 'number' ? fallback : px(fallback ?? 0);
131
+ if (typeof fallback === 'number')
132
+ return fallback;
133
+ // `?? 0` is defensive only: validateRawRule rejects rules whose fallback does
134
+ // not parse, so a compiled rule never reaches here with garbage.
135
+ return px(fallback ?? 0) ?? 0;
50
136
  }
51
137
  function makeOp(op, rhs) {
52
138
  return (v) => cmp(v, rhs, op);
@@ -104,15 +190,18 @@ export function parseCrossAxisRules(rawRules, opts = {}) {
104
190
  }
105
191
  return true;
106
192
  };
107
- for (const r of rawRules) {
108
- // Filter by breakpoint
109
- if (r.bp && bp && r.bp !== bp) {
193
+ for (const raw of rawRules) {
194
+ // Filter by breakpoint (shared with coverage enumeration so they never drift).
195
+ if (!ruleMatchesBp(raw, bp))
110
196
  continue;
111
- }
112
- if (r.bp && !bp) {
113
- // Rule targets specific breakpoint; skip in global run
197
+ // Validate shape BEFORE compiling: an invalid rule is skipped with a reason,
198
+ // never compiled into an always-true or NaN predicate (TASK-037).
199
+ const valid = validateRawRule(raw);
200
+ if (!valid.ok) {
201
+ skipped.push({ id: valid.id, reason: valid.reason });
114
202
  continue;
115
203
  }
204
+ const r = valid.rule;
116
205
  try {
117
206
  if (r.when && r.require) {
118
207
  // Validate IDs
@@ -151,7 +240,7 @@ export function parseCrossAxisRules(rawRules, opts = {}) {
151
240
  test: (_, ctx) => {
152
241
  const a = ctx.getPx(r.compare.a) ?? NaN;
153
242
  const b = ctx.getPx(r.compare.b) ?? NaN;
154
- const delta = px(r.compare.delta ?? 0);
243
+ const delta = px(r.compare.delta ?? 0) ?? 0;
155
244
  if (Number.isNaN(a) || Number.isNaN(b))
156
245
  return true; // skip check if missing
157
246
  return cmp(a, b + delta, r.compare.op);
@@ -159,15 +248,12 @@ export function parseCrossAxisRules(rawRules, opts = {}) {
159
248
  msg: (_, ctx) => {
160
249
  const a = ctx.getPx(r.compare.a);
161
250
  const b = ctx.getPx(r.compare.b);
162
- const delta = px(r.compare.delta ?? 0);
251
+ const delta = px(r.compare.delta ?? 0) ?? 0;
163
252
  return `${r.compare.a} ${prettyFail(r.compare.op)} ${fmt((b ?? 0) + delta)} (was ${fmt(a ?? NaN)})`;
164
253
  },
165
254
  },
166
255
  });
167
256
  }
168
- else {
169
- skipped.push({ id: r.id, reason: 'neither when+require nor compare present' });
170
- }
171
257
  }
172
258
  catch (e) {
173
259
  skipped.push({ id: r.id, reason: `exception: ${e?.message ?? e}` });
@@ -220,3 +306,72 @@ export function loadCrossAxisRules(path, opts = {}) {
220
306
  const result = parseCrossAxisRules(rawRules, opts);
221
307
  return result.rules;
222
308
  }
309
+ /**
310
+ * Load + parse cross-axis rules AND return notices to surface (TASK-037).
311
+ *
312
+ * Unlike {@link loadCrossAxisRules}, a present-but-unusable file or a skipped
313
+ * (invalid) rule produces a `warn`-level {@link ConstraintIssue} so it appears
314
+ * in the validation result instead of vanishing into a silent "no rules → green".
315
+ */
316
+ export function loadCrossAxisRulesDetailed(path, opts = {}) {
317
+ const file = readCrossAxisRulesFile(path);
318
+ if (file.status === 'missing')
319
+ return { rules: [], notices: [] };
320
+ if (file.status === 'invalid') {
321
+ return {
322
+ rules: [],
323
+ notices: [
324
+ {
325
+ id: 'cross-axis',
326
+ rule: 'cross-axis',
327
+ level: 'warn',
328
+ where: path,
329
+ message: `Cross-axis rules file present but unusable: ${file.reason}`,
330
+ },
331
+ ],
332
+ };
333
+ }
334
+ const result = parseCrossAxisRules(file.rules, opts);
335
+ const notices = result.skipped.map((s) => ({
336
+ id: `cross-axis:${s.id ?? '(no id)'}`,
337
+ rule: 'cross-axis',
338
+ level: 'warn',
339
+ message: `Cross-axis rule ${s.id ? `"${s.id}" ` : ''}skipped: ${s.reason}`,
340
+ }));
341
+ return { rules: result.rules, notices };
342
+ }
343
+ /**
344
+ * Token ids a cross-axis file contributes to constraint coverage for a given
345
+ * breakpoint scope (TASK-037). Mirrors compilation exactly: the SAME bp filter
346
+ * and the SAME shape validation, so a rule that did not run cannot make coverage
347
+ * look "matched" (which would suppress the "nothing was checked" note).
348
+ *
349
+ * `coverageKnown` is false only when the file is present-but-unusable.
350
+ */
351
+ export function referencedIdsForFile(path, bp) {
352
+ const file = readCrossAxisRulesFile(path);
353
+ if (file.status === 'missing')
354
+ return { ids: [], coverageKnown: true };
355
+ if (file.status === 'invalid')
356
+ return { ids: [], coverageKnown: false };
357
+ const ids = [];
358
+ for (const raw of file.rules) {
359
+ if (!ruleMatchesBp(raw, bp))
360
+ continue;
361
+ const v = validateRawRule(raw);
362
+ if (!v.ok)
363
+ continue; // invalid rule did not compile → contributes no coverage
364
+ const r = v.rule;
365
+ if (r.when?.id)
366
+ ids.push(r.when.id);
367
+ if (r.require?.id)
368
+ ids.push(r.require.id);
369
+ if (r.require?.ref)
370
+ ids.push(r.require.ref);
371
+ if (r.compare?.a)
372
+ ids.push(r.compare.a);
373
+ if (r.compare?.b)
374
+ ids.push(r.compare.b);
375
+ }
376
+ return { ids, coverageKnown: true };
377
+ }
@@ -9,7 +9,9 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
+ import { z } from 'zod';
12
13
  import type { CrossAxisRule } from '../core/constraints/cross-axis.js';
14
+ import type { ConstraintIssue } from '../core/engine.js';
13
15
 
14
16
  /**
15
17
  * Raw rule format as stored in JSON files.
@@ -71,27 +73,132 @@ export type LoadCrossAxisOptions = {
71
73
  * @param path Path to cross-axis rules JSON file
72
74
  * @returns Parsed rules or undefined if file missing/invalid
73
75
  */
74
- export function loadCrossAxisRulesFromFile(path: string): RawCrossAxisRule[] | undefined {
75
- if (!existsSync(path)) {
76
- return undefined;
77
- }
76
+ /**
77
+ * Outcome of reading a cross-axis rules file, distinguishing the three cases a
78
+ * validator must not collapse (TASK-037): a *missing* file (no rules, fine) vs.
79
+ * a *present-but-unusable* file (bad JSON / wrong shape — must be surfaced, never
80
+ * silently treated as "no rules → green") vs. a successfully read rule array.
81
+ */
82
+ export type RawCrossAxisFileResult =
83
+ | { status: 'missing' }
84
+ | { status: 'invalid'; reason: string }
85
+ | { status: 'ok'; rules: RawCrossAxisRule[] };
78
86
 
87
+ export function readCrossAxisRulesFile(path: string): RawCrossAxisFileResult {
88
+ if (!existsSync(path)) return { status: 'missing' };
89
+ let data: unknown;
79
90
  try {
80
- const data = JSON.parse(readFileSync(path, 'utf8')) as { rules: RawCrossAxisRule[] };
81
- return data.rules || [];
82
- } catch {
83
- // Return undefined on parse errors (consistent with silent failure behavior)
84
- return undefined;
91
+ data = JSON.parse(readFileSync(path, 'utf8'));
92
+ } catch (e: any) {
93
+ return { status: 'invalid', reason: `invalid JSON (${e?.message ?? e})` };
94
+ }
95
+ const rules = (data as { rules?: unknown } | null)?.rules;
96
+ if (!Array.isArray(rules)) {
97
+ return { status: 'invalid', reason: 'expected a top-level "rules" array' };
98
+ }
99
+ return { status: 'ok', rules: rules as RawCrossAxisRule[] };
100
+ }
101
+
102
+ /**
103
+ * Back-compat shim: returns the rule array, or `undefined` for both missing and
104
+ * present-but-unusable files. Prefer {@link readCrossAxisRulesFile} when the
105
+ * caller needs to surface the unusable case.
106
+ */
107
+ export function loadCrossAxisRulesFromFile(path: string): RawCrossAxisRule[] | undefined {
108
+ const res = readCrossAxisRulesFile(path);
109
+ return res.status === 'ok' ? res.rules : undefined;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Rule shape validation (TASK-037)
114
+ //
115
+ // A cross-axis rule that fails to compile must be SKIPPED WITH A REASON, never
116
+ // compiled into an always-true predicate or a NaN comparison. Examples the old
117
+ // code admitted silently: a `when` missing `op`/`value` (always-true), a
118
+ // `require` with no RHS (compared against 0), a non-numeric `fallback` (NaN).
119
+ // ---------------------------------------------------------------------------
120
+
121
+ const opEnum = z.enum(['<=', '>=', '<', '>', '==', '!=']);
122
+ const sizeConst = z.union([z.number(), z.string()]);
123
+
124
+ const RawCrossAxisRuleSchema = z
125
+ .object({
126
+ id: z.string(),
127
+ level: z.enum(['error', 'warn']).optional(),
128
+ where: z.string().optional(),
129
+ bp: z.string().optional(),
130
+ when: z.object({ id: z.string(), op: opEnum, value: z.number().finite() }).strict().optional(),
131
+ require: z
132
+ .object({ id: z.string(), op: opEnum, ref: z.string().optional(), fallback: sizeConst.optional() })
133
+ .strict()
134
+ .optional(),
135
+ compare: z.object({ a: z.string(), op: opEnum, b: z.string(), delta: sizeConst.optional() }).strict().optional(),
136
+ })
137
+ .strict();
138
+
139
+ export type RuleValidation =
140
+ | { ok: true; rule: RawCrossAxisRule }
141
+ | { ok: false; id?: string; reason: string };
142
+
143
+ export function validateRawRule(raw: unknown): RuleValidation {
144
+ const parsed = RawCrossAxisRuleSchema.safeParse(raw);
145
+ if (!parsed.success) {
146
+ const id =
147
+ raw && typeof raw === 'object' && 'id' in raw && typeof (raw as any).id === 'string'
148
+ ? (raw as any).id
149
+ : undefined;
150
+ const reason = parsed.error.issues.map((e) => `${e.path.join('.') || '<root>'}: ${e.message}`).join('; ');
151
+ return { ok: false, id, reason };
152
+ }
153
+ const rule = parsed.data as RawCrossAxisRule;
154
+ const hasWhenReq = !!(rule.when && rule.require);
155
+ const hasCompare = !!rule.compare;
156
+ if (!hasWhenReq && !hasCompare) {
157
+ return { ok: false, id: rule.id, reason: 'must define when+require or compare' };
158
+ }
159
+ if (hasWhenReq && hasCompare) {
160
+ return { ok: false, id: rule.id, reason: 'must define exactly one of when+require or compare' };
161
+ }
162
+ // Size constants must parse, or the compiled predicate degrades to NaN/0.
163
+ if (rule.require?.fallback !== undefined && px(rule.require.fallback) === null) {
164
+ return { ok: false, id: rule.id, reason: `require.fallback is not a parseable size: ${JSON.stringify(rule.require.fallback)}` };
165
+ }
166
+ if (rule.compare?.delta !== undefined && px(rule.compare.delta) === null) {
167
+ return { ok: false, id: rule.id, reason: `compare.delta is not a parseable size: ${JSON.stringify(rule.compare.delta)}` };
85
168
  }
169
+ return { ok: true, rule };
170
+ }
171
+
172
+ /**
173
+ * Whether a rule is active for a given breakpoint scope. Shared by rule
174
+ * compilation and coverage enumeration so the two never drift (TASK-037): a
175
+ * rule that did not run must not be able to make coverage look "matched".
176
+ */
177
+ export function ruleMatchesBp(r: { bp?: string }, bp?: string): boolean {
178
+ if (r?.bp && bp && r.bp !== bp) return false; // targets a different bp
179
+ if (r?.bp && !bp) return false; // bp-specific rule in a global run
180
+ return true;
86
181
  }
87
182
 
88
183
  // ============================================================================
89
184
  // Rule Parsing and Validation
90
185
  // ============================================================================
91
186
 
92
- // Helper functions (copied from core/cross-axis-config.ts)
93
- const px = (v: string | number) =>
94
- typeof v === 'number' ? v : parseFloat(String(v)) * (String(v).trim().endsWith('rem') ? 16 : 1);
187
+ // Parse a cross-axis size CONSTANT (rule `fallback`/`delta`), not a token value.
188
+ // Mirrors the hardened finite-size policy (TASK-037): real numbers only, `rem`/
189
+ // `em` 16px-relative, non-finite rejected but allows a leading `-` because a
190
+ // `compare.delta` is a signed offset (e.g. "-560px"). Returns null on garbage so
191
+ // callers reject the rule instead of silently degrading to NaN/0.
192
+ const px = (v: string | number): number | null => {
193
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
194
+ if (typeof v !== 'string') return null;
195
+ const m = v.trim().match(/^(-?\d*\.?\d+)\s*(px|rem|em)?$/i);
196
+ if (!m) return null;
197
+ const n = parseFloat(m[1]);
198
+ if (!Number.isFinite(n)) return null;
199
+ const unit = (m[2] || 'px').toLowerCase();
200
+ return unit === 'rem' || unit === 'em' ? n * 16 : n;
201
+ };
95
202
 
96
203
  const cmp = (a: number, b: number, op: '<=' | '>=' | '<' | '>' | '==' | '!=') =>
97
204
  op === '>=' ? a >= b : op === '>' ? a > b : op === '<=' ? a <= b : op === '<' ? a < b : op === '==' ? a === b : a !== b;
@@ -105,7 +212,10 @@ function valueOrRef(ctx: any, ref?: string, fallback?: string | number) {
105
212
  const v = ctx.getPx(ref);
106
213
  if (v != null) return v;
107
214
  }
108
- return typeof fallback === 'number' ? fallback : px(fallback ?? 0);
215
+ if (typeof fallback === 'number') return fallback;
216
+ // `?? 0` is defensive only: validateRawRule rejects rules whose fallback does
217
+ // not parse, so a compiled rule never reaches here with garbage.
218
+ return px(fallback ?? 0) ?? 0;
109
219
  }
110
220
 
111
221
  function makeOp(op: '<=' | '>=' | '<' | '>' | '==' | '!=', rhs: number) {
@@ -169,15 +279,18 @@ export function parseCrossAxisRules(rawRules: RawCrossAxisRule[], opts: LoadCros
169
279
  return true;
170
280
  };
171
281
 
172
- for (const r of rawRules) {
173
- // Filter by breakpoint
174
- if (r.bp && bp && r.bp !== bp) {
175
- continue;
176
- }
177
- if (r.bp && !bp) {
178
- // Rule targets specific breakpoint; skip in global run
282
+ for (const raw of rawRules) {
283
+ // Filter by breakpoint (shared with coverage enumeration so they never drift).
284
+ if (!ruleMatchesBp(raw as RawCrossAxisRule, bp)) continue;
285
+
286
+ // Validate shape BEFORE compiling: an invalid rule is skipped with a reason,
287
+ // never compiled into an always-true or NaN predicate (TASK-037).
288
+ const valid = validateRawRule(raw);
289
+ if (!valid.ok) {
290
+ skipped.push({ id: valid.id, reason: valid.reason });
179
291
  continue;
180
292
  }
293
+ const r = valid.rule;
181
294
 
182
295
  try {
183
296
  if (r.when && r.require) {
@@ -217,20 +330,18 @@ export function parseCrossAxisRules(rawRules: RawCrossAxisRule[], opts: LoadCros
217
330
  test: (_: number, ctx: any) => {
218
331
  const a = ctx.getPx(r.compare!.a) ?? NaN;
219
332
  const b = ctx.getPx(r.compare!.b) ?? NaN;
220
- const delta = px(r.compare!.delta ?? 0);
333
+ const delta = px(r.compare!.delta ?? 0) ?? 0;
221
334
  if (Number.isNaN(a) || Number.isNaN(b)) return true; // skip check if missing
222
335
  return cmp(a, b + delta, r.compare!.op);
223
336
  },
224
337
  msg: (_: number, ctx: any) => {
225
338
  const a = ctx.getPx(r.compare!.a);
226
339
  const b = ctx.getPx(r.compare!.b);
227
- const delta = px(r.compare!.delta ?? 0);
340
+ const delta = px(r.compare!.delta ?? 0) ?? 0;
228
341
  return `${r.compare!.a} ${prettyFail(r.compare!.op)} ${fmt((b ?? 0) + delta)} (was ${fmt(a ?? NaN)})`;
229
342
  },
230
343
  },
231
344
  });
232
- } else {
233
- skipped.push({ id: r.id, reason: 'neither when+require nor compare present' });
234
345
  }
235
346
  } catch (e: any) {
236
347
  skipped.push({ id: r.id, reason: `exception: ${e?.message ?? e}` });
@@ -287,3 +398,67 @@ export function loadCrossAxisRules(path: string, opts: LoadCrossAxisOptions = {}
287
398
  const result = parseCrossAxisRules(rawRules, opts);
288
399
  return result.rules;
289
400
  }
401
+
402
+ /**
403
+ * Load + parse cross-axis rules AND return notices to surface (TASK-037).
404
+ *
405
+ * Unlike {@link loadCrossAxisRules}, a present-but-unusable file or a skipped
406
+ * (invalid) rule produces a `warn`-level {@link ConstraintIssue} so it appears
407
+ * in the validation result instead of vanishing into a silent "no rules → green".
408
+ */
409
+ export function loadCrossAxisRulesDetailed(
410
+ path: string,
411
+ opts: LoadCrossAxisOptions = {},
412
+ ): { rules: CrossAxisRule[]; notices: ConstraintIssue[] } {
413
+ const file = readCrossAxisRulesFile(path);
414
+ if (file.status === 'missing') return { rules: [], notices: [] };
415
+ if (file.status === 'invalid') {
416
+ return {
417
+ rules: [],
418
+ notices: [
419
+ {
420
+ id: 'cross-axis',
421
+ rule: 'cross-axis',
422
+ level: 'warn',
423
+ where: path,
424
+ message: `Cross-axis rules file present but unusable: ${file.reason}`,
425
+ },
426
+ ],
427
+ };
428
+ }
429
+ const result = parseCrossAxisRules(file.rules, opts);
430
+ const notices: ConstraintIssue[] = result.skipped.map((s) => ({
431
+ id: `cross-axis:${s.id ?? '(no id)'}`,
432
+ rule: 'cross-axis',
433
+ level: 'warn' as const,
434
+ message: `Cross-axis rule ${s.id ? `"${s.id}" ` : ''}skipped: ${s.reason}`,
435
+ }));
436
+ return { rules: result.rules, notices };
437
+ }
438
+
439
+ /**
440
+ * Token ids a cross-axis file contributes to constraint coverage for a given
441
+ * breakpoint scope (TASK-037). Mirrors compilation exactly: the SAME bp filter
442
+ * and the SAME shape validation, so a rule that did not run cannot make coverage
443
+ * look "matched" (which would suppress the "nothing was checked" note).
444
+ *
445
+ * `coverageKnown` is false only when the file is present-but-unusable.
446
+ */
447
+ export function referencedIdsForFile(path: string, bp?: string): { ids: string[]; coverageKnown: boolean } {
448
+ const file = readCrossAxisRulesFile(path);
449
+ if (file.status === 'missing') return { ids: [], coverageKnown: true };
450
+ if (file.status === 'invalid') return { ids: [], coverageKnown: false };
451
+ const ids: string[] = [];
452
+ for (const raw of file.rules) {
453
+ if (!ruleMatchesBp(raw as RawCrossAxisRule, bp)) continue;
454
+ const v = validateRawRule(raw);
455
+ if (!v.ok) continue; // invalid rule did not compile → contributes no coverage
456
+ const r = v.rule;
457
+ if (r.when?.id) ids.push(r.when.id);
458
+ if (r.require?.id) ids.push(r.require.id);
459
+ if (r.require?.ref) ids.push(r.require.ref);
460
+ if (r.compare?.a) ids.push(r.compare.a);
461
+ if (r.compare?.b) ids.push(r.compare.b);
462
+ }
463
+ return { ids, coverageKnown: true };
464
+ }