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.
Files changed (116) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -659
  3. package/adapters/README.md +46 -46
  4. package/adapters/css.ts +116 -116
  5. package/adapters/js.ts +14 -14
  6. package/adapters/json.ts +45 -45
  7. package/cli/build-css.ts +32 -32
  8. package/cli/commands/build.ts +65 -65
  9. package/cli/commands/graph.d.ts.map +1 -1
  10. package/cli/commands/graph.js +26 -10
  11. package/cli/commands/graph.ts +180 -137
  12. package/cli/commands/index.ts +7 -7
  13. package/cli/commands/patch-apply.ts +80 -80
  14. package/cli/commands/patch.ts +22 -22
  15. package/cli/commands/set.d.ts.map +1 -1
  16. package/cli/commands/set.js +12 -4
  17. package/cli/commands/set.ts +239 -225
  18. package/cli/commands/utils.ts +50 -50
  19. package/cli/commands/validate.d.ts.map +1 -1
  20. package/cli/commands/validate.js +86 -33
  21. package/cli/commands/validate.ts +176 -115
  22. package/cli/commands/why.d.ts.map +1 -1
  23. package/cli/commands/why.js +86 -20
  24. package/cli/commands/why.ts +158 -46
  25. package/cli/config-schema.ts +27 -27
  26. package/cli/config.ts +35 -35
  27. package/cli/constraint-registry.d.ts +101 -0
  28. package/cli/constraint-registry.d.ts.map +1 -0
  29. package/cli/constraint-registry.js +225 -0
  30. package/cli/constraint-registry.ts +304 -0
  31. package/cli/constraints-loader.d.ts +30 -0
  32. package/cli/constraints-loader.d.ts.map +1 -0
  33. package/cli/constraints-loader.js +58 -0
  34. package/cli/constraints-loader.ts +83 -0
  35. package/cli/cross-axis-loader.d.ts +91 -0
  36. package/cli/cross-axis-loader.d.ts.map +1 -0
  37. package/cli/cross-axis-loader.js +222 -0
  38. package/cli/cross-axis-loader.ts +289 -0
  39. package/cli/dcv.js +4 -0
  40. package/cli/dcv.ts +111 -107
  41. package/cli/engine-helpers.d.ts +33 -0
  42. package/cli/engine-helpers.d.ts.map +1 -1
  43. package/cli/engine-helpers.js +87 -22
  44. package/cli/engine-helpers.ts +133 -61
  45. package/cli/graph-poset.ts +74 -74
  46. package/cli/json-output.d.ts +64 -0
  47. package/cli/json-output.d.ts.map +1 -0
  48. package/cli/json-output.js +107 -0
  49. package/cli/json-output.ts +177 -0
  50. package/cli/result.ts +27 -27
  51. package/cli/run.ts +54 -54
  52. package/cli/smoke-test.ts +40 -40
  53. package/cli/types.d.ts +6 -0
  54. package/cli/types.d.ts.map +1 -1
  55. package/cli/types.ts +84 -78
  56. package/core/breakpoints.ts +50 -50
  57. package/core/cli-format.ts +31 -31
  58. package/core/color.ts +148 -148
  59. package/core/constraints/cross-axis.ts +114 -114
  60. package/core/constraints/monotonic-lightness.ts +38 -38
  61. package/core/constraints/monotonic.ts +74 -74
  62. package/core/constraints/threshold.ts +43 -43
  63. package/core/constraints/wcag.ts +70 -70
  64. package/core/cross-axis-config.d.ts +29 -0
  65. package/core/cross-axis-config.d.ts.map +1 -1
  66. package/core/cross-axis-config.js +29 -0
  67. package/core/cross-axis-config.ts +181 -151
  68. package/core/engine.d.ts +95 -0
  69. package/core/engine.d.ts.map +1 -1
  70. package/core/engine.js +22 -0
  71. package/core/engine.ts +167 -65
  72. package/core/flatten.ts +116 -116
  73. package/core/image-export.ts +48 -48
  74. package/core/index.d.ts +9 -30
  75. package/core/index.d.ts.map +1 -1
  76. package/core/index.js +7 -54
  77. package/core/index.ts +10 -72
  78. package/core/patch.ts +134 -134
  79. package/core/poset.ts +311 -311
  80. package/core/why.ts +63 -63
  81. package/package.json +96 -90
  82. package/themes/color.lg.order.json +15 -15
  83. package/themes/color.md.order.json +15 -15
  84. package/themes/color.order.json +15 -15
  85. package/themes/color.sm.order.json +15 -15
  86. package/themes/cross-axis.rules.json +35 -35
  87. package/themes/cross-axis.sm.rules.json +12 -12
  88. package/themes/layout.lg.order.json +18 -18
  89. package/themes/layout.md.order.json +18 -18
  90. package/themes/layout.order.json +18 -18
  91. package/themes/layout.sm.order.json +18 -18
  92. package/themes/spacing.order.json +14 -14
  93. package/themes/typography.lg.order.json +15 -15
  94. package/themes/typography.md.order.json +15 -15
  95. package/themes/typography.order.json +15 -15
  96. package/themes/typography.sm.order.json +15 -15
  97. package/dist/test-overrides-removal.json +0 -4
  98. package/dist/tmp.patch.json +0 -35
  99. package/tokens/overrides/base.json +0 -22
  100. package/tokens/overrides/lg.json +0 -20
  101. package/tokens/overrides/md.json +0 -16
  102. package/tokens/overrides/sm.json +0 -16
  103. package/tokens/overrides/viol.color.json +0 -6
  104. package/tokens/overrides/viol.typography.json +0 -6
  105. package/tokens/tokens.demo-violations.json +0 -116
  106. package/tokens/tokens.example.json +0 -128
  107. package/tokens/tokens.json +0 -67
  108. package/tokens/tokens.multi-violations.json +0 -21
  109. package/tokens/tokens.schema.d.ts +0 -2298
  110. package/tokens/tokens.schema.d.ts.map +0 -1
  111. package/tokens/tokens.schema.js +0 -148
  112. package/tokens/tokens.schema.ts +0 -196
  113. package/tokens/tokens.test.json +0 -38
  114. package/tokens/tokens.touch-violation.json +0 -8
  115. package/tokens/typography.classes.css +0 -11
  116. package/tokens/typography.css +0 -20
@@ -1,50 +1,50 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
- import { resolve, dirname } from 'node:path';
3
- import { valuesToCss } from '../../adapters/css.js';
4
- import type { TokenNode, TokenValue } from '../../core/flatten.js';
5
-
6
- // Shared helpers for command modules
7
-
8
- export function loadTokens(tokensPath: string): TokenNode {
9
- const resolvedPath = resolve(tokensPath);
10
- if (!existsSync(resolvedPath)) {
11
- throw new Error(`Token file not found: ${resolvedPath}`);
12
- }
13
- const data = JSON.parse(readFileSync(resolvedPath, 'utf8'));
14
- if (typeof data !== 'object' || data === null) {
15
- throw new Error(`Invalid token file: expected object, got ${typeof data}`);
16
- }
17
- return data as TokenNode;
18
- }
19
-
20
- export function outputResult(data: unknown, format: string, outputPath?: string): void {
21
- let content: string;
22
- switch (format) {
23
- case 'json':
24
- content = JSON.stringify(data, null, 2);
25
- break;
26
- case 'css': {
27
- if (data && typeof data === 'object' && 'patch' in (data as { patch?: unknown })) {
28
- const patch = (data as { patch: Record<string, TokenValue> }).patch;
29
- content = valuesToCss(patch);
30
- } else if (data && typeof data === 'object') {
31
- content = valuesToCss(data as Record<string, TokenValue>);
32
- } else {
33
- content = valuesToCss({});
34
- }
35
- break; }
36
- case 'js':
37
- content = `export default ${JSON.stringify(data, null, 2)};`;
38
- break;
39
- default:
40
- content = JSON.stringify(data, null, 2);
41
- }
42
- if (outputPath) {
43
- const dir = dirname(outputPath);
44
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
45
- writeFileSync(outputPath, content, 'utf8');
46
- console.log(`Output written to: ${outputPath}`);
47
- } else {
48
- console.log(content);
49
- }
50
- }
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { resolve, dirname } from 'node:path';
3
+ import { valuesToCss } from '../../adapters/css.js';
4
+ import type { TokenNode, TokenValue } from '../../core/flatten.js';
5
+
6
+ // Shared helpers for command modules
7
+
8
+ export function loadTokens(tokensPath: string): TokenNode {
9
+ const resolvedPath = resolve(tokensPath);
10
+ if (!existsSync(resolvedPath)) {
11
+ throw new Error(`Token file not found: ${resolvedPath}`);
12
+ }
13
+ const data = JSON.parse(readFileSync(resolvedPath, 'utf8'));
14
+ if (typeof data !== 'object' || data === null) {
15
+ throw new Error(`Invalid token file: expected object, got ${typeof data}`);
16
+ }
17
+ return data as TokenNode;
18
+ }
19
+
20
+ export function outputResult(data: unknown, format: string, outputPath?: string): void {
21
+ let content: string;
22
+ switch (format) {
23
+ case 'json':
24
+ content = JSON.stringify(data, null, 2);
25
+ break;
26
+ case 'css': {
27
+ if (data && typeof data === 'object' && 'patch' in (data as { patch?: unknown })) {
28
+ const patch = (data as { patch: Record<string, TokenValue> }).patch;
29
+ content = valuesToCss(patch);
30
+ } else if (data && typeof data === 'object') {
31
+ content = valuesToCss(data as Record<string, TokenValue>);
32
+ } else {
33
+ content = valuesToCss({});
34
+ }
35
+ break; }
36
+ case 'js':
37
+ content = `export default ${JSON.stringify(data, null, 2)};`;
38
+ break;
39
+ default:
40
+ content = JSON.stringify(data, null, 2);
41
+ }
42
+ if (outputPath) {
43
+ const dir = dirname(outputPath);
44
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
45
+ writeFileSync(outputPath, content, 'utf8');
46
+ console.log(`Output written to: ${outputPath}`);
47
+ } else {
48
+ console.log(content);
49
+ }
50
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["validate.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA0G9E"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["validate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAMnD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAoK9E"}
@@ -1,8 +1,11 @@
1
1
  import { flattenTokens } from '../../core/flatten.js';
2
- import { createValidationEngine } from '../engine-helpers.js';
2
+ import { Engine } from '../../core/engine.js';
3
3
  import { loadConfig } from '../config.js';
4
- import { parseBreakpoints, loadTokensWithBreakpoint } from '../../core/breakpoints.js';
5
- import { loadCrossAxisPlugin } from '../../core/cross-axis-config.js';
4
+ import { parseBreakpoints, loadTokensWithBreakpoint, mergeTokens } from '../../core/breakpoints.js';
5
+ import { createValidationResult, createValidationReceipt, writeJsonOutput } from '../json-output.js';
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { setupConstraints } from '../constraint-registry.js';
6
9
  export async function validateCommand(_options) {
7
10
  try {
8
11
  const bps = parseBreakpoints(process.argv);
@@ -13,9 +16,13 @@ export async function validateCommand(_options) {
13
16
  let totalWarnings = 0;
14
17
  const argv = process.argv.slice(2);
15
18
  const failOnIdx = argv.indexOf('--fail-on');
16
- const failOn = failOnIdx >= 0 ? argv[failOnIdx + 1] : 'error';
19
+ const failOn = _options.failOn ?? (failOnIdx >= 0 ? argv[failOnIdx + 1] : 'error');
17
20
  const sumIdx = argv.indexOf('--summary');
18
- const summaryFmt = sumIdx >= 0 ? argv[sumIdx + 1] : 'none';
21
+ const summaryFmt = _options.summary ?? (sumIdx >= 0 ? argv[sumIdx + 1] : 'none');
22
+ const outputFormat = _options.format ?? 'text';
23
+ // Collect all issues for JSON output
24
+ const allErrors = [];
25
+ const allWarnings = [];
19
26
  const rows = [];
20
27
  function pushRow(bpLabel, stats) { rows.push({ bp: bpLabel, ...stats }); }
21
28
  function printSummaryTable(rs) {
@@ -45,19 +52,31 @@ export async function validateCommand(_options) {
45
52
  const tStartTotal = globalThis.performance.now();
46
53
  for (const bp of plan) {
47
54
  const tStart = globalThis.performance.now();
48
- const tokens = loadTokensWithBreakpoint(bp);
49
- const engine = createValidationEngine(tokens, bp, config);
50
- const initIds = Object.keys(flattenTokens(tokens).flat).reduce((a, k) => { a[k] = true; return a; }, {});
51
- const knownIds = new Set(Object.keys(initIds));
52
- // Load global + bp-specific cross-axis rules (bp file optional)
53
- engine.use(loadCrossAxisPlugin('themes/cross-axis.rules.json', bp, { debug: crossAxisDebug, knownIds }));
54
- if (bp) {
55
- const bpRulesPath = `themes/cross-axis.${bp}.rules.json`;
56
- engine.use(loadCrossAxisPlugin(bpRulesPath, bp, { debug: crossAxisDebug, knownIds }));
55
+ let tokens = loadTokensWithBreakpoint(bp);
56
+ // Optional theme overlay (tokens/themes/<name>.json), mirroring build behavior
57
+ if (_options.theme) {
58
+ const themePath = join('tokens/themes', `${_options.theme}.json`);
59
+ if (existsSync(themePath)) {
60
+ try {
61
+ const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
62
+ tokens = mergeTokens(tokens, themeTokens);
63
+ }
64
+ catch {
65
+ // If theme file is invalid JSON, ignore and proceed with base tokens
66
+ }
67
+ }
68
+ }
69
+ // Create engine with flattened tokens
70
+ const { flat, edges } = flattenTokens(tokens);
71
+ const init = {};
72
+ for (const t of Object.values(flat)) {
73
+ init[t.id] = t.value;
57
74
  }
58
- const { ThresholdPlugin } = await import('../../core/constraints/threshold.js');
59
- engine.use(ThresholdPlugin([{ id: 'control.size.min', op: '>=', valuePx: 44, where: 'Touch target (WCAG / Apple HIG)' }]));
60
- const allIds = new Set(Object.keys(initIds));
75
+ const engine = new Engine(init, edges);
76
+ const knownIds = new Set(Object.keys(init));
77
+ // Discover and attach all constraints via centralized registry
78
+ setupConstraints(engine, { config, bp, constraintsDir: 'themes' }, { knownIds, crossAxisDebug });
79
+ const allIds = new Set(Object.keys(init));
61
80
  const issues = engine.evaluate(allIds);
62
81
  const errs = issues.filter((i) => i.level === 'error');
63
82
  const warns = issues.filter((i) => i.level !== 'error');
@@ -65,14 +84,20 @@ export async function validateCommand(_options) {
65
84
  anyErrors = true;
66
85
  totalErrors += errs.length;
67
86
  totalWarnings += warns.length;
87
+ // Collect for JSON output
88
+ allErrors.push(...errs);
89
+ allWarnings.push(...warns);
68
90
  const rulesEvaluated = errs.length + warns.length;
69
91
  pushRow(bp ?? 'global', { rules: rulesEvaluated, warnings: warns.length, errors: errs.length });
70
92
  const dur = globalThis.performance.now() - tStart;
71
93
  perBpTimings.push({ bp: bp ?? 'global', ms: dur });
72
- console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
73
- for (const it of issues) {
74
- const tag = it.level === 'error' ? 'ERROR' : 'WARN ';
75
- console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`);
94
+ // Only print text output if not in JSON mode
95
+ if (outputFormat !== 'json') {
96
+ console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
97
+ for (const it of issues) {
98
+ const tag = it.level === 'error' ? 'ERROR' : 'WARN ';
99
+ console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`);
100
+ }
76
101
  }
77
102
  }
78
103
  const totalMs = globalThis.performance.now() - tStartTotal;
@@ -81,20 +106,48 @@ export async function validateCommand(_options) {
81
106
  const agg = rows.reduce((a, b) => ({ rules: a.rules + b.rules, warnings: a.warnings + b.warnings, errors: a.errors + b.errors }), { rules: 0, warnings: 0, errors: 0 });
82
107
  rows.push({ bp: 'TOTAL', ...agg });
83
108
  }
84
- if (_options.perf) {
85
- console.log('[perf] per-breakpoint timings:');
86
- for (const t of perBpTimings)
87
- console.log(` ${t.bp}: ${t.ms.toFixed(2)}ms`);
88
- console.log(`[perf] total: ${totalMs.toFixed(2)}ms`);
109
+ // Get package version for stats
110
+ let engineVersion = '1.0.0';
111
+ try {
112
+ // eslint-disable-next-line no-undef
113
+ const pkgPath = new URL('../../package.json', import.meta.url);
114
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
115
+ engineVersion = pkg.version;
89
116
  }
90
- if (summaryFmt === 'json') {
91
- // Provide machine-readable aggregate separate from rows if TOTAL present
92
- const totalRow = rows.find(r => r.bp === 'TOTAL');
93
- const json = totalRow ? { rows, total: { rules: totalRow.rules, warnings: totalRow.warnings, errors: totalRow.errors } } : { rows };
94
- console.log(JSON.stringify(json, null, 2));
117
+ catch {
118
+ // Ignore package.json read errors, use default version
95
119
  }
96
- else if (summaryFmt === 'table') {
97
- printSummaryTable(rows);
120
+ // Handle JSON output mode
121
+ if (outputFormat === 'json') {
122
+ const result = createValidationResult(allErrors, allWarnings, totalMs, engineVersion);
123
+ // If receipt requested, generate full receipt
124
+ if (_options.receipt) {
125
+ const tokensFile = _options.tokens ?? 'tokens/tokens.example.json';
126
+ const constraintsDir = 'themes';
127
+ const receipt = createValidationReceipt(result, tokensFile, constraintsDir, bps[0], failOn);
128
+ writeJsonOutput(receipt, _options.receipt);
129
+ }
130
+ else {
131
+ writeJsonOutput(result, _options.output);
132
+ }
133
+ }
134
+ else {
135
+ // Text output mode
136
+ if (_options.perf) {
137
+ console.log('[perf] per-breakpoint timings:');
138
+ for (const t of perBpTimings)
139
+ console.log(` ${t.bp}: ${t.ms.toFixed(2)}ms`);
140
+ console.log(`[perf] total: ${totalMs.toFixed(2)}ms`);
141
+ }
142
+ if (summaryFmt === 'json') {
143
+ // Provide machine-readable aggregate separate from rows if TOTAL present
144
+ const totalRow = rows.find(r => r.bp === 'TOTAL');
145
+ const json = totalRow ? { rows, total: { rules: totalRow.rules, warnings: totalRow.warnings, errors: totalRow.errors } } : { rows };
146
+ console.log(JSON.stringify(json, null, 2));
147
+ }
148
+ else if (summaryFmt === 'table') {
149
+ printSummaryTable(rows);
150
+ }
98
151
  }
99
152
  let code = anyErrors ? 1 : 0;
100
153
  // Budget checks (do not override fail-on semantics unless budgets add failures)
@@ -1,115 +1,176 @@
1
- import { flattenTokens } from '../../core/flatten.js';
2
- import { createValidationEngine } from '../engine-helpers.js';
3
- import { loadConfig } from '../config.js';
4
- import { parseBreakpoints, loadTokensWithBreakpoint, type Breakpoint } from '../../core/breakpoints.js';
5
- import { loadCrossAxisPlugin } from '../../core/cross-axis-config.js';
6
- import type { ConstraintIssue } from '../../core/engine.js';
7
- import type { ValidateOptions } from '../types.js';
8
-
9
- export async function validateCommand(_options: ValidateOptions): Promise<void> {
10
- try {
11
- const bps = parseBreakpoints(process.argv);
12
- const crossAxisDebug = process.argv.includes('--cross-axis-debug');
13
- const plan: (Breakpoint | undefined)[] = bps.length ? bps : [undefined];
14
- let anyErrors = false; let totalErrors = 0; let totalWarnings = 0;
15
- const argv = process.argv.slice(2);
16
- const failOnIdx = argv.indexOf('--fail-on');
17
- type FailOn = 'off' | 'warn' | 'error';
18
- const failOn: FailOn = failOnIdx >= 0 ? (argv[failOnIdx + 1] as FailOn) : 'error';
19
- const sumIdx = argv.indexOf('--summary');
20
- type SummaryFmt = 'table' | 'json' | 'none';
21
- const summaryFmt: SummaryFmt = sumIdx >= 0 ? (argv[sumIdx + 1] as SummaryFmt) : 'none';
22
- type VRow = { bp: string; rules: number; warnings: number; errors: number };
23
- const rows: VRow[] = [];
24
- function pushRow(bpLabel: string, stats: { rules: number; warnings: number; errors: number }) { rows.push({ bp: bpLabel, ...stats }); }
25
- function printSummaryTable(rs: VRow[]) {
26
- if (!rs.length) return;
27
- const showTotalLine = !rs.some(r => r.bp === 'TOTAL');
28
- const cols = ['scope','rules','warnings','errors'] as const;
29
- const data = rs.map(r => ({ scope: r.bp, rules: String(r.rules), warnings: String(r.warnings), errors: String(r.errors) }));
30
- const widths = cols.map(c => Math.max(c.length, ...data.map(d => d[c].length)));
31
- const line = (vals: string[]) => vals.map((v,i)=>v.padEnd(widths[i])).join(' ');
32
- console.log(line(cols as unknown as string[]));
33
- console.log(line(widths.map(w => '-'.repeat(w))));
34
- for (const d of data) console.log(line(cols.map(c => d[c])));
35
- if (showTotalLine && rs.length > 1) {
36
- const tot = rs.reduce((a,b)=>({ rules:a.rules+b.rules, warnings:a.warnings+b.warnings, errors:a.errors+b.errors }), { rules:0,warnings:0,errors:0 });
37
- console.log(line(['TOTAL', String(tot.rules), String(tot.warnings), String(tot.errors)]));
38
- }
39
- }
40
- const cfgRes = loadConfig(_options.config);
41
- if (!cfgRes.ok) { console.error(cfgRes.error); process.exit(2); }
42
- const config = cfgRes.value;
43
- const perBpTimings: Array<{ bp: string; ms: number }> = [];
44
- const tStartTotal = globalThis.performance.now();
45
- for (const bp of plan) {
46
- const tStart = globalThis.performance.now();
47
- const tokens = loadTokensWithBreakpoint(bp);
48
- const engine = createValidationEngine(tokens, bp, config);
49
- const initIds = Object.keys(flattenTokens(tokens).flat).reduce<Record<string, true>>((a,k)=>{a[k]=true;return a;},{});
50
- const knownIds = new Set(Object.keys(initIds));
51
- // Load global + bp-specific cross-axis rules (bp file optional)
52
- engine.use(loadCrossAxisPlugin('themes/cross-axis.rules.json', bp, { debug: crossAxisDebug, knownIds }));
53
- if (bp) {
54
- const bpRulesPath = `themes/cross-axis.${bp}.rules.json`;
55
- engine.use(loadCrossAxisPlugin(bpRulesPath, bp, { debug: crossAxisDebug, knownIds }));
56
- }
57
- const { ThresholdPlugin } = await import('../../core/constraints/threshold.js');
58
- engine.use(ThresholdPlugin([{ id: 'control.size.min', op: '>=', valuePx: 44, where: 'Touch target (WCAG / Apple HIG)' }]));
59
- const allIds = new Set(Object.keys(initIds));
60
- const issues = engine.evaluate(allIds);
61
- const errs = issues.filter((i: ConstraintIssue) => i.level === 'error');
62
- const warns = issues.filter((i: ConstraintIssue) => i.level !== 'error');
63
- if (errs.length) anyErrors = true;
64
- totalErrors += errs.length; totalWarnings += warns.length;
65
- const rulesEvaluated = errs.length + warns.length; pushRow(bp ?? 'global', { rules: rulesEvaluated, warnings: warns.length, errors: errs.length });
66
- const dur = globalThis.performance.now() - tStart;
67
- perBpTimings.push({ bp: bp ?? 'global', ms: dur });
68
- console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
69
- for (const it of issues) { const tag = it.level === 'error' ? 'ERROR' : 'WARN '; console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`); }
70
- }
71
- const totalMs = globalThis.performance.now() - tStartTotal;
72
- // Append aggregate total row if multiple scopes and not already added
73
- if (rows.length > 1) {
74
- const agg = rows.reduce((a,b)=>({ rules:a.rules+b.rules, warnings:a.warnings+b.warnings, errors:a.errors+b.errors }), { rules:0,warnings:0,errors:0 });
75
- rows.push({ bp: 'TOTAL', ...agg });
76
- }
77
- if (_options.perf) {
78
- console.log('[perf] per-breakpoint timings:');
79
- for (const t of perBpTimings) console.log(` ${t.bp}: ${t.ms.toFixed(2)}ms`);
80
- console.log(`[perf] total: ${totalMs.toFixed(2)}ms`);
81
- }
82
- if (summaryFmt === 'json') {
83
- // Provide machine-readable aggregate separate from rows if TOTAL present
84
- const totalRow = rows.find(r => r.bp === 'TOTAL');
85
- const json = totalRow ? { rows, total: { rules: totalRow.rules, warnings: totalRow.warnings, errors: totalRow.errors } } : { rows };
86
- console.log(JSON.stringify(json, null, 2));
87
- } else if (summaryFmt === 'table') {
88
- printSummaryTable(rows);
89
- }
90
- let code = anyErrors ? 1 : 0;
91
- // Budget checks (do not override fail-on semantics unless budgets add failures)
92
- const budgetTotal = (_options as any)['budget-total-ms'] ?? _options.budgetTotalMs;
93
- const budgetPerBp = (_options as any)['budget-per-bp-ms'] ?? _options.budgetPerBpMs;
94
- let budgetFailed = false;
95
- if (budgetTotal != null && totalMs > budgetTotal) {
96
- console.error(`[perf] total time ${totalMs.toFixed(2)}ms exceeded budget ${budgetTotal}ms`);
97
- budgetFailed = true;
98
- }
99
- if (budgetPerBp != null) {
100
- for (const t of perBpTimings) {
101
- if (t.ms > budgetPerBp) {
102
- console.error(`[perf] ${t.bp} time ${t.ms.toFixed(2)}ms exceeded per-breakpoint budget ${budgetPerBp}ms`);
103
- budgetFailed = true;
104
- }
105
- }
106
- }
107
- if (failOn === 'off') code = 0; else if (failOn === 'warn') code = (totalErrors + totalWarnings) > 0 ? 1 : 0; else code = totalErrors > 0 ? 1 : 0;
108
- if (budgetFailed) code = Math.max(code, 1);
109
- process.exit(code);
110
- } catch (e) {
111
- const msg = e instanceof Error ? e.message : String(e);
112
- console.error('validate: failed:', msg);
113
- process.exit(2);
114
- }
115
- }
1
+ import { flattenTokens, type TokenNode, type FlatToken } from '../../core/flatten.js';
2
+ import { Engine } from '../../core/engine.js';
3
+ import { loadConfig } from '../config.js';
4
+ import { parseBreakpoints, loadTokensWithBreakpoint, mergeTokens, type Breakpoint } from '../../core/breakpoints.js';
5
+ import type { ConstraintIssue } from '../../core/engine.js';
6
+ import type { ValidateOptions } from '../types.js';
7
+ import { createValidationResult, createValidationReceipt, writeJsonOutput } from '../json-output.js';
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { setupConstraints } from '../constraint-registry.js';
11
+
12
+ export async function validateCommand(_options: ValidateOptions): Promise<void> {
13
+ try {
14
+ const bps = parseBreakpoints(process.argv);
15
+ const crossAxisDebug = process.argv.includes('--cross-axis-debug');
16
+ const plan: (Breakpoint | undefined)[] = bps.length ? bps : [undefined];
17
+ let anyErrors = false; let totalErrors = 0; let totalWarnings = 0;
18
+ const argv = process.argv.slice(2);
19
+ const failOnIdx = argv.indexOf('--fail-on');
20
+ type FailOn = 'off' | 'warn' | 'error';
21
+ const failOn: FailOn = _options.failOn ?? (failOnIdx >= 0 ? (argv[failOnIdx + 1] as FailOn) : 'error');
22
+ const sumIdx = argv.indexOf('--summary');
23
+ type SummaryFmt = 'table' | 'json' | 'none';
24
+ const summaryFmt: SummaryFmt = _options.summary ?? (sumIdx >= 0 ? (argv[sumIdx + 1] as SummaryFmt) : 'none');
25
+ const outputFormat = _options.format ?? 'text';
26
+
27
+ // Collect all issues for JSON output
28
+ const allErrors: ConstraintIssue[] = [];
29
+ const allWarnings: ConstraintIssue[] = [];
30
+ type VRow = { bp: string; rules: number; warnings: number; errors: number };
31
+ const rows: VRow[] = [];
32
+ function pushRow(bpLabel: string, stats: { rules: number; warnings: number; errors: number }) { rows.push({ bp: bpLabel, ...stats }); }
33
+ function printSummaryTable(rs: VRow[]) {
34
+ if (!rs.length) return;
35
+ const showTotalLine = !rs.some(r => r.bp === 'TOTAL');
36
+ const cols = ['scope','rules','warnings','errors'] as const;
37
+ const data = rs.map(r => ({ scope: r.bp, rules: String(r.rules), warnings: String(r.warnings), errors: String(r.errors) }));
38
+ const widths = cols.map(c => Math.max(c.length, ...data.map(d => d[c].length)));
39
+ const line = (vals: string[]) => vals.map((v,i)=>v.padEnd(widths[i])).join(' ');
40
+ console.log(line(cols as unknown as string[]));
41
+ console.log(line(widths.map(w => '-'.repeat(w))));
42
+ for (const d of data) console.log(line(cols.map(c => d[c])));
43
+ if (showTotalLine && rs.length > 1) {
44
+ const tot = rs.reduce((a,b)=>({ rules:a.rules+b.rules, warnings:a.warnings+b.warnings, errors:a.errors+b.errors }), { rules:0,warnings:0,errors:0 });
45
+ console.log(line(['TOTAL', String(tot.rules), String(tot.warnings), String(tot.errors)]));
46
+ }
47
+ }
48
+ const cfgRes = loadConfig(_options.config);
49
+ if (!cfgRes.ok) { console.error(cfgRes.error); process.exit(2); }
50
+ const config = cfgRes.value;
51
+ const perBpTimings: Array<{ bp: string; ms: number }> = [];
52
+ const tStartTotal = globalThis.performance.now();
53
+ for (const bp of plan) {
54
+ const tStart = globalThis.performance.now();
55
+ let tokens: TokenNode = loadTokensWithBreakpoint(bp);
56
+ // Optional theme overlay (tokens/themes/<name>.json), mirroring build behavior
57
+ if (_options.theme) {
58
+ const themePath = join('tokens/themes', `${_options.theme}.json`);
59
+ if (existsSync(themePath)) {
60
+ try {
61
+ const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
62
+ tokens = mergeTokens(tokens, themeTokens);
63
+ } catch {
64
+ // If theme file is invalid JSON, ignore and proceed with base tokens
65
+ }
66
+ }
67
+ }
68
+ // Create engine with flattened tokens
69
+ const { flat, edges } = flattenTokens(tokens);
70
+ const init: Record<string, string | number> = {};
71
+ for (const t of Object.values(flat)) {
72
+ init[(t as FlatToken).id] = (t as FlatToken).value;
73
+ }
74
+ const engine = new Engine(init, edges);
75
+ const knownIds = new Set(Object.keys(init));
76
+
77
+ // Discover and attach all constraints via centralized registry
78
+ setupConstraints(
79
+ engine,
80
+ { config, bp, constraintsDir: 'themes' },
81
+ { knownIds, crossAxisDebug },
82
+ );
83
+
84
+ const allIds = new Set(Object.keys(init));
85
+ const issues = engine.evaluate(allIds);
86
+ const errs = issues.filter((i: ConstraintIssue) => i.level === 'error');
87
+ const warns = issues.filter((i: ConstraintIssue) => i.level !== 'error');
88
+ if (errs.length) anyErrors = true;
89
+ totalErrors += errs.length; totalWarnings += warns.length;
90
+
91
+ // Collect for JSON output
92
+ allErrors.push(...errs);
93
+ allWarnings.push(...warns);
94
+
95
+ const rulesEvaluated = errs.length + warns.length; pushRow(bp ?? 'global', { rules: rulesEvaluated, warnings: warns.length, errors: errs.length });
96
+ const dur = globalThis.performance.now() - tStart;
97
+ perBpTimings.push({ bp: bp ?? 'global', ms: dur });
98
+
99
+ // Only print text output if not in JSON mode
100
+ if (outputFormat !== 'json') {
101
+ console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
102
+ for (const it of issues) { const tag = it.level === 'error' ? 'ERROR' : 'WARN '; console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`); }
103
+ }
104
+ }
105
+ const totalMs = globalThis.performance.now() - tStartTotal;
106
+ // Append aggregate total row if multiple scopes and not already added
107
+ if (rows.length > 1) {
108
+ const agg = rows.reduce((a,b)=>({ rules:a.rules+b.rules, warnings:a.warnings+b.warnings, errors:a.errors+b.errors }), { rules:0,warnings:0,errors:0 });
109
+ rows.push({ bp: 'TOTAL', ...agg });
110
+ }
111
+ // Get package version for stats
112
+ let engineVersion = '1.0.0';
113
+ try {
114
+ // eslint-disable-next-line no-undef
115
+ const pkgPath = new URL('../../package.json', import.meta.url);
116
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
117
+ engineVersion = pkg.version;
118
+ } catch {
119
+ // Ignore package.json read errors, use default version
120
+ }
121
+
122
+ // Handle JSON output mode
123
+ if (outputFormat === 'json') {
124
+ const result = createValidationResult(allErrors, allWarnings, totalMs, engineVersion);
125
+
126
+ // If receipt requested, generate full receipt
127
+ if (_options.receipt) {
128
+ const tokensFile = _options.tokens ?? 'tokens/tokens.example.json';
129
+ const constraintsDir = 'themes';
130
+ const receipt = createValidationReceipt(result, tokensFile, constraintsDir, bps[0], failOn);
131
+ writeJsonOutput(receipt, _options.receipt);
132
+ } else {
133
+ writeJsonOutput(result, _options.output);
134
+ }
135
+ } else {
136
+ // Text output mode
137
+ if (_options.perf) {
138
+ console.log('[perf] per-breakpoint timings:');
139
+ for (const t of perBpTimings) console.log(` ${t.bp}: ${t.ms.toFixed(2)}ms`);
140
+ console.log(`[perf] total: ${totalMs.toFixed(2)}ms`);
141
+ }
142
+ if (summaryFmt === 'json') {
143
+ // Provide machine-readable aggregate separate from rows if TOTAL present
144
+ const totalRow = rows.find(r => r.bp === 'TOTAL');
145
+ const json = totalRow ? { rows, total: { rules: totalRow.rules, warnings: totalRow.warnings, errors: totalRow.errors } } : { rows };
146
+ console.log(JSON.stringify(json, null, 2));
147
+ } else if (summaryFmt === 'table') {
148
+ printSummaryTable(rows);
149
+ }
150
+ }
151
+ let code = anyErrors ? 1 : 0;
152
+ // Budget checks (do not override fail-on semantics unless budgets add failures)
153
+ const budgetTotal = (_options as any)['budget-total-ms'] ?? _options.budgetTotalMs;
154
+ const budgetPerBp = (_options as any)['budget-per-bp-ms'] ?? _options.budgetPerBpMs;
155
+ let budgetFailed = false;
156
+ if (budgetTotal != null && totalMs > budgetTotal) {
157
+ console.error(`[perf] total time ${totalMs.toFixed(2)}ms exceeded budget ${budgetTotal}ms`);
158
+ budgetFailed = true;
159
+ }
160
+ if (budgetPerBp != null) {
161
+ for (const t of perBpTimings) {
162
+ if (t.ms > budgetPerBp) {
163
+ console.error(`[perf] ${t.bp} time ${t.ms.toFixed(2)}ms exceeded per-breakpoint budget ${budgetPerBp}ms`);
164
+ budgetFailed = true;
165
+ }
166
+ }
167
+ }
168
+ if (failOn === 'off') code = 0; else if (failOn === 'warn') code = (totalErrors + totalWarnings) > 0 ? 1 : 0; else code = totalErrors > 0 ? 1 : 0;
169
+ if (budgetFailed) code = Math.max(code, 1);
170
+ process.exit(code);
171
+ } catch (e) {
172
+ const msg = e instanceof Error ? e.message : String(e);
173
+ console.error('validate: failed:', msg);
174
+ process.exit(2);
175
+ }
176
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"why.d.ts","sourceRoot":"","sources":["why.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnE"}
1
+ {"version":3,"file":"why.d.ts","sourceRoot":"","sources":["why.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAO9C,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAmJnE"}