design-constraint-validator 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- 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 +33 -16
- package/cli/commands/graph.ts +28 -15
- 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 +5 -17
- package/cli/commands/validate.ts +10 -18
- 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 +144 -178
- package/cli/config-schema.d.ts.map +1 -1
- package/cli/config-schema.js +25 -5
- package/cli/config-schema.ts +27 -5
- package/cli/constraint-registry.d.ts.map +1 -1
- package/cli/constraint-registry.js +53 -15
- package/cli/constraint-registry.ts +53 -18
- 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 +23 -1
- package/cli/dcv.ts +23 -1
- package/cli/types.d.ts +19 -9
- package/cli/types.d.ts.map +1 -1
- package/cli/types.ts +23 -10
- package/cli/validate-api.d.ts.map +1 -1
- package/cli/validate-api.js +6 -1
- package/cli/validate-api.ts +6 -1
- 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.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.js +1 -1
- package/core/constraints/wcag.ts +1 -1
- package/core/flatten.d.ts.map +1 -1
- package/core/flatten.js +8 -0
- package/core/flatten.ts +9 -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/mcp/contracts.d.ts +1456 -13
- package/mcp/contracts.d.ts.map +1 -1
- package/mcp/contracts.js +45 -1
- package/mcp/contracts.ts +55 -1
- package/mcp/index.d.ts +6 -4
- package/mcp/index.d.ts.map +1 -1
- package/mcp/index.js +6 -3
- package/mcp/index.ts +28 -1
- 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 +14 -3
- package/mcp/tools.d.ts.map +1 -1
- package/mcp/tools.js +133 -6
- package/mcp/tools.ts +188 -11
- package/package.json +2 -7
- package/server.json +3 -3
|
@@ -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
|
|
@@ -98,6 +108,7 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
|
|
|
98
108
|
const { config, basePath = '.', bp, constraintsDir = 'themes' } = opts;
|
|
99
109
|
const sources: ConstraintSource[] = [];
|
|
100
110
|
const constraintsCfg = config.constraints ?? {};
|
|
111
|
+
const constraintsRoot = isAbsolute(constraintsDir) ? constraintsDir : join(basePath, constraintsDir);
|
|
101
112
|
|
|
102
113
|
// 1. Built-in WCAG defaults
|
|
103
114
|
const enableBuiltInWcag =
|
|
@@ -134,17 +145,29 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
|
|
|
134
145
|
op: r.op,
|
|
135
146
|
valuePx: r.valuePx,
|
|
136
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,
|
|
137
151
|
}));
|
|
138
152
|
sources.push({ type: 'custom-threshold', rules });
|
|
139
153
|
}
|
|
140
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
|
+
|
|
141
166
|
// 5. Order files (monotonic constraints)
|
|
142
167
|
const axes = ['typography', 'spacing', 'layout'] as const;
|
|
143
168
|
for (const axis of axes) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (existsSync(orderPath)) {
|
|
169
|
+
const orderPath = orderPathFor(axis);
|
|
170
|
+
if (orderPath) {
|
|
148
171
|
try {
|
|
149
172
|
const data = JSON.parse(readFileSync(orderPath, 'utf8'));
|
|
150
173
|
const orders: OrderRule[] = data.order || [];
|
|
@@ -157,11 +180,9 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
|
|
|
157
180
|
}
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
// 6. Color lightness order files
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (existsSync(colorOrderPath)) {
|
|
183
|
+
// 6. Color lightness order files (same bp → global fallback)
|
|
184
|
+
const colorOrderPath = orderPathFor('color');
|
|
185
|
+
if (colorOrderPath) {
|
|
165
186
|
try {
|
|
166
187
|
const data = JSON.parse(readFileSync(colorOrderPath, 'utf8'));
|
|
167
188
|
const orders: OrderRule[] = data.order || [];
|
|
@@ -174,14 +195,14 @@ export function discoverConstraints(opts: DiscoveryOptions): ConstraintSource[]
|
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
// 7. Cross-axis rules (global)
|
|
177
|
-
const crossAxisPath = join(
|
|
198
|
+
const crossAxisPath = join(constraintsRoot, 'cross-axis.rules.json');
|
|
178
199
|
if (existsSync(crossAxisPath)) {
|
|
179
200
|
sources.push({ type: 'cross-axis-file', path: crossAxisPath });
|
|
180
201
|
}
|
|
181
202
|
|
|
182
203
|
// 8. Cross-axis rules (breakpoint-specific)
|
|
183
204
|
if (bp) {
|
|
184
|
-
const crossAxisBpPath = join(
|
|
205
|
+
const crossAxisBpPath = join(constraintsRoot, `cross-axis.${bp}.rules.json`);
|
|
185
206
|
if (existsSync(crossAxisBpPath)) {
|
|
186
207
|
sources.push({ type: 'cross-axis-file', path: crossAxisBpPath, bp });
|
|
187
208
|
}
|
|
@@ -246,13 +267,16 @@ export function attachConstraints(engine: Engine, sources: ConstraintSource[], o
|
|
|
246
267
|
}
|
|
247
268
|
|
|
248
269
|
case 'cross-axis-file': {
|
|
249
|
-
// Phase 3B: Load rules from filesystem in CLI layer, pass to core plugin
|
|
250
|
-
|
|
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, {
|
|
251
274
|
bp: source.bp,
|
|
252
275
|
knownIds,
|
|
253
276
|
debug: crossAxisDebug,
|
|
254
277
|
});
|
|
255
278
|
engine.use(CrossAxisPlugin(rules, source.bp));
|
|
279
|
+
if (notices.length) engine.use(noticePlugin(notices));
|
|
256
280
|
break;
|
|
257
281
|
}
|
|
258
282
|
}
|
|
@@ -330,9 +354,20 @@ export function collectReferencedIds(sources: ConstraintSource[]): { ids: Set<st
|
|
|
330
354
|
case 'lightness-file':
|
|
331
355
|
addOrders(source.orders);
|
|
332
356
|
break;
|
|
333
|
-
case 'cross-axis-file':
|
|
334
|
-
|
|
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);
|
|
335
369
|
break;
|
|
370
|
+
}
|
|
336
371
|
}
|
|
337
372
|
}
|
|
338
373
|
|
|
@@ -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":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEzD;;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;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAC9B;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAEhD,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,sBAAsB,CAa3E;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,SAAS,CAGvF;AA6BD,MAAM,MAAM,cAAc,GACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,gBAAgB,CAAA;CAAE,GACpC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/C,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,cAAc,CA2B5D;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAItE;AAuED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,EAAE,IAAI,GAAE,oBAAyB,GAAG,mBAAmB,CA8GtH;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,aAAa,EAAE,CAejG;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,oBAAyB,GAC9B;IAAE,KAAK,EAAE,aAAa,EAAE,CAAC;IAAC,OAAO,EAAE,eAAe,EAAE,CAAA;CAAE,CAyBxD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,EAAE,CAAC;IAAC,aAAa,EAAE,OAAO,CAAA;CAAE,CAiBzG"}
|
package/cli/cross-axis-loader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
// Filter by breakpoint
|
|
109
|
-
if (
|
|
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
|
-
|
|
113
|
-
|
|
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
|
+
}
|