design-constraint-validator 1.0.0 → 1.1.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/LICENSE +21 -21
- package/README.md +215 -659
- package/adapters/README.md +46 -46
- package/adapters/css.ts +116 -116
- package/adapters/js.ts +14 -14
- package/adapters/json.ts +45 -45
- package/cli/build-css.ts +32 -32
- package/cli/commands/build.ts +65 -65
- package/cli/commands/graph.d.ts.map +1 -1
- package/cli/commands/graph.js +26 -10
- package/cli/commands/graph.ts +180 -137
- package/cli/commands/index.ts +7 -7
- package/cli/commands/patch-apply.ts +80 -80
- package/cli/commands/patch.ts +22 -22
- package/cli/commands/set.d.ts.map +1 -1
- package/cli/commands/set.js +12 -4
- package/cli/commands/set.ts +239 -225
- package/cli/commands/utils.ts +50 -50
- package/cli/commands/validate.d.ts.map +1 -1
- package/cli/commands/validate.js +86 -33
- package/cli/commands/validate.ts +176 -115
- package/cli/commands/why.d.ts.map +1 -1
- package/cli/commands/why.js +86 -20
- package/cli/commands/why.ts +158 -46
- package/cli/config-schema.ts +27 -27
- package/cli/config.ts +35 -35
- package/cli/constraint-registry.d.ts +101 -0
- package/cli/constraint-registry.d.ts.map +1 -0
- package/cli/constraint-registry.js +225 -0
- package/cli/constraint-registry.ts +304 -0
- package/cli/constraints-loader.d.ts +30 -0
- package/cli/constraints-loader.d.ts.map +1 -0
- package/cli/constraints-loader.js +58 -0
- package/cli/constraints-loader.ts +83 -0
- package/cli/cross-axis-loader.d.ts +91 -0
- package/cli/cross-axis-loader.d.ts.map +1 -0
- package/cli/cross-axis-loader.js +222 -0
- package/cli/cross-axis-loader.ts +289 -0
- package/cli/dcv.js +4 -0
- package/cli/dcv.ts +111 -107
- package/cli/engine-helpers.d.ts +33 -0
- package/cli/engine-helpers.d.ts.map +1 -1
- package/cli/engine-helpers.js +87 -22
- package/cli/engine-helpers.ts +133 -61
- package/cli/graph-poset.ts +74 -74
- package/cli/json-output.d.ts +64 -0
- package/cli/json-output.d.ts.map +1 -0
- package/cli/json-output.js +107 -0
- package/cli/json-output.ts +177 -0
- package/cli/result.ts +27 -27
- package/cli/run.ts +54 -54
- package/cli/smoke-test.ts +40 -40
- package/cli/types.d.ts +6 -0
- package/cli/types.d.ts.map +1 -1
- package/cli/types.ts +84 -78
- package/core/breakpoints.ts +50 -50
- package/core/cli-format.ts +31 -31
- package/core/color.ts +148 -148
- package/core/constraints/cross-axis.ts +114 -114
- package/core/constraints/monotonic-lightness.ts +38 -38
- package/core/constraints/monotonic.ts +74 -74
- package/core/constraints/threshold.ts +43 -43
- package/core/constraints/wcag.ts +70 -70
- package/core/cross-axis-config.d.ts +29 -0
- package/core/cross-axis-config.d.ts.map +1 -1
- package/core/cross-axis-config.js +29 -0
- package/core/cross-axis-config.ts +181 -151
- package/core/engine.d.ts +95 -0
- package/core/engine.d.ts.map +1 -1
- package/core/engine.js +22 -0
- package/core/engine.ts +167 -65
- package/core/flatten.ts +116 -116
- package/core/image-export.ts +48 -48
- package/core/index.d.ts +9 -30
- package/core/index.d.ts.map +1 -1
- package/core/index.js +7 -54
- package/core/index.ts +10 -72
- package/core/patch.ts +134 -134
- package/core/poset.ts +311 -311
- package/core/why.ts +63 -63
- package/package.json +96 -90
- package/themes/color.lg.order.json +15 -15
- package/themes/color.md.order.json +15 -15
- package/themes/color.order.json +15 -15
- package/themes/color.sm.order.json +15 -15
- package/themes/cross-axis.rules.json +35 -35
- package/themes/cross-axis.sm.rules.json +12 -12
- package/themes/layout.lg.order.json +18 -18
- package/themes/layout.md.order.json +18 -18
- package/themes/layout.order.json +18 -18
- package/themes/layout.sm.order.json +18 -18
- package/themes/spacing.order.json +14 -14
- package/themes/typography.lg.order.json +15 -15
- package/themes/typography.md.order.json +15 -15
- package/themes/typography.order.json +15 -15
- package/themes/typography.sm.order.json +15 -15
- package/dist/test-overrides-removal.json +0 -4
- package/dist/tmp.patch.json +0 -35
- package/tokens/overrides/base.json +0 -22
- package/tokens/overrides/lg.json +0 -20
- package/tokens/overrides/md.json +0 -16
- package/tokens/overrides/sm.json +0 -16
- package/tokens/overrides/viol.color.json +0 -6
- package/tokens/overrides/viol.typography.json +0 -6
- package/tokens/tokens.demo-violations.json +0 -116
- package/tokens/tokens.example.json +0 -128
- package/tokens/tokens.json +0 -67
- package/tokens/tokens.multi-violations.json +0 -21
- package/tokens/tokens.schema.d.ts +0 -2298
- package/tokens/tokens.schema.d.ts.map +0 -1
- package/tokens/tokens.schema.js +0 -148
- package/tokens/tokens.schema.ts +0 -196
- package/tokens/tokens.test.json +0 -38
- package/tokens/tokens.touch-violation.json +0 -8
- package/tokens/typography.classes.css +0 -11
- package/tokens/typography.css +0 -20
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated This module is deprecated. Use constraint-registry.ts instead.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3A (Architectural Cleanup): This file contains legacy runtime constraint
|
|
5
|
+
* loading logic that has been replaced by the centralized constraint-registry.ts module.
|
|
6
|
+
*
|
|
7
|
+
* Migration: Replace attachRuntimeConstraints() with setupConstraints() from constraint-registry.ts
|
|
8
|
+
*
|
|
9
|
+
* This file will be removed in a future major version.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Engine } from '../core/engine.js';
|
|
13
|
+
import { loadCrossAxisPlugin } from '../core/cross-axis-config.js';
|
|
14
|
+
import { ThresholdPlugin } from '../core/constraints/threshold.js';
|
|
15
|
+
import type { Breakpoint } from '../core/breakpoints.js';
|
|
16
|
+
import type { DcvConfig } from './types.js';
|
|
17
|
+
|
|
18
|
+
type AttachRuntimeOpts = {
|
|
19
|
+
config: DcvConfig;
|
|
20
|
+
knownIds: Set<string>;
|
|
21
|
+
bp?: Breakpoint;
|
|
22
|
+
crossAxisDebug?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attach runtime constraints that depend on project files or built-in policies:
|
|
27
|
+
* - Cross-axis rules from themes/cross-axis*.rules.json
|
|
28
|
+
* - Built-in threshold rules (e.g., control.size.min >= 44px)
|
|
29
|
+
*
|
|
30
|
+
* @deprecated Use setupConstraints() from constraint-registry.ts instead.
|
|
31
|
+
* This function will be removed in a future major version.
|
|
32
|
+
*/
|
|
33
|
+
export function attachRuntimeConstraints(engine: Engine, opts: AttachRuntimeOpts): void {
|
|
34
|
+
const { knownIds, bp, crossAxisDebug, config } = opts;
|
|
35
|
+
|
|
36
|
+
// Cross-axis rules: global + optional breakpoint-specific
|
|
37
|
+
try {
|
|
38
|
+
engine.use(
|
|
39
|
+
loadCrossAxisPlugin('themes/cross-axis.rules.json', bp, {
|
|
40
|
+
debug: !!crossAxisDebug,
|
|
41
|
+
knownIds,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (bp) {
|
|
46
|
+
const bpRulesPath = `themes/cross-axis.${bp}.rules.json`;
|
|
47
|
+
engine.use(
|
|
48
|
+
loadCrossAxisPlugin(bpRulesPath, bp, {
|
|
49
|
+
debug: !!crossAxisDebug,
|
|
50
|
+
knownIds,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// If cross-axis configuration fails, continue with other constraints.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const constraintsCfg = config.constraints ?? {};
|
|
59
|
+
|
|
60
|
+
// Built-in threshold rule for touch targets (configurable)
|
|
61
|
+
const enableBuiltInThreshold =
|
|
62
|
+
constraintsCfg.enableBuiltInThreshold === undefined ? true : !!constraintsCfg.enableBuiltInThreshold;
|
|
63
|
+
|
|
64
|
+
if (enableBuiltInThreshold) {
|
|
65
|
+
try {
|
|
66
|
+
engine.use(
|
|
67
|
+
ThresholdPlugin(
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
id: 'control.size.min',
|
|
71
|
+
op: '>=',
|
|
72
|
+
valuePx: 44,
|
|
73
|
+
where: 'Touch target (WCAG / Apple HIG)',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
'threshold',
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
// Threshold attachment is best-effort; failures should not abort validation.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem loader for cross-axis constraint rules.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3B (Filesystem Separation): This module handles reading cross-axis rules
|
|
5
|
+
* from JSON files and parsing them into in-memory data structures.
|
|
6
|
+
*
|
|
7
|
+
* Core modules (core/constraints/cross-axis.ts) accept pre-parsed rules,
|
|
8
|
+
* while CLI modules use this loader to read from filesystem.
|
|
9
|
+
*/
|
|
10
|
+
import type { CrossAxisRule } from '../core/constraints/cross-axis.js';
|
|
11
|
+
/**
|
|
12
|
+
* Raw rule format as stored in JSON files.
|
|
13
|
+
*/
|
|
14
|
+
export type RawCrossAxisRule = {
|
|
15
|
+
id: string;
|
|
16
|
+
level?: 'error' | 'warn';
|
|
17
|
+
where?: string;
|
|
18
|
+
bp?: string;
|
|
19
|
+
when?: {
|
|
20
|
+
id: string;
|
|
21
|
+
op: '<=' | '>=' | '<' | '>' | '==' | '!=';
|
|
22
|
+
value: number;
|
|
23
|
+
};
|
|
24
|
+
require?: {
|
|
25
|
+
id: string;
|
|
26
|
+
op: '<=' | '>=' | '<' | '>' | '==' | '!=';
|
|
27
|
+
ref?: string;
|
|
28
|
+
fallback?: string | number;
|
|
29
|
+
};
|
|
30
|
+
compare?: {
|
|
31
|
+
a: string;
|
|
32
|
+
op: '<=' | '>=' | '<' | '>' | '==' | '!=';
|
|
33
|
+
b: string;
|
|
34
|
+
delta?: string | number;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Result of loading and parsing cross-axis rules.
|
|
39
|
+
*/
|
|
40
|
+
export type LoadCrossAxisResult = {
|
|
41
|
+
rules: CrossAxisRule[];
|
|
42
|
+
unknownIds: Set<string>;
|
|
43
|
+
skipped: Array<{
|
|
44
|
+
id?: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Options for loading cross-axis rules.
|
|
50
|
+
*/
|
|
51
|
+
export type LoadCrossAxisOptions = {
|
|
52
|
+
/** Breakpoint to filter rules for */
|
|
53
|
+
bp?: string;
|
|
54
|
+
/** Set of known token IDs for validation */
|
|
55
|
+
knownIds?: Set<string>;
|
|
56
|
+
/** Enable debug logging */
|
|
57
|
+
debug?: boolean;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Load raw cross-axis rules from a JSON file.
|
|
61
|
+
*
|
|
62
|
+
* Returns undefined if file doesn't exist or can't be parsed.
|
|
63
|
+
*
|
|
64
|
+
* @param path Path to cross-axis rules JSON file
|
|
65
|
+
* @returns Parsed rules or undefined if file missing/invalid
|
|
66
|
+
*/
|
|
67
|
+
export declare function loadCrossAxisRulesFromFile(path: string): RawCrossAxisRule[] | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Parse raw cross-axis rules into executable constraint rules.
|
|
70
|
+
*
|
|
71
|
+
* This function performs:
|
|
72
|
+
* - Breakpoint filtering (only include rules matching the target breakpoint)
|
|
73
|
+
* - Token ID validation (track unknown IDs)
|
|
74
|
+
* - Rule compilation (convert JSON predicates into executable functions)
|
|
75
|
+
*
|
|
76
|
+
* @param rawRules Raw rules from JSON file
|
|
77
|
+
* @param opts Parsing options (breakpoint, knownIds, debug)
|
|
78
|
+
* @returns Parsed rules with validation info
|
|
79
|
+
*/
|
|
80
|
+
export declare function parseCrossAxisRules(rawRules: RawCrossAxisRule[], opts?: LoadCrossAxisOptions): LoadCrossAxisResult;
|
|
81
|
+
/**
|
|
82
|
+
* Load and parse cross-axis rules from a JSON file.
|
|
83
|
+
*
|
|
84
|
+
* This is the main entry point for CLI code that needs to load cross-axis rules.
|
|
85
|
+
*
|
|
86
|
+
* @param path Path to cross-axis rules JSON file
|
|
87
|
+
* @param opts Parsing options
|
|
88
|
+
* @returns Parsed rules (empty array if file doesn't exist)
|
|
89
|
+
*/
|
|
90
|
+
export declare function loadCrossAxisRules(path: string, opts?: LoadCrossAxisOptions): CrossAxisRule[];
|
|
91
|
+
//# sourceMappingURL=cross-axis-loader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cross-axis-loader.d.ts","sourceRoot":"","sources":["cross-axis-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEvE;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAC;QACX,EAAE,EAAE,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;QAC1C,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;IACF,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,EAAE,EAAE,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;QAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KAC5B,CAAC;IACF,OAAO,CAAC,EAAE;QACR,CAAC,EAAE,MAAM,CAAC;QACV,EAAE,EAAE,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;QAC1C,CAAC,EAAE,MAAM,CAAC;QACV,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KACzB,CAAC;CACH,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB,OAAO,EAAE,KAAK,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjD,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,qCAAqC;IACrC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAMF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,SAAS,CAYvF;AAwDD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,EAAE,IAAI,GAAE,oBAAyB,GAAG,mBAAmB,CA6GtH;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,aAAa,EAAE,CAejG"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem loader for cross-axis constraint rules.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3B (Filesystem Separation): This module handles reading cross-axis rules
|
|
5
|
+
* from JSON files and parsing them into in-memory data structures.
|
|
6
|
+
*
|
|
7
|
+
* Core modules (core/constraints/cross-axis.ts) accept pre-parsed rules,
|
|
8
|
+
* while CLI modules use this loader to read from filesystem.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Filesystem Loading
|
|
13
|
+
// ============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* Load raw cross-axis rules from a JSON file.
|
|
16
|
+
*
|
|
17
|
+
* Returns undefined if file doesn't exist or can't be parsed.
|
|
18
|
+
*
|
|
19
|
+
* @param path Path to cross-axis rules JSON file
|
|
20
|
+
* @returns Parsed rules or undefined if file missing/invalid
|
|
21
|
+
*/
|
|
22
|
+
export function loadCrossAxisRulesFromFile(path) {
|
|
23
|
+
if (!existsSync(path)) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
return data.rules || [];
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Return undefined on parse errors (consistent with silent failure behavior)
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Rule Parsing and Validation
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Helper functions (copied from core/cross-axis-config.ts)
|
|
39
|
+
const px = (v) => typeof v === 'number' ? v : parseFloat(String(v)) * (String(v).trim().endsWith('rem') ? 16 : 1);
|
|
40
|
+
const cmp = (a, b, op) => op === '>=' ? a >= b : op === '>' ? a > b : op === '<=' ? a <= b : op === '<' ? a < b : op === '==' ? a === b : a !== b;
|
|
41
|
+
const prettyFail = (op) => ({ '>=': '<', '>': '≤', '<=': '>', '<': '≥', '==': '≠', '!=': '=' }[op] || '≠');
|
|
42
|
+
const fmt = (v) => (Number.isFinite(Number(v)) ? `${Number(v)}px` : String(v));
|
|
43
|
+
function valueOrRef(ctx, ref, fallback) {
|
|
44
|
+
if (ref) {
|
|
45
|
+
const v = ctx.getPx(ref);
|
|
46
|
+
if (v != null)
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
return typeof fallback === 'number' ? fallback : px(fallback ?? 0);
|
|
50
|
+
}
|
|
51
|
+
function makeOp(op, rhs) {
|
|
52
|
+
return (v) => cmp(v, rhs, op);
|
|
53
|
+
}
|
|
54
|
+
// Lightweight Levenshtein distance for suggestions
|
|
55
|
+
function levenshtein(a, b) {
|
|
56
|
+
const dp = Array(b.length + 1)
|
|
57
|
+
.fill(0)
|
|
58
|
+
.map((_, j) => j);
|
|
59
|
+
for (let i = 1; i <= a.length; i++) {
|
|
60
|
+
let prev = i - 1, cur = i;
|
|
61
|
+
for (let j = 1; j <= b.length; j++) {
|
|
62
|
+
const tmp = cur;
|
|
63
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
64
|
+
cur = Math.min(dp[j] + 1, cur + 1, prev + cost);
|
|
65
|
+
dp[j] = tmp;
|
|
66
|
+
prev = tmp;
|
|
67
|
+
}
|
|
68
|
+
dp[b.length] = cur;
|
|
69
|
+
}
|
|
70
|
+
return dp[b.length];
|
|
71
|
+
}
|
|
72
|
+
function suggest(id, known, k = 3) {
|
|
73
|
+
return [...known]
|
|
74
|
+
.map((c) => ({ id: c, d: levenshtein(id, c) }))
|
|
75
|
+
.sort((a, b) => a.d - b.d)
|
|
76
|
+
.slice(0, k);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse raw cross-axis rules into executable constraint rules.
|
|
80
|
+
*
|
|
81
|
+
* This function performs:
|
|
82
|
+
* - Breakpoint filtering (only include rules matching the target breakpoint)
|
|
83
|
+
* - Token ID validation (track unknown IDs)
|
|
84
|
+
* - Rule compilation (convert JSON predicates into executable functions)
|
|
85
|
+
*
|
|
86
|
+
* @param rawRules Raw rules from JSON file
|
|
87
|
+
* @param opts Parsing options (breakpoint, knownIds, debug)
|
|
88
|
+
* @returns Parsed rules with validation info
|
|
89
|
+
*/
|
|
90
|
+
export function parseCrossAxisRules(rawRules, opts = {}) {
|
|
91
|
+
const { bp, knownIds = new Set(), debug = false } = opts;
|
|
92
|
+
const rules = [];
|
|
93
|
+
const unknownIds = new Set();
|
|
94
|
+
const skipped = [];
|
|
95
|
+
const log = (...args) => {
|
|
96
|
+
if (debug)
|
|
97
|
+
console.log('[cross-axis]', ...args);
|
|
98
|
+
};
|
|
99
|
+
const needId = (id) => {
|
|
100
|
+
if (!id)
|
|
101
|
+
return false;
|
|
102
|
+
if (!knownIds.has(id)) {
|
|
103
|
+
unknownIds.add(id);
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
};
|
|
107
|
+
for (const r of rawRules) {
|
|
108
|
+
// Filter by breakpoint
|
|
109
|
+
if (r.bp && bp && r.bp !== bp) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (r.bp && !bp) {
|
|
113
|
+
// Rule targets specific breakpoint; skip in global run
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (r.when && r.require) {
|
|
118
|
+
// Validate IDs
|
|
119
|
+
needId(r.when.id);
|
|
120
|
+
needId(r.require.id);
|
|
121
|
+
if (r.require.ref)
|
|
122
|
+
needId(r.require.ref);
|
|
123
|
+
rules.push({
|
|
124
|
+
id: r.id,
|
|
125
|
+
level: r.level,
|
|
126
|
+
where: r.where,
|
|
127
|
+
when: { id: r.when.id, test: makeOp(r.when.op, r.when.value) },
|
|
128
|
+
require: {
|
|
129
|
+
id: r.require.id,
|
|
130
|
+
test: (v, ctx) => {
|
|
131
|
+
const rhs = valueOrRef(ctx, r.require.ref, r.require.fallback);
|
|
132
|
+
return cmp(v, rhs, r.require.op);
|
|
133
|
+
},
|
|
134
|
+
msg: (v, ctx) => {
|
|
135
|
+
const rhs = valueOrRef(ctx, r.require.ref, r.require.fallback);
|
|
136
|
+
return `${r.require.id} ${prettyFail(r.require.op)} ${fmt(rhs)} (was ${fmt(v)})`;
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else if (r.compare) {
|
|
142
|
+
needId(r.compare.a);
|
|
143
|
+
needId(r.compare.b);
|
|
144
|
+
rules.push({
|
|
145
|
+
id: r.id,
|
|
146
|
+
level: r.level,
|
|
147
|
+
where: r.where,
|
|
148
|
+
when: { id: r.compare.a, test: () => true },
|
|
149
|
+
require: {
|
|
150
|
+
id: r.compare.a,
|
|
151
|
+
test: (_, ctx) => {
|
|
152
|
+
const a = ctx.getPx(r.compare.a) ?? NaN;
|
|
153
|
+
const b = ctx.getPx(r.compare.b) ?? NaN;
|
|
154
|
+
const delta = px(r.compare.delta ?? 0);
|
|
155
|
+
if (Number.isNaN(a) || Number.isNaN(b))
|
|
156
|
+
return true; // skip check if missing
|
|
157
|
+
return cmp(a, b + delta, r.compare.op);
|
|
158
|
+
},
|
|
159
|
+
msg: (_, ctx) => {
|
|
160
|
+
const a = ctx.getPx(r.compare.a);
|
|
161
|
+
const b = ctx.getPx(r.compare.b);
|
|
162
|
+
const delta = px(r.compare.delta ?? 0);
|
|
163
|
+
return `${r.compare.a} ${prettyFail(r.compare.op)} ${fmt((b ?? 0) + delta)} (was ${fmt(a ?? NaN)})`;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
skipped.push({ id: r.id, reason: 'neither when+require nor compare present' });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
skipped.push({ id: r.id, reason: `exception: ${e?.message ?? e}` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Debug logging
|
|
177
|
+
if (debug) {
|
|
178
|
+
log(`parsed ${rules.length} rule(s)${bp ? ` [bp=${bp}]` : ''}`);
|
|
179
|
+
if (unknownIds.size) {
|
|
180
|
+
log(`unknown ids referenced:`, [...unknownIds].join(', '));
|
|
181
|
+
for (const u of unknownIds) {
|
|
182
|
+
const s = suggest(u, knownIds, 3);
|
|
183
|
+
if (s.length)
|
|
184
|
+
log(` did you mean: ${s.map((x) => `${x.id} (d=${x.d})`).join(', ')}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (skipped.length) {
|
|
188
|
+
for (const s of skipped)
|
|
189
|
+
log(`skipped rule ${s.id ?? '(no id)'} — ${s.reason}`);
|
|
190
|
+
}
|
|
191
|
+
// Extra hint for common anchor pitfall
|
|
192
|
+
for (const r of rawRules) {
|
|
193
|
+
if (r.require?.ref && !knownIds.has(r.require.ref)) {
|
|
194
|
+
log(`anchor missing: ${r.require.ref} → will use fallback=${JSON.stringify(r.require.fallback)} when evaluating`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { rules, unknownIds, skipped };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Load and parse cross-axis rules from a JSON file.
|
|
202
|
+
*
|
|
203
|
+
* This is the main entry point for CLI code that needs to load cross-axis rules.
|
|
204
|
+
*
|
|
205
|
+
* @param path Path to cross-axis rules JSON file
|
|
206
|
+
* @param opts Parsing options
|
|
207
|
+
* @returns Parsed rules (empty array if file doesn't exist)
|
|
208
|
+
*/
|
|
209
|
+
export function loadCrossAxisRules(path, opts = {}) {
|
|
210
|
+
const { debug = false } = opts;
|
|
211
|
+
const log = (...args) => {
|
|
212
|
+
if (debug)
|
|
213
|
+
console.log('[cross-axis]', ...args);
|
|
214
|
+
};
|
|
215
|
+
const rawRules = loadCrossAxisRulesFromFile(path);
|
|
216
|
+
if (!rawRules) {
|
|
217
|
+
log(`no rules file at ${path} (bp=${opts.bp ?? 'global'})`);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const result = parseCrossAxisRules(rawRules, opts);
|
|
221
|
+
return result.rules;
|
|
222
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem loader for cross-axis constraint rules.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3B (Filesystem Separation): This module handles reading cross-axis rules
|
|
5
|
+
* from JSON files and parsing them into in-memory data structures.
|
|
6
|
+
*
|
|
7
|
+
* Core modules (core/constraints/cross-axis.ts) accept pre-parsed rules,
|
|
8
|
+
* while CLI modules use this loader to read from filesystem.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import type { CrossAxisRule } from '../core/constraints/cross-axis.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Raw rule format as stored in JSON files.
|
|
16
|
+
*/
|
|
17
|
+
export type RawCrossAxisRule = {
|
|
18
|
+
id: string;
|
|
19
|
+
level?: 'error' | 'warn';
|
|
20
|
+
where?: string;
|
|
21
|
+
bp?: string;
|
|
22
|
+
when?: {
|
|
23
|
+
id: string;
|
|
24
|
+
op: '<=' | '>=' | '<' | '>' | '==' | '!=';
|
|
25
|
+
value: number;
|
|
26
|
+
};
|
|
27
|
+
require?: {
|
|
28
|
+
id: string;
|
|
29
|
+
op: '<=' | '>=' | '<' | '>' | '==' | '!=';
|
|
30
|
+
ref?: string;
|
|
31
|
+
fallback?: string | number;
|
|
32
|
+
};
|
|
33
|
+
compare?: {
|
|
34
|
+
a: string;
|
|
35
|
+
op: '<=' | '>=' | '<' | '>' | '==' | '!=';
|
|
36
|
+
b: string;
|
|
37
|
+
delta?: string | number;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Result of loading and parsing cross-axis rules.
|
|
43
|
+
*/
|
|
44
|
+
export type LoadCrossAxisResult = {
|
|
45
|
+
rules: CrossAxisRule[];
|
|
46
|
+
unknownIds: Set<string>;
|
|
47
|
+
skipped: Array<{ id?: string; reason: string }>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options for loading cross-axis rules.
|
|
52
|
+
*/
|
|
53
|
+
export type LoadCrossAxisOptions = {
|
|
54
|
+
/** Breakpoint to filter rules for */
|
|
55
|
+
bp?: string;
|
|
56
|
+
/** Set of known token IDs for validation */
|
|
57
|
+
knownIds?: Set<string>;
|
|
58
|
+
/** Enable debug logging */
|
|
59
|
+
debug?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Filesystem Loading
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load raw cross-axis rules from a JSON file.
|
|
68
|
+
*
|
|
69
|
+
* Returns undefined if file doesn't exist or can't be parsed.
|
|
70
|
+
*
|
|
71
|
+
* @param path Path to cross-axis rules JSON file
|
|
72
|
+
* @returns Parsed rules or undefined if file missing/invalid
|
|
73
|
+
*/
|
|
74
|
+
export function loadCrossAxisRulesFromFile(path: string): RawCrossAxisRule[] | undefined {
|
|
75
|
+
if (!existsSync(path)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(readFileSync(path, 'utf8')) as { rules: RawCrossAxisRule[] };
|
|
81
|
+
return data.rules || [];
|
|
82
|
+
} catch {
|
|
83
|
+
// Return undefined on parse errors (consistent with silent failure behavior)
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Rule Parsing and Validation
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
// Helper functions (copied from core/cross-axis-config.ts)
|
|
93
|
+
const px = (v: string | number) =>
|
|
94
|
+
typeof v === 'number' ? v : parseFloat(String(v)) * (String(v).trim().endsWith('rem') ? 16 : 1);
|
|
95
|
+
|
|
96
|
+
const cmp = (a: number, b: number, op: '<=' | '>=' | '<' | '>' | '==' | '!=') =>
|
|
97
|
+
op === '>=' ? a >= b : op === '>' ? a > b : op === '<=' ? a <= b : op === '<' ? a < b : op === '==' ? a === b : a !== b;
|
|
98
|
+
|
|
99
|
+
const prettyFail = (op: string) => ({ '>=': '<', '>': '≤', '<=': '>', '<': '≥', '==': '≠', '!=': '=' } as any)[op] || '≠';
|
|
100
|
+
|
|
101
|
+
const fmt = (v: number | string) => (Number.isFinite(Number(v)) ? `${Number(v)}px` : String(v));
|
|
102
|
+
|
|
103
|
+
function valueOrRef(ctx: any, ref?: string, fallback?: string | number) {
|
|
104
|
+
if (ref) {
|
|
105
|
+
const v = ctx.getPx(ref);
|
|
106
|
+
if (v != null) return v;
|
|
107
|
+
}
|
|
108
|
+
return typeof fallback === 'number' ? fallback : px(fallback ?? 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makeOp(op: '<=' | '>=' | '<' | '>' | '==' | '!=', rhs: number) {
|
|
112
|
+
return (v: number) => cmp(v, rhs, op);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Lightweight Levenshtein distance for suggestions
|
|
116
|
+
function levenshtein(a: string, b: string) {
|
|
117
|
+
const dp = Array(b.length + 1)
|
|
118
|
+
.fill(0)
|
|
119
|
+
.map((_, j) => j);
|
|
120
|
+
for (let i = 1; i <= a.length; i++) {
|
|
121
|
+
let prev = i - 1,
|
|
122
|
+
cur = i;
|
|
123
|
+
for (let j = 1; j <= b.length; j++) {
|
|
124
|
+
const tmp = cur;
|
|
125
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
126
|
+
cur = Math.min(dp[j] + 1, cur + 1, prev + cost);
|
|
127
|
+
dp[j] = tmp;
|
|
128
|
+
prev = tmp;
|
|
129
|
+
}
|
|
130
|
+
dp[b.length] = cur;
|
|
131
|
+
}
|
|
132
|
+
return dp[b.length];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function suggest(id: string, known: Set<string>, k = 3) {
|
|
136
|
+
return [...known]
|
|
137
|
+
.map((c) => ({ id: c, d: levenshtein(id, c) }))
|
|
138
|
+
.sort((a, b) => a.d - b.d)
|
|
139
|
+
.slice(0, k);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse raw cross-axis rules into executable constraint rules.
|
|
144
|
+
*
|
|
145
|
+
* This function performs:
|
|
146
|
+
* - Breakpoint filtering (only include rules matching the target breakpoint)
|
|
147
|
+
* - Token ID validation (track unknown IDs)
|
|
148
|
+
* - Rule compilation (convert JSON predicates into executable functions)
|
|
149
|
+
*
|
|
150
|
+
* @param rawRules Raw rules from JSON file
|
|
151
|
+
* @param opts Parsing options (breakpoint, knownIds, debug)
|
|
152
|
+
* @returns Parsed rules with validation info
|
|
153
|
+
*/
|
|
154
|
+
export function parseCrossAxisRules(rawRules: RawCrossAxisRule[], opts: LoadCrossAxisOptions = {}): LoadCrossAxisResult {
|
|
155
|
+
const { bp, knownIds = new Set(), debug = false } = opts;
|
|
156
|
+
const rules: CrossAxisRule[] = [];
|
|
157
|
+
const unknownIds = new Set<string>();
|
|
158
|
+
const skipped: Array<{ id?: string; reason: string }> = [];
|
|
159
|
+
|
|
160
|
+
const log = (...args: any[]) => {
|
|
161
|
+
if (debug) console.log('[cross-axis]', ...args);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const needId = (id?: string) => {
|
|
165
|
+
if (!id) return false;
|
|
166
|
+
if (!knownIds.has(id)) {
|
|
167
|
+
unknownIds.add(id);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const r of rawRules) {
|
|
173
|
+
// Filter by breakpoint
|
|
174
|
+
if (r.bp && bp && r.bp !== bp) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (r.bp && !bp) {
|
|
178
|
+
// Rule targets specific breakpoint; skip in global run
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (r.when && r.require) {
|
|
184
|
+
// Validate IDs
|
|
185
|
+
needId(r.when.id);
|
|
186
|
+
needId(r.require.id);
|
|
187
|
+
if (r.require.ref) needId(r.require.ref);
|
|
188
|
+
|
|
189
|
+
rules.push({
|
|
190
|
+
id: r.id,
|
|
191
|
+
level: r.level,
|
|
192
|
+
where: r.where,
|
|
193
|
+
when: { id: r.when.id, test: makeOp(r.when.op, r.when.value) },
|
|
194
|
+
require: {
|
|
195
|
+
id: r.require.id,
|
|
196
|
+
test: (v: number, ctx: any) => {
|
|
197
|
+
const rhs = valueOrRef(ctx, r.require!.ref, r.require!.fallback);
|
|
198
|
+
return cmp(v, rhs, r.require!.op);
|
|
199
|
+
},
|
|
200
|
+
msg: (v: number, ctx: any) => {
|
|
201
|
+
const rhs = valueOrRef(ctx, r.require!.ref, r.require!.fallback);
|
|
202
|
+
return `${r.require!.id} ${prettyFail(r.require!.op)} ${fmt(rhs)} (was ${fmt(v)})`;
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
} else if (r.compare) {
|
|
207
|
+
needId(r.compare.a);
|
|
208
|
+
needId(r.compare.b);
|
|
209
|
+
|
|
210
|
+
rules.push({
|
|
211
|
+
id: r.id,
|
|
212
|
+
level: r.level,
|
|
213
|
+
where: r.where,
|
|
214
|
+
when: { id: r.compare.a, test: () => true },
|
|
215
|
+
require: {
|
|
216
|
+
id: r.compare.a,
|
|
217
|
+
test: (_: number, ctx: any) => {
|
|
218
|
+
const a = ctx.getPx(r.compare!.a) ?? NaN;
|
|
219
|
+
const b = ctx.getPx(r.compare!.b) ?? NaN;
|
|
220
|
+
const delta = px(r.compare!.delta ?? 0);
|
|
221
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return true; // skip check if missing
|
|
222
|
+
return cmp(a, b + delta, r.compare!.op);
|
|
223
|
+
},
|
|
224
|
+
msg: (_: number, ctx: any) => {
|
|
225
|
+
const a = ctx.getPx(r.compare!.a);
|
|
226
|
+
const b = ctx.getPx(r.compare!.b);
|
|
227
|
+
const delta = px(r.compare!.delta ?? 0);
|
|
228
|
+
return `${r.compare!.a} ${prettyFail(r.compare!.op)} ${fmt((b ?? 0) + delta)} (was ${fmt(a ?? NaN)})`;
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
skipped.push({ id: r.id, reason: 'neither when+require nor compare present' });
|
|
234
|
+
}
|
|
235
|
+
} catch (e: any) {
|
|
236
|
+
skipped.push({ id: r.id, reason: `exception: ${e?.message ?? e}` });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Debug logging
|
|
241
|
+
if (debug) {
|
|
242
|
+
log(`parsed ${rules.length} rule(s)${bp ? ` [bp=${bp}]` : ''}`);
|
|
243
|
+
if (unknownIds.size) {
|
|
244
|
+
log(`unknown ids referenced:`, [...unknownIds].join(', '));
|
|
245
|
+
for (const u of unknownIds) {
|
|
246
|
+
const s = suggest(u, knownIds, 3);
|
|
247
|
+
if (s.length) log(` did you mean: ${s.map((x) => `${x.id} (d=${x.d})`).join(', ')}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (skipped.length) {
|
|
251
|
+
for (const s of skipped) log(`skipped rule ${s.id ?? '(no id)'} — ${s.reason}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Extra hint for common anchor pitfall
|
|
255
|
+
for (const r of rawRules) {
|
|
256
|
+
if (r.require?.ref && !knownIds.has(r.require.ref)) {
|
|
257
|
+
log(`anchor missing: ${r.require.ref} → will use fallback=${JSON.stringify(r.require.fallback)} when evaluating`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { rules, unknownIds, skipped };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Load and parse cross-axis rules from a JSON file.
|
|
267
|
+
*
|
|
268
|
+
* This is the main entry point for CLI code that needs to load cross-axis rules.
|
|
269
|
+
*
|
|
270
|
+
* @param path Path to cross-axis rules JSON file
|
|
271
|
+
* @param opts Parsing options
|
|
272
|
+
* @returns Parsed rules (empty array if file doesn't exist)
|
|
273
|
+
*/
|
|
274
|
+
export function loadCrossAxisRules(path: string, opts: LoadCrossAxisOptions = {}): CrossAxisRule[] {
|
|
275
|
+
const { debug = false } = opts;
|
|
276
|
+
const log = (...args: any[]) => {
|
|
277
|
+
if (debug) console.log('[cross-axis]', ...args);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const rawRules = loadCrossAxisRulesFromFile(path);
|
|
281
|
+
|
|
282
|
+
if (!rawRules) {
|
|
283
|
+
log(`no rules file at ${path} (bp=${opts.bp ?? 'global'})`);
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const result = parseCrossAxisRules(rawRules, opts);
|
|
288
|
+
return result.rules;
|
|
289
|
+
}
|