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.
- package/README.md +89 -23
- package/cli/commands/build.d.ts.map +1 -1
- package/cli/commands/build.js +32 -24
- package/cli/commands/build.ts +26 -17
- package/cli/commands/graph.d.ts.map +1 -1
- package/cli/commands/graph.js +35 -18
- package/cli/commands/graph.ts +30 -17
- package/cli/commands/patch-apply.d.ts.map +1 -1
- package/cli/commands/patch-apply.js +4 -1
- package/cli/commands/patch-apply.ts +4 -1
- package/cli/commands/set.d.ts.map +1 -1
- package/cli/commands/set.js +18 -19
- package/cli/commands/set.ts +19 -19
- package/cli/commands/utils.d.ts +1 -0
- package/cli/commands/utils.d.ts.map +1 -1
- package/cli/commands/utils.js +20 -1
- package/cli/commands/utils.ts +23 -1
- package/cli/commands/validate.d.ts.map +1 -1
- package/cli/commands/validate.js +45 -23
- package/cli/commands/validate.ts +47 -26
- package/cli/commands/why.d.ts.map +1 -1
- package/cli/commands/why.js +22 -10
- package/cli/commands/why.ts +20 -9
- package/cli/config-schema.d.ts +171 -166
- package/cli/config-schema.d.ts.map +1 -1
- package/cli/config-schema.js +29 -7
- package/cli/config-schema.ts +31 -7
- package/cli/config.d.ts.map +1 -1
- package/cli/config.js +8 -2
- package/cli/config.ts +8 -2
- package/cli/constraint-registry.d.ts +16 -0
- package/cli/constraint-registry.d.ts.map +1 -1
- package/cli/constraint-registry.js +115 -44
- package/cli/constraint-registry.ts +118 -47
- package/cli/cross-axis-loader.d.ts +62 -0
- package/cli/cross-axis-loader.d.ts.map +1 -1
- package/cli/cross-axis-loader.js +186 -31
- package/cli/cross-axis-loader.ts +199 -24
- package/cli/dcv.js +31 -25
- package/cli/dcv.ts +31 -21
- package/cli/json-output.d.ts +3 -1
- package/cli/json-output.d.ts.map +1 -1
- package/cli/json-output.js +11 -4
- package/cli/json-output.ts +13 -4
- package/cli/types.d.ts +21 -9
- package/cli/types.d.ts.map +1 -1
- package/cli/types.ts +25 -10
- package/cli/validate-api.d.ts +40 -0
- package/cli/validate-api.d.ts.map +1 -0
- package/cli/validate-api.js +90 -0
- package/cli/validate-api.ts +131 -0
- package/core/breakpoints.d.ts +8 -2
- package/core/breakpoints.d.ts.map +1 -1
- package/core/breakpoints.js +24 -3
- package/core/breakpoints.ts +22 -3
- package/core/color.js +4 -4
- package/core/color.ts +4 -4
- package/core/constraints/cross-axis.d.ts.map +1 -1
- package/core/constraints/cross-axis.js +37 -9
- package/core/constraints/cross-axis.ts +37 -9
- package/core/constraints/monotonic-lightness.d.ts.map +1 -1
- package/core/constraints/monotonic-lightness.js +9 -5
- package/core/constraints/monotonic-lightness.ts +9 -4
- package/core/constraints/monotonic.d.ts.map +1 -1
- package/core/constraints/monotonic.js +32 -8
- package/core/constraints/monotonic.ts +29 -8
- package/core/constraints/threshold.d.ts.map +1 -1
- package/core/constraints/threshold.js +24 -4
- package/core/constraints/threshold.ts +23 -4
- package/core/constraints/wcag.d.ts.map +1 -1
- package/core/constraints/wcag.js +7 -1
- package/core/constraints/wcag.ts +7 -1
- package/core/dtcg.d.ts +38 -0
- package/core/dtcg.d.ts.map +1 -0
- package/core/dtcg.js +88 -0
- package/core/dtcg.ts +102 -0
- package/core/engine.d.ts +6 -0
- package/core/engine.d.ts.map +1 -1
- package/core/engine.ts +7 -0
- package/core/flatten.d.ts +5 -3
- package/core/flatten.d.ts.map +1 -1
- package/core/flatten.js +32 -10
- package/core/flatten.ts +48 -16
- package/core/image-export.d.ts.map +1 -1
- package/core/image-export.js +10 -7
- package/core/image-export.ts +9 -6
- package/core/index.d.ts +2 -0
- package/core/index.d.ts.map +1 -1
- package/core/index.js +4 -0
- package/core/index.ts +6 -0
- package/core/poset.d.ts +6 -1
- package/core/poset.d.ts.map +1 -1
- package/core/poset.js +7 -2
- package/core/poset.ts +7 -2
- package/core/why.d.ts +1 -1
- package/core/why.d.ts.map +1 -1
- package/core/why.ts +1 -1
- package/mcp/contracts.d.ts +1561 -0
- package/mcp/contracts.d.ts.map +1 -0
- package/mcp/contracts.js +74 -0
- package/mcp/contracts.ts +105 -0
- package/mcp/index.d.ts +11 -0
- package/mcp/index.d.ts.map +1 -0
- package/mcp/index.js +35 -0
- package/mcp/index.ts +97 -0
- package/mcp/insights.d.ts +94 -0
- package/mcp/insights.d.ts.map +1 -0
- package/mcp/insights.js +445 -0
- package/mcp/insights.ts +541 -0
- package/mcp/tools.d.ts +63 -0
- package/mcp/tools.d.ts.map +1 -0
- package/mcp/tools.js +299 -0
- package/mcp/tools.ts +431 -0
- package/package.json +36 -26
- package/server.json +21 -0
- package/cli/constraints-loader.d.ts.map +0 -1
- package/cli/engine-helpers.d.ts.map +0 -1
- 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 {
|
|
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
|
|
72
|
-
|
|
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
|
|
88
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
133
|
-
|
|
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
|
|
150
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
1
|
+
{"version":3,"file":"cross-axis-loader.d.ts","sourceRoot":"","sources":["cross-axis-loader.ts"],"names":[],"mappings":"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"}
|