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
@@ -11,13 +11,32 @@
11
11
  * - All entry points (validate, set, graph) use this registry for consistency
12
12
  */
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
- import { join } from 'node:path';
14
+ import { isAbsolute, join } from 'node:path';
15
15
  import { MonotonicPlugin, parseSize as parseSizePx } from '../core/constraints/monotonic.js';
16
16
  import { MonotonicLightness } from '../core/constraints/monotonic-lightness.js';
17
17
  import { WcagContrastPlugin } from '../core/constraints/wcag.js';
18
18
  import { ThresholdPlugin } from '../core/constraints/threshold.js';
19
19
  import { CrossAxisPlugin } from '../core/constraints/cross-axis.js';
20
- import { loadCrossAxisRules } from './cross-axis-loader.js';
20
+ import { loadCrossAxisRulesDetailed, referencedIdsForFile } from './cross-axis-loader.js';
21
+ /**
22
+ * A plugin that emits a fixed set of pre-computed issues unconditionally (it
23
+ * ignores the candidate set). Used to surface cross-axis loader notices —
24
+ * present-but-unusable files and skipped invalid rules (TASK-037) — through the
25
+ * normal issue channel so they appear as warnings instead of vanishing.
26
+ */
27
+ function noticePlugin(notices) {
28
+ return { id: 'cross-axis-notices', evaluate: () => notices };
29
+ }
30
+ // Built-in default constraint rules. Shared by attachConstraints (to register
31
+ // plugins) and collectReferencedIds (to compute coverage) so the two never drift.
32
+ export const DEFAULT_WCAG_PAIRS = [
33
+ { fg: 'color.role.text.default', bg: 'color.role.bg.surface', min: 4.5, where: 'Body text on surface' },
34
+ { fg: 'color.role.accent.default', bg: 'color.role.bg.surface', min: 3.0, where: 'Accent on surface' },
35
+ { fg: 'color.role.focus.ring', bg: 'color.role.bg.surface', min: 3.0, where: 'Focus ring on surface', backdrop: '#ffffff' },
36
+ ];
37
+ export const DEFAULT_THRESHOLDS = [
38
+ { id: 'control.size.min', op: '>=', valuePx: 44, where: 'Touch target (WCAG / Apple HIG)' },
39
+ ];
21
40
  // ============================================================================
22
41
  // Discovery
23
42
  // ============================================================================
@@ -34,6 +53,7 @@ export function discoverConstraints(opts) {
34
53
  const { config, basePath = '.', bp, constraintsDir = 'themes' } = opts;
35
54
  const sources = [];
36
55
  const constraintsCfg = config.constraints ?? {};
56
+ const constraintsRoot = isAbsolute(constraintsDir) ? constraintsDir : join(basePath, constraintsDir);
37
57
  // 1. Built-in WCAG defaults
38
58
  const enableBuiltInWcag = constraintsCfg.enableBuiltInWcagDefaults === undefined ? true : !!constraintsCfg.enableBuiltInWcagDefaults;
39
59
  if (enableBuiltInWcag) {
@@ -62,15 +82,28 @@ export function discoverConstraints(opts) {
62
82
  op: r.op,
63
83
  valuePx: r.valuePx,
64
84
  where: r.where,
85
+ // Preserve configured severity (TASK-037): the core plugin honors `level`,
86
+ // but dropping it here silently promoted a `warn` threshold to an error.
87
+ level: r.level,
65
88
  }));
66
89
  sources.push({ type: 'custom-threshold', rules });
67
90
  }
91
+ // A breakpoint uses its own `<axis>.<bp>.order.json` when present, but MUST fall
92
+ // back to the global `<axis>.order.json` otherwise — without this, any axis
93
+ // lacking a per-bp file (e.g. spacing) contributed ZERO constraints under
94
+ // --breakpoint/--all-breakpoints, a silent false-pass (TASK-034).
95
+ const orderPathFor = (base) => {
96
+ const bpPath = bp ? join(constraintsRoot, `${base}.${bp}.order.json`) : undefined;
97
+ if (bpPath && existsSync(bpPath))
98
+ return bpPath;
99
+ const globalPath = join(constraintsRoot, `${base}.order.json`);
100
+ return existsSync(globalPath) ? globalPath : undefined;
101
+ };
68
102
  // 5. Order files (monotonic constraints)
69
103
  const axes = ['typography', 'spacing', 'layout'];
70
104
  for (const axis of axes) {
71
- const suffix = bp ? `.${bp}` : '';
72
- const orderPath = join(basePath, constraintsDir, `${axis}${suffix}.order.json`);
73
- if (existsSync(orderPath)) {
105
+ const orderPath = orderPathFor(axis);
106
+ if (orderPath) {
74
107
  try {
75
108
  const data = JSON.parse(readFileSync(orderPath, 'utf8'));
76
109
  const orders = data.order || [];
@@ -83,10 +116,9 @@ export function discoverConstraints(opts) {
83
116
  }
84
117
  }
85
118
  }
86
- // 6. Color lightness order files
87
- const suffix = bp ? `.${bp}` : '';
88
- const colorOrderPath = join(basePath, constraintsDir, `color${suffix}.order.json`);
89
- if (existsSync(colorOrderPath)) {
119
+ // 6. Color lightness order files (same bp → global fallback)
120
+ const colorOrderPath = orderPathFor('color');
121
+ if (colorOrderPath) {
90
122
  try {
91
123
  const data = JSON.parse(readFileSync(colorOrderPath, 'utf8'));
92
124
  const orders = data.order || [];
@@ -99,13 +131,13 @@ export function discoverConstraints(opts) {
99
131
  }
100
132
  }
101
133
  // 7. Cross-axis rules (global)
102
- const crossAxisPath = join(basePath, constraintsDir, 'cross-axis.rules.json');
134
+ const crossAxisPath = join(constraintsRoot, 'cross-axis.rules.json');
103
135
  if (existsSync(crossAxisPath)) {
104
136
  sources.push({ type: 'cross-axis-file', path: crossAxisPath });
105
137
  }
106
138
  // 8. Cross-axis rules (breakpoint-specific)
107
139
  if (bp) {
108
- const crossAxisBpPath = join(basePath, constraintsDir, `cross-axis.${bp}.rules.json`);
140
+ const crossAxisBpPath = join(constraintsRoot, `cross-axis.${bp}.rules.json`);
109
141
  if (existsSync(crossAxisBpPath)) {
110
142
  sources.push({ type: 'cross-axis-file', path: crossAxisBpPath, bp });
111
143
  }
@@ -132,42 +164,13 @@ export function attachConstraints(engine, sources, opts) {
132
164
  switch (source.type) {
133
165
  case 'builtin-wcag': {
134
166
  if (source.enabled) {
135
- const defaultWcagPairs = [
136
- {
137
- fg: 'color.role.text.default',
138
- bg: 'color.role.bg.surface',
139
- min: 4.5,
140
- where: 'Body text on surface',
141
- },
142
- {
143
- fg: 'color.role.accent.default',
144
- bg: 'color.role.bg.surface',
145
- min: 3.0,
146
- where: 'Accent on surface',
147
- },
148
- {
149
- fg: 'color.role.focus.ring',
150
- bg: 'color.role.bg.surface',
151
- min: 3.0,
152
- where: 'Focus ring on surface',
153
- backdrop: '#ffffff',
154
- },
155
- ];
156
- engine.use(WcagContrastPlugin(defaultWcagPairs));
167
+ engine.use(WcagContrastPlugin(DEFAULT_WCAG_PAIRS));
157
168
  }
158
169
  break;
159
170
  }
160
171
  case 'builtin-threshold': {
161
172
  if (source.enabled) {
162
- const defaultThresholds = [
163
- {
164
- id: 'control.size.min',
165
- op: '>=',
166
- valuePx: 44,
167
- where: 'Touch target (WCAG / Apple HIG)',
168
- },
169
- ];
170
- engine.use(ThresholdPlugin(defaultThresholds, 'threshold'));
173
+ engine.use(ThresholdPlugin(DEFAULT_THRESHOLDS, 'threshold'));
171
174
  }
172
175
  break;
173
176
  }
@@ -189,13 +192,17 @@ export function attachConstraints(engine, sources, opts) {
189
192
  break;
190
193
  }
191
194
  case 'cross-axis-file': {
192
- // Phase 3B: Load rules from filesystem in CLI layer, pass to core plugin
193
- const rules = loadCrossAxisRules(source.path, {
195
+ // Phase 3B: Load rules from filesystem in CLI layer, pass to core plugin.
196
+ // Detailed load also surfaces notices (unusable file / skipped invalid
197
+ // rules) as warnings so they are never silently dropped (TASK-037).
198
+ const { rules, notices } = loadCrossAxisRulesDetailed(source.path, {
194
199
  bp: source.bp,
195
200
  knownIds,
196
201
  debug: crossAxisDebug,
197
202
  });
198
203
  engine.use(CrossAxisPlugin(rules, source.bp));
204
+ if (notices.length)
205
+ engine.use(noticePlugin(notices));
199
206
  break;
200
207
  }
201
208
  }
@@ -223,3 +230,67 @@ export function setupConstraints(engine, discoveryOpts, attachOpts) {
223
230
  attachConstraints(engine, sources, attachOpts);
224
231
  return sources;
225
232
  }
233
+ /**
234
+ * Collect the token ids referenced by the active constraint sources, plus
235
+ * whether that coverage is fully enumerable.
236
+ *
237
+ * Used to detect the silent-pass case: a token file that validates with zero
238
+ * errors only because no active constraint references any of its tokens. Cross-
239
+ * axis rule ids are not enumerated here, so when any cross-axis source is present
240
+ * `coverageKnown` is false and callers must stay conservative (never claim
241
+ * "nothing was checked" when they cannot be sure).
242
+ */
243
+ export function collectReferencedIds(sources) {
244
+ const ids = new Set();
245
+ let coverageKnown = true;
246
+ const addOrders = (orders) => {
247
+ for (const [a, , b] of orders) {
248
+ ids.add(a);
249
+ ids.add(b);
250
+ }
251
+ };
252
+ for (const source of sources) {
253
+ switch (source.type) {
254
+ case 'builtin-wcag':
255
+ for (const p of DEFAULT_WCAG_PAIRS) {
256
+ ids.add(p.fg);
257
+ ids.add(p.bg);
258
+ }
259
+ break;
260
+ case 'builtin-threshold':
261
+ for (const t of DEFAULT_THRESHOLDS)
262
+ ids.add(t.id);
263
+ break;
264
+ case 'config-wcag':
265
+ for (const r of source.rules) {
266
+ ids.add(r.fg);
267
+ ids.add(r.bg);
268
+ }
269
+ break;
270
+ case 'custom-threshold':
271
+ for (const r of source.rules)
272
+ ids.add(r.id);
273
+ break;
274
+ case 'order-file':
275
+ case 'lightness-file':
276
+ addOrders(source.orders);
277
+ break;
278
+ case 'cross-axis-file': {
279
+ // TASK-031/037: enumerate referenced ids so coverage stays KNOWN, but
280
+ // MIRROR attach exactly — same `source.bp` filter and same shape
281
+ // validation. A rule that did not run (wrong breakpoint, or invalid)
282
+ // must not make coverage look "matched" and suppress the no-match note.
283
+ // An unusable file stays conservative (coverageKnown=false).
284
+ const { ids: caIds, coverageKnown: known } = referencedIdsForFile(source.path, source.bp);
285
+ if (!known) {
286
+ coverageKnown = false;
287
+ break;
288
+ }
289
+ for (const id of caIds)
290
+ ids.add(id);
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ return { ids, coverageKnown };
296
+ }
@@ -12,8 +12,8 @@
12
12
  */
13
13
 
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
- import { join } from 'node:path';
16
- import type { Engine } from '../core/engine.js';
15
+ import { isAbsolute, join } from 'node:path';
16
+ import type { Engine, ConstraintIssue, ConstraintPlugin } from '../core/engine.js';
17
17
  import type { Breakpoint } from '../core/breakpoints.js';
18
18
  import type { DcvConfig } from './types.js';
19
19
  import { MonotonicPlugin, parseSize as parseSizePx } from '../core/constraints/monotonic.js';
@@ -21,7 +21,17 @@ import { MonotonicLightness } from '../core/constraints/monotonic-lightness.js';
21
21
  import { WcagContrastPlugin } from '../core/constraints/wcag.js';
22
22
  import { ThresholdPlugin } from '../core/constraints/threshold.js';
23
23
  import { CrossAxisPlugin } from '../core/constraints/cross-axis.js';
24
- import { loadCrossAxisRules } from './cross-axis-loader.js';
24
+ import { loadCrossAxisRulesDetailed, referencedIdsForFile } from './cross-axis-loader.js';
25
+
26
+ /**
27
+ * A plugin that emits a fixed set of pre-computed issues unconditionally (it
28
+ * ignores the candidate set). Used to surface cross-axis loader notices —
29
+ * present-but-unusable files and skipped invalid rules (TASK-037) — through the
30
+ * normal issue channel so they appear as warnings instead of vanishing.
31
+ */
32
+ function noticePlugin(notices: ConstraintIssue[]): ConstraintPlugin {
33
+ return { id: 'cross-axis-notices', evaluate: () => notices };
34
+ }
25
35
 
26
36
  // ============================================================================
27
37
  // Types
@@ -69,6 +79,18 @@ export type AttachOptions = {
69
79
  crossAxisDebug?: boolean;
70
80
  };
71
81
 
82
+ // Built-in default constraint rules. Shared by attachConstraints (to register
83
+ // plugins) and collectReferencedIds (to compute coverage) so the two never drift.
84
+ export const DEFAULT_WCAG_PAIRS: WcagRule[] = [
85
+ { fg: 'color.role.text.default', bg: 'color.role.bg.surface', min: 4.5, where: 'Body text on surface' },
86
+ { fg: 'color.role.accent.default', bg: 'color.role.bg.surface', min: 3.0, where: 'Accent on surface' },
87
+ { fg: 'color.role.focus.ring', bg: 'color.role.bg.surface', min: 3.0, where: 'Focus ring on surface', backdrop: '#ffffff' },
88
+ ];
89
+
90
+ export const DEFAULT_THRESHOLDS: ThresholdRule[] = [
91
+ { id: 'control.size.min', op: '>=', valuePx: 44, where: 'Touch target (WCAG / Apple HIG)' },
92
+ ];
93
+
72
94
  // ============================================================================
73
95
  // Discovery
74
96
  // ============================================================================
@@ -86,6 +108,7 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
86
108
  const { config, basePath = '.', bp, constraintsDir = 'themes' } = opts;
87
109
  const sources: ConstraintSource[] = [];
88
110
  const constraintsCfg = config.constraints ?? {};
111
+ const constraintsRoot = isAbsolute(constraintsDir) ? constraintsDir : join(basePath, constraintsDir);
89
112
 
90
113
  // 1. Built-in WCAG defaults
91
114
  const enableBuiltInWcag =
@@ -122,17 +145,29 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
122
145
  op: r.op,
123
146
  valuePx: r.valuePx,
124
147
  where: r.where,
148
+ // Preserve configured severity (TASK-037): the core plugin honors `level`,
149
+ // but dropping it here silently promoted a `warn` threshold to an error.
150
+ level: r.level,
125
151
  }));
126
152
  sources.push({ type: 'custom-threshold', rules });
127
153
  }
128
154
 
155
+ // A breakpoint uses its own `<axis>.<bp>.order.json` when present, but MUST fall
156
+ // back to the global `<axis>.order.json` otherwise — without this, any axis
157
+ // lacking a per-bp file (e.g. spacing) contributed ZERO constraints under
158
+ // --breakpoint/--all-breakpoints, a silent false-pass (TASK-034).
159
+ const orderPathFor = (base: string): string | undefined => {
160
+ const bpPath = bp ? join(constraintsRoot, `${base}.${bp}.order.json`) : undefined;
161
+ if (bpPath && existsSync(bpPath)) return bpPath;
162
+ const globalPath = join(constraintsRoot, `${base}.order.json`);
163
+ return existsSync(globalPath) ? globalPath : undefined;
164
+ };
165
+
129
166
  // 5. Order files (monotonic constraints)
130
167
  const axes = ['typography', 'spacing', 'layout'] as const;
131
168
  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)) {
169
+ const orderPath = orderPathFor(axis);
170
+ if (orderPath) {
136
171
  try {
137
172
  const data = JSON.parse(readFileSync(orderPath, 'utf8'));
138
173
  const orders: OrderRule[] = data.order || [];
@@ -145,11 +180,9 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
145
180
  }
146
181
  }
147
182
 
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)) {
183
+ // 6. Color lightness order files (same bp → global fallback)
184
+ const colorOrderPath = orderPathFor('color');
185
+ if (colorOrderPath) {
153
186
  try {
154
187
  const data = JSON.parse(readFileSync(colorOrderPath, 'utf8'));
155
188
  const orders: OrderRule[] = data.order || [];
@@ -162,14 +195,14 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
162
195
  }
163
196
 
164
197
  // 7. Cross-axis rules (global)
165
- const crossAxisPath = join(basePath, constraintsDir, 'cross-axis.rules.json');
198
+ const crossAxisPath = join(constraintsRoot, 'cross-axis.rules.json');
166
199
  if (existsSync(crossAxisPath)) {
167
200
  sources.push({ type: 'cross-axis-file', path: crossAxisPath });
168
201
  }
169
202
 
170
203
  // 8. Cross-axis rules (breakpoint-specific)
171
204
  if (bp) {
172
- const crossAxisBpPath = join(basePath, constraintsDir, `cross-axis.${bp}.rules.json`);
205
+ const crossAxisBpPath = join(constraintsRoot, `cross-axis.${bp}.rules.json`);
173
206
  if (existsSync(crossAxisBpPath)) {
174
207
  sources.push({ type: 'cross-axis-file', path: crossAxisBpPath, bp });
175
208
  }
@@ -200,43 +233,14 @@ export function attachConstraints(engine: Engine, sources: ConstraintSource[], o
200
233
  switch (source.type) {
201
234
  case 'builtin-wcag': {
202
235
  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));
236
+ engine.use(WcagContrastPlugin(DEFAULT_WCAG_PAIRS));
225
237
  }
226
238
  break;
227
239
  }
228
240
 
229
241
  case 'builtin-threshold': {
230
242
  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'));
243
+ engine.use(ThresholdPlugin(DEFAULT_THRESHOLDS, 'threshold'));
240
244
  }
241
245
  break;
242
246
  }
@@ -263,13 +267,16 @@ export function attachConstraints(engine: Engine, sources: ConstraintSource[], o
263
267
  }
264
268
 
265
269
  case 'cross-axis-file': {
266
- // Phase 3B: Load rules from filesystem in CLI layer, pass to core plugin
267
- const rules = loadCrossAxisRules(source.path, {
270
+ // Phase 3B: Load rules from filesystem in CLI layer, pass to core plugin.
271
+ // Detailed load also surfaces notices (unusable file / skipped invalid
272
+ // rules) as warnings so they are never silently dropped (TASK-037).
273
+ const { rules, notices } = loadCrossAxisRulesDetailed(source.path, {
268
274
  bp: source.bp,
269
275
  knownIds,
270
276
  debug: crossAxisDebug,
271
277
  });
272
278
  engine.use(CrossAxisPlugin(rules, source.bp));
279
+ if (notices.length) engine.use(noticePlugin(notices));
273
280
  break;
274
281
  }
275
282
  }
@@ -302,3 +309,67 @@ export function setupConstraints(
302
309
  attachConstraints(engine, sources, attachOpts);
303
310
  return sources;
304
311
  }
312
+
313
+ /**
314
+ * Collect the token ids referenced by the active constraint sources, plus
315
+ * whether that coverage is fully enumerable.
316
+ *
317
+ * Used to detect the silent-pass case: a token file that validates with zero
318
+ * errors only because no active constraint references any of its tokens. Cross-
319
+ * axis rule ids are not enumerated here, so when any cross-axis source is present
320
+ * `coverageKnown` is false and callers must stay conservative (never claim
321
+ * "nothing was checked" when they cannot be sure).
322
+ */
323
+ export function collectReferencedIds(sources: ConstraintSource[]): { ids: Set<string>; coverageKnown: boolean } {
324
+ const ids = new Set<string>();
325
+ let coverageKnown = true;
326
+ const addOrders = (orders: OrderRule[]) => {
327
+ for (const [a, , b] of orders) {
328
+ ids.add(a);
329
+ ids.add(b);
330
+ }
331
+ };
332
+
333
+ for (const source of sources) {
334
+ switch (source.type) {
335
+ case 'builtin-wcag':
336
+ for (const p of DEFAULT_WCAG_PAIRS) {
337
+ ids.add(p.fg);
338
+ ids.add(p.bg);
339
+ }
340
+ break;
341
+ case 'builtin-threshold':
342
+ for (const t of DEFAULT_THRESHOLDS) ids.add(t.id);
343
+ break;
344
+ case 'config-wcag':
345
+ for (const r of source.rules) {
346
+ ids.add(r.fg);
347
+ ids.add(r.bg);
348
+ }
349
+ break;
350
+ case 'custom-threshold':
351
+ for (const r of source.rules) ids.add(r.id);
352
+ break;
353
+ case 'order-file':
354
+ case 'lightness-file':
355
+ addOrders(source.orders);
356
+ break;
357
+ case 'cross-axis-file': {
358
+ // TASK-031/037: enumerate referenced ids so coverage stays KNOWN, but
359
+ // MIRROR attach exactly — same `source.bp` filter and same shape
360
+ // validation. A rule that did not run (wrong breakpoint, or invalid)
361
+ // must not make coverage look "matched" and suppress the no-match note.
362
+ // An unusable file stays conservative (coverageKnown=false).
363
+ const { ids: caIds, coverageKnown: known } = referencedIdsForFile(source.path, source.bp);
364
+ if (!known) {
365
+ coverageKnown = false;
366
+ break;
367
+ }
368
+ for (const id of caIds) ids.add(id);
369
+ break;
370
+ }
371
+ }
372
+ }
373
+
374
+ return { ids, coverageKnown };
375
+ }
@@ -8,6 +8,7 @@
8
8
  * while CLI modules use this loader to read from filesystem.
9
9
  */
10
10
  import type { CrossAxisRule } from '../core/constraints/cross-axis.js';
11
+ import type { ConstraintIssue } from '../core/engine.js';
11
12
  /**
12
13
  * Raw rule format as stored in JSON files.
13
14
  */
@@ -64,7 +65,45 @@ export type LoadCrossAxisOptions = {
64
65
  * @param path Path to cross-axis rules JSON file
65
66
  * @returns Parsed rules or undefined if file missing/invalid
66
67
  */
68
+ /**
69
+ * Outcome of reading a cross-axis rules file, distinguishing the three cases a
70
+ * validator must not collapse (TASK-037): a *missing* file (no rules, fine) vs.
71
+ * a *present-but-unusable* file (bad JSON / wrong shape — must be surfaced, never
72
+ * silently treated as "no rules → green") vs. a successfully read rule array.
73
+ */
74
+ export type RawCrossAxisFileResult = {
75
+ status: 'missing';
76
+ } | {
77
+ status: 'invalid';
78
+ reason: string;
79
+ } | {
80
+ status: 'ok';
81
+ rules: RawCrossAxisRule[];
82
+ };
83
+ export declare function readCrossAxisRulesFile(path: string): RawCrossAxisFileResult;
84
+ /**
85
+ * Back-compat shim: returns the rule array, or `undefined` for both missing and
86
+ * present-but-unusable files. Prefer {@link readCrossAxisRulesFile} when the
87
+ * caller needs to surface the unusable case.
88
+ */
67
89
  export declare function loadCrossAxisRulesFromFile(path: string): RawCrossAxisRule[] | undefined;
90
+ export type RuleValidation = {
91
+ ok: true;
92
+ rule: RawCrossAxisRule;
93
+ } | {
94
+ ok: false;
95
+ id?: string;
96
+ reason: string;
97
+ };
98
+ export declare function validateRawRule(raw: unknown): RuleValidation;
99
+ /**
100
+ * Whether a rule is active for a given breakpoint scope. Shared by rule
101
+ * compilation and coverage enumeration so the two never drift (TASK-037): a
102
+ * rule that did not run must not be able to make coverage look "matched".
103
+ */
104
+ export declare function ruleMatchesBp(r: {
105
+ bp?: string;
106
+ }, bp?: string): boolean;
68
107
  /**
69
108
  * Parse raw cross-axis rules into executable constraint rules.
70
109
  *
@@ -88,4 +127,27 @@ export declare function parseCrossAxisRules(rawRules: RawCrossAxisRule[], opts?:
88
127
  * @returns Parsed rules (empty array if file doesn't exist)
89
128
  */
90
129
  export declare function loadCrossAxisRules(path: string, opts?: LoadCrossAxisOptions): CrossAxisRule[];
130
+ /**
131
+ * Load + parse cross-axis rules AND return notices to surface (TASK-037).
132
+ *
133
+ * Unlike {@link loadCrossAxisRules}, a present-but-unusable file or a skipped
134
+ * (invalid) rule produces a `warn`-level {@link ConstraintIssue} so it appears
135
+ * in the validation result instead of vanishing into a silent "no rules → green".
136
+ */
137
+ export declare function loadCrossAxisRulesDetailed(path: string, opts?: LoadCrossAxisOptions): {
138
+ rules: CrossAxisRule[];
139
+ notices: ConstraintIssue[];
140
+ };
141
+ /**
142
+ * Token ids a cross-axis file contributes to constraint coverage for a given
143
+ * breakpoint scope (TASK-037). Mirrors compilation exactly: the SAME bp filter
144
+ * and the SAME shape validation, so a rule that did not run cannot make coverage
145
+ * look "matched" (which would suppress the "nothing was checked" note).
146
+ *
147
+ * `coverageKnown` is false only when the file is present-but-unusable.
148
+ */
149
+ export declare function referencedIdsForFile(path: string, bp?: string): {
150
+ ids: string[];
151
+ coverageKnown: boolean;
152
+ };
91
153
  //# sourceMappingURL=cross-axis-loader.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"cross-axis-loader.d.ts","sourceRoot":"","sources":["cross-axis-loader.ts"],"names":[],"mappings":"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"}