design-constraint-validator 1.0.0 → 2.0.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.
Files changed (121) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +229 -659
  3. package/adapters/README.md +46 -46
  4. package/adapters/css.ts +116 -116
  5. package/adapters/decisionthemes.d.ts +44 -0
  6. package/adapters/decisionthemes.d.ts.map +1 -0
  7. package/adapters/decisionthemes.js +35 -0
  8. package/adapters/decisionthemes.ts +59 -0
  9. package/adapters/js.ts +14 -14
  10. package/adapters/json.ts +45 -45
  11. package/cli/build-css.ts +32 -32
  12. package/cli/commands/build.ts +65 -65
  13. package/cli/commands/graph.d.ts.map +1 -1
  14. package/cli/commands/graph.js +26 -10
  15. package/cli/commands/graph.ts +180 -137
  16. package/cli/commands/index.ts +7 -7
  17. package/cli/commands/patch-apply.ts +80 -80
  18. package/cli/commands/patch.ts +22 -22
  19. package/cli/commands/set.d.ts.map +1 -1
  20. package/cli/commands/set.js +12 -4
  21. package/cli/commands/set.ts +239 -225
  22. package/cli/commands/utils.ts +50 -50
  23. package/cli/commands/validate.d.ts.map +1 -1
  24. package/cli/commands/validate.js +89 -33
  25. package/cli/commands/validate.ts +180 -115
  26. package/cli/commands/why.d.ts.map +1 -1
  27. package/cli/commands/why.js +86 -20
  28. package/cli/commands/why.ts +158 -46
  29. package/cli/config-schema.ts +27 -27
  30. package/cli/config.ts +35 -35
  31. package/cli/constraint-registry.d.ts +101 -0
  32. package/cli/constraint-registry.d.ts.map +1 -0
  33. package/cli/constraint-registry.js +225 -0
  34. package/cli/constraint-registry.ts +304 -0
  35. package/cli/constraints-loader.d.ts.map +1 -0
  36. package/cli/cross-axis-loader.d.ts +91 -0
  37. package/cli/cross-axis-loader.d.ts.map +1 -0
  38. package/cli/cross-axis-loader.js +222 -0
  39. package/cli/cross-axis-loader.ts +289 -0
  40. package/cli/dcv.js +4 -0
  41. package/cli/dcv.ts +111 -107
  42. package/cli/engine-helpers.d.ts.map +1 -1
  43. package/cli/graph-poset.ts +74 -74
  44. package/cli/json-output.d.ts +69 -0
  45. package/cli/json-output.d.ts.map +1 -0
  46. package/cli/json-output.js +109 -0
  47. package/cli/json-output.ts +184 -0
  48. package/cli/result.ts +27 -27
  49. package/cli/run.ts +54 -54
  50. package/cli/smoke-test.ts +40 -40
  51. package/cli/types.d.ts +6 -0
  52. package/cli/types.d.ts.map +1 -1
  53. package/cli/types.ts +84 -78
  54. package/cli/version-banner.d.ts +20 -0
  55. package/cli/version-banner.d.ts.map +1 -0
  56. package/cli/version-banner.js +49 -0
  57. package/cli/version-banner.ts +61 -0
  58. package/core/breakpoints.ts +50 -50
  59. package/core/cli-format.ts +31 -31
  60. package/core/color.ts +148 -148
  61. package/core/constraints/cross-axis.ts +114 -114
  62. package/core/constraints/monotonic-lightness.ts +38 -38
  63. package/core/constraints/monotonic.ts +74 -74
  64. package/core/constraints/threshold.ts +43 -43
  65. package/core/constraints/wcag.ts +70 -70
  66. package/core/cross-axis-config.d.ts.map +1 -1
  67. package/core/engine.d.ts +95 -0
  68. package/core/engine.d.ts.map +1 -1
  69. package/core/engine.js +22 -0
  70. package/core/engine.ts +167 -65
  71. package/core/flatten.ts +116 -116
  72. package/core/image-export.ts +48 -48
  73. package/core/index.d.ts +9 -30
  74. package/core/index.d.ts.map +1 -1
  75. package/core/index.js +7 -54
  76. package/core/index.ts +10 -72
  77. package/core/patch.ts +134 -134
  78. package/core/poset.ts +311 -311
  79. package/core/why.ts +63 -63
  80. package/package.json +96 -90
  81. package/themes/color.lg.order.json +15 -15
  82. package/themes/color.md.order.json +15 -15
  83. package/themes/color.order.json +15 -15
  84. package/themes/color.sm.order.json +15 -15
  85. package/themes/cross-axis.rules.json +35 -35
  86. package/themes/cross-axis.sm.rules.json +12 -12
  87. package/themes/layout.lg.order.json +18 -18
  88. package/themes/layout.md.order.json +18 -18
  89. package/themes/layout.order.json +18 -18
  90. package/themes/layout.sm.order.json +18 -18
  91. package/themes/spacing.order.json +14 -14
  92. package/themes/typography.lg.order.json +15 -15
  93. package/themes/typography.md.order.json +15 -15
  94. package/themes/typography.order.json +15 -15
  95. package/themes/typography.sm.order.json +15 -15
  96. package/cli/engine-helpers.d.ts +0 -8
  97. package/cli/engine-helpers.js +0 -70
  98. package/cli/engine-helpers.ts +0 -61
  99. package/core/cross-axis-config.d.ts +0 -5
  100. package/core/cross-axis-config.js +0 -144
  101. package/core/cross-axis-config.ts +0 -152
  102. package/dist/test-overrides-removal.json +0 -4
  103. package/dist/tmp.patch.json +0 -35
  104. package/tokens/overrides/base.json +0 -22
  105. package/tokens/overrides/lg.json +0 -20
  106. package/tokens/overrides/md.json +0 -16
  107. package/tokens/overrides/sm.json +0 -16
  108. package/tokens/overrides/viol.color.json +0 -6
  109. package/tokens/overrides/viol.typography.json +0 -6
  110. package/tokens/tokens.demo-violations.json +0 -116
  111. package/tokens/tokens.example.json +0 -128
  112. package/tokens/tokens.json +0 -67
  113. package/tokens/tokens.multi-violations.json +0 -21
  114. package/tokens/tokens.schema.d.ts +0 -2298
  115. package/tokens/tokens.schema.d.ts.map +0 -1
  116. package/tokens/tokens.schema.js +0 -148
  117. package/tokens/tokens.schema.ts +0 -196
  118. package/tokens/tokens.test.json +0 -38
  119. package/tokens/tokens.touch-violation.json +0 -8
  120. package/tokens/typography.classes.css +0 -11
  121. package/tokens/typography.css +0 -20
@@ -1,9 +1,15 @@
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';
9
+ import { printVersionBanner } from '../version-banner.js';
6
10
  export async function validateCommand(_options) {
11
+ // Show version banner (subtle, dimmed)
12
+ printVersionBanner({ quiet: _options.format === 'json' });
7
13
  try {
8
14
  const bps = parseBreakpoints(process.argv);
9
15
  const crossAxisDebug = process.argv.includes('--cross-axis-debug');
@@ -13,9 +19,13 @@ export async function validateCommand(_options) {
13
19
  let totalWarnings = 0;
14
20
  const argv = process.argv.slice(2);
15
21
  const failOnIdx = argv.indexOf('--fail-on');
16
- const failOn = failOnIdx >= 0 ? argv[failOnIdx + 1] : 'error';
22
+ const failOn = _options.failOn ?? (failOnIdx >= 0 ? argv[failOnIdx + 1] : 'error');
17
23
  const sumIdx = argv.indexOf('--summary');
18
- const summaryFmt = sumIdx >= 0 ? argv[sumIdx + 1] : 'none';
24
+ const summaryFmt = _options.summary ?? (sumIdx >= 0 ? argv[sumIdx + 1] : 'none');
25
+ const outputFormat = _options.format ?? 'text';
26
+ // Collect all issues for JSON output
27
+ const allErrors = [];
28
+ const allWarnings = [];
19
29
  const rows = [];
20
30
  function pushRow(bpLabel, stats) { rows.push({ bp: bpLabel, ...stats }); }
21
31
  function printSummaryTable(rs) {
@@ -45,19 +55,31 @@ export async function validateCommand(_options) {
45
55
  const tStartTotal = globalThis.performance.now();
46
56
  for (const bp of plan) {
47
57
  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 }));
58
+ let tokens = loadTokensWithBreakpoint(bp);
59
+ // Optional theme overlay (tokens/themes/<name>.json), mirroring build behavior
60
+ if (_options.theme) {
61
+ const themePath = join('tokens/themes', `${_options.theme}.json`);
62
+ if (existsSync(themePath)) {
63
+ try {
64
+ const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
65
+ tokens = mergeTokens(tokens, themeTokens);
66
+ }
67
+ catch {
68
+ // If theme file is invalid JSON, ignore and proceed with base tokens
69
+ }
70
+ }
71
+ }
72
+ // Create engine with flattened tokens
73
+ const { flat, edges } = flattenTokens(tokens);
74
+ const init = {};
75
+ for (const t of Object.values(flat)) {
76
+ init[t.id] = t.value;
57
77
  }
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));
78
+ const engine = new Engine(init, edges);
79
+ const knownIds = new Set(Object.keys(init));
80
+ // Discover and attach all constraints via centralized registry
81
+ setupConstraints(engine, { config, bp, constraintsDir: 'themes' }, { knownIds, crossAxisDebug });
82
+ const allIds = new Set(Object.keys(init));
61
83
  const issues = engine.evaluate(allIds);
62
84
  const errs = issues.filter((i) => i.level === 'error');
63
85
  const warns = issues.filter((i) => i.level !== 'error');
@@ -65,14 +87,20 @@ export async function validateCommand(_options) {
65
87
  anyErrors = true;
66
88
  totalErrors += errs.length;
67
89
  totalWarnings += warns.length;
90
+ // Collect for JSON output
91
+ allErrors.push(...errs);
92
+ allWarnings.push(...warns);
68
93
  const rulesEvaluated = errs.length + warns.length;
69
94
  pushRow(bp ?? 'global', { rules: rulesEvaluated, warnings: warns.length, errors: errs.length });
70
95
  const dur = globalThis.performance.now() - tStart;
71
96
  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}`);
97
+ // Only print text output if not in JSON mode
98
+ if (outputFormat !== 'json') {
99
+ console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
100
+ for (const it of issues) {
101
+ const tag = it.level === 'error' ? 'ERROR' : 'WARN ';
102
+ console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`);
103
+ }
76
104
  }
77
105
  }
78
106
  const totalMs = globalThis.performance.now() - tStartTotal;
@@ -81,20 +109,48 @@ export async function validateCommand(_options) {
81
109
  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
110
  rows.push({ bp: 'TOTAL', ...agg });
83
111
  }
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`);
112
+ // Get package version for stats
113
+ let engineVersion = '1.0.0';
114
+ try {
115
+ // eslint-disable-next-line no-undef
116
+ const pkgPath = new URL('../../package.json', import.meta.url);
117
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
118
+ engineVersion = pkg.version;
89
119
  }
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));
120
+ catch {
121
+ // Ignore package.json read errors, use default version
95
122
  }
96
- else if (summaryFmt === 'table') {
97
- printSummaryTable(rows);
123
+ // Handle JSON output mode
124
+ if (outputFormat === 'json') {
125
+ const result = createValidationResult(allErrors, allWarnings, totalMs, engineVersion);
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
+ }
133
+ else {
134
+ writeJsonOutput(result, _options.output);
135
+ }
136
+ }
137
+ else {
138
+ // Text output mode
139
+ if (_options.perf) {
140
+ console.log('[perf] per-breakpoint timings:');
141
+ for (const t of perBpTimings)
142
+ console.log(` ${t.bp}: ${t.ms.toFixed(2)}ms`);
143
+ console.log(`[perf] total: ${totalMs.toFixed(2)}ms`);
144
+ }
145
+ if (summaryFmt === 'json') {
146
+ // Provide machine-readable aggregate separate from rows if TOTAL present
147
+ const totalRow = rows.find(r => r.bp === 'TOTAL');
148
+ const json = totalRow ? { rows, total: { rules: totalRow.rules, warnings: totalRow.warnings, errors: totalRow.errors } } : { rows };
149
+ console.log(JSON.stringify(json, null, 2));
150
+ }
151
+ else if (summaryFmt === 'table') {
152
+ printSummaryTable(rows);
153
+ }
98
154
  }
99
155
  let code = anyErrors ? 1 : 0;
100
156
  // Budget checks (do not override fail-on semantics unless budgets add failures)
@@ -1,115 +1,180 @@
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
+ import { printVersionBanner } from '../version-banner.js';
12
+
13
+ export async function validateCommand(_options: ValidateOptions): Promise<void> {
14
+ // Show version banner (subtle, dimmed)
15
+ printVersionBanner({ quiet: _options.format === 'json' });
16
+
17
+ try {
18
+ const bps = parseBreakpoints(process.argv);
19
+ const crossAxisDebug = process.argv.includes('--cross-axis-debug');
20
+ const plan: (Breakpoint | undefined)[] = bps.length ? bps : [undefined];
21
+ let anyErrors = false; let totalErrors = 0; let totalWarnings = 0;
22
+ const argv = process.argv.slice(2);
23
+ const failOnIdx = argv.indexOf('--fail-on');
24
+ type FailOn = 'off' | 'warn' | 'error';
25
+ const failOn: FailOn = _options.failOn ?? (failOnIdx >= 0 ? (argv[failOnIdx + 1] as FailOn) : 'error');
26
+ const sumIdx = argv.indexOf('--summary');
27
+ type SummaryFmt = 'table' | 'json' | 'none';
28
+ const summaryFmt: SummaryFmt = _options.summary ?? (sumIdx >= 0 ? (argv[sumIdx + 1] as SummaryFmt) : 'none');
29
+ const outputFormat = _options.format ?? 'text';
30
+
31
+ // Collect all issues for JSON output
32
+ const allErrors: ConstraintIssue[] = [];
33
+ const allWarnings: ConstraintIssue[] = [];
34
+ type VRow = { bp: string; rules: number; warnings: number; errors: number };
35
+ const rows: VRow[] = [];
36
+ function pushRow(bpLabel: string, stats: { rules: number; warnings: number; errors: number }) { rows.push({ bp: bpLabel, ...stats }); }
37
+ function printSummaryTable(rs: VRow[]) {
38
+ if (!rs.length) return;
39
+ const showTotalLine = !rs.some(r => r.bp === 'TOTAL');
40
+ const cols = ['scope','rules','warnings','errors'] as const;
41
+ const data = rs.map(r => ({ scope: r.bp, rules: String(r.rules), warnings: String(r.warnings), errors: String(r.errors) }));
42
+ const widths = cols.map(c => Math.max(c.length, ...data.map(d => d[c].length)));
43
+ const line = (vals: string[]) => vals.map((v,i)=>v.padEnd(widths[i])).join(' ');
44
+ console.log(line(cols as unknown as string[]));
45
+ console.log(line(widths.map(w => '-'.repeat(w))));
46
+ for (const d of data) console.log(line(cols.map(c => d[c])));
47
+ if (showTotalLine && rs.length > 1) {
48
+ 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 });
49
+ console.log(line(['TOTAL', String(tot.rules), String(tot.warnings), String(tot.errors)]));
50
+ }
51
+ }
52
+ const cfgRes = loadConfig(_options.config);
53
+ if (!cfgRes.ok) { console.error(cfgRes.error); process.exit(2); }
54
+ const config = cfgRes.value;
55
+ const perBpTimings: Array<{ bp: string; ms: number }> = [];
56
+ const tStartTotal = globalThis.performance.now();
57
+ for (const bp of plan) {
58
+ const tStart = globalThis.performance.now();
59
+ let tokens: TokenNode = loadTokensWithBreakpoint(bp);
60
+ // Optional theme overlay (tokens/themes/<name>.json), mirroring build behavior
61
+ if (_options.theme) {
62
+ const themePath = join('tokens/themes', `${_options.theme}.json`);
63
+ if (existsSync(themePath)) {
64
+ try {
65
+ const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
66
+ tokens = mergeTokens(tokens, themeTokens);
67
+ } catch {
68
+ // If theme file is invalid JSON, ignore and proceed with base tokens
69
+ }
70
+ }
71
+ }
72
+ // Create engine with flattened tokens
73
+ const { flat, edges } = flattenTokens(tokens);
74
+ const init: Record<string, string | number> = {};
75
+ for (const t of Object.values(flat)) {
76
+ init[(t as FlatToken).id] = (t as FlatToken).value;
77
+ }
78
+ const engine = new Engine(init, edges);
79
+ const knownIds = new Set(Object.keys(init));
80
+
81
+ // Discover and attach all constraints via centralized registry
82
+ setupConstraints(
83
+ engine,
84
+ { config, bp, constraintsDir: 'themes' },
85
+ { knownIds, crossAxisDebug },
86
+ );
87
+
88
+ const allIds = new Set(Object.keys(init));
89
+ const issues = engine.evaluate(allIds);
90
+ const errs = issues.filter((i: ConstraintIssue) => i.level === 'error');
91
+ const warns = issues.filter((i: ConstraintIssue) => i.level !== 'error');
92
+ if (errs.length) anyErrors = true;
93
+ totalErrors += errs.length; totalWarnings += warns.length;
94
+
95
+ // Collect for JSON output
96
+ allErrors.push(...errs);
97
+ allWarnings.push(...warns);
98
+
99
+ const rulesEvaluated = errs.length + warns.length; pushRow(bp ?? 'global', { rules: rulesEvaluated, warnings: warns.length, errors: errs.length });
100
+ const dur = globalThis.performance.now() - tStart;
101
+ perBpTimings.push({ bp: bp ?? 'global', ms: dur });
102
+
103
+ // Only print text output if not in JSON mode
104
+ if (outputFormat !== 'json') {
105
+ console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
106
+ 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}`); }
107
+ }
108
+ }
109
+ const totalMs = globalThis.performance.now() - tStartTotal;
110
+ // Append aggregate total row if multiple scopes and not already added
111
+ if (rows.length > 1) {
112
+ 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 });
113
+ rows.push({ bp: 'TOTAL', ...agg });
114
+ }
115
+ // Get package version for stats
116
+ let engineVersion = '1.0.0';
117
+ try {
118
+ // eslint-disable-next-line no-undef
119
+ const pkgPath = new URL('../../package.json', import.meta.url);
120
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
121
+ engineVersion = pkg.version;
122
+ } catch {
123
+ // Ignore package.json read errors, use default version
124
+ }
125
+
126
+ // Handle JSON output mode
127
+ if (outputFormat === 'json') {
128
+ const result = createValidationResult(allErrors, allWarnings, totalMs, engineVersion);
129
+
130
+ // If receipt requested, generate full receipt
131
+ if (_options.receipt) {
132
+ const tokensFile = _options.tokens ?? 'tokens/tokens.example.json';
133
+ const constraintsDir = 'themes';
134
+ const receipt = createValidationReceipt(result, tokensFile, constraintsDir, bps[0], failOn);
135
+ writeJsonOutput(receipt, _options.receipt);
136
+ } else {
137
+ writeJsonOutput(result, _options.output);
138
+ }
139
+ } else {
140
+ // Text output mode
141
+ if (_options.perf) {
142
+ console.log('[perf] per-breakpoint timings:');
143
+ for (const t of perBpTimings) console.log(` ${t.bp}: ${t.ms.toFixed(2)}ms`);
144
+ console.log(`[perf] total: ${totalMs.toFixed(2)}ms`);
145
+ }
146
+ if (summaryFmt === 'json') {
147
+ // Provide machine-readable aggregate separate from rows if TOTAL present
148
+ const totalRow = rows.find(r => r.bp === 'TOTAL');
149
+ const json = totalRow ? { rows, total: { rules: totalRow.rules, warnings: totalRow.warnings, errors: totalRow.errors } } : { rows };
150
+ console.log(JSON.stringify(json, null, 2));
151
+ } else if (summaryFmt === 'table') {
152
+ printSummaryTable(rows);
153
+ }
154
+ }
155
+ let code = anyErrors ? 1 : 0;
156
+ // Budget checks (do not override fail-on semantics unless budgets add failures)
157
+ const budgetTotal = (_options as any)['budget-total-ms'] ?? _options.budgetTotalMs;
158
+ const budgetPerBp = (_options as any)['budget-per-bp-ms'] ?? _options.budgetPerBpMs;
159
+ let budgetFailed = false;
160
+ if (budgetTotal != null && totalMs > budgetTotal) {
161
+ console.error(`[perf] total time ${totalMs.toFixed(2)}ms exceeded budget ${budgetTotal}ms`);
162
+ budgetFailed = true;
163
+ }
164
+ if (budgetPerBp != null) {
165
+ for (const t of perBpTimings) {
166
+ if (t.ms > budgetPerBp) {
167
+ console.error(`[perf] ${t.bp} time ${t.ms.toFixed(2)}ms exceeded per-breakpoint budget ${budgetPerBp}ms`);
168
+ budgetFailed = true;
169
+ }
170
+ }
171
+ }
172
+ if (failOn === 'off') code = 0; else if (failOn === 'warn') code = (totalErrors + totalWarnings) > 0 ? 1 : 0; else code = totalErrors > 0 ? 1 : 0;
173
+ if (budgetFailed) code = Math.max(code, 1);
174
+ process.exit(code);
175
+ } catch (e) {
176
+ const msg = e instanceof Error ? e.message : String(e);
177
+ console.error('validate: failed:', msg);
178
+ process.exit(2);
179
+ }
180
+ }
@@ -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"}
@@ -2,61 +2,127 @@ import { readFileSync } from 'node:fs';
2
2
  import { flattenTokens } from '../../core/flatten.js';
3
3
  import { explain } from '../../core/why.js';
4
4
  import { loadTokens } from './utils.js';
5
+ import { Engine } from '../../core/engine.js';
6
+ import { loadConfig } from '../config.js';
7
+ import { setupConstraints } from '../constraint-registry.js';
5
8
  export async function whyCommand(options) {
6
9
  const tokensPath = options.tokens || 'tokens/tokens.json';
7
10
  const tokens = loadTokens(tokensPath);
8
11
  const { flat, edges } = flattenTokens(tokens);
9
12
  const target = options.tokenId;
10
13
  if (!flat[target]) {
11
- console.error(`❌ Token not found: ${target}`);
14
+ console.error(`Token not found: ${target}`);
12
15
  const { suggestIds } = await import('../../core/cli-format.js');
13
16
  const suggestions = suggestIds(target, Object.keys(flat));
14
17
  if (suggestions.length > 0) {
15
- console.log(`\nDid you mean:`);
16
- suggestions.slice(0, 5).forEach(s => console.log(` ${s.id}`));
18
+ console.log('\nDid you mean:');
19
+ suggestions.slice(0, 5).forEach((s) => console.log(` ${s.id}`));
17
20
  }
18
21
  else {
19
- console.log(`\nAvailable tokens:`);
20
- Object.keys(flat).sort().slice(0, 10).forEach(id => console.log(` ${id}`));
21
- if (Object.keys(flat).length > 10)
22
+ console.log('\nAvailable tokens:');
23
+ Object.keys(flat)
24
+ .sort()
25
+ .slice(0, 10)
26
+ .forEach((id) => console.log(` ${id}`));
27
+ if (Object.keys(flat).length > 10) {
22
28
  console.log(` ... and ${Object.keys(flat).length - 10} more`);
29
+ }
23
30
  }
24
31
  process.exit(1);
25
32
  }
26
- function safeLoad(p) { try {
27
- return JSON.parse(readFileSync(p, 'utf8'));
33
+ function safeLoad(p) {
34
+ try {
35
+ return JSON.parse(readFileSync(p, 'utf8'));
36
+ }
37
+ catch {
38
+ return {};
39
+ }
28
40
  }
29
- catch {
30
- return {};
31
- } }
32
41
  const overrides = safeLoad('tokens/overrides/local.json');
33
42
  const theme = safeLoad('themes/theme.json');
34
- const report = explain(target, flat, edges, { overrides: overrides?.overrides ?? overrides, theme });
43
+ const baseReport = explain(target, flat, edges, {
44
+ overrides: overrides?.overrides ?? overrides,
45
+ theme,
46
+ });
47
+ // Best-effort constraint summary: which rules currently implicate this token
48
+ let constraintsSummary;
49
+ try {
50
+ const cfgRes = loadConfig(options.config);
51
+ if (cfgRes.ok) {
52
+ const config = cfgRes.value;
53
+ // Create engine with flattened tokens
54
+ const init = {};
55
+ for (const t of Object.values(flat)) {
56
+ init[t.id] = t.value;
57
+ }
58
+ const engine = new Engine(init, edges);
59
+ const knownIds = new Set(Object.keys(init));
60
+ // Discover and attach all constraints via centralized registry
61
+ setupConstraints(engine, { config, constraintsDir: 'themes' }, { knownIds });
62
+ const candidates = new Set([target]);
63
+ const allIssues = engine.evaluate(candidates);
64
+ if (allIssues.length) {
65
+ const related = allIssues.filter((issue) => {
66
+ const parts = String(issue.id).split('|');
67
+ return parts.includes(target);
68
+ });
69
+ if (related.length) {
70
+ constraintsSummary = related.map((issue) => ({
71
+ ruleId: issue.rule,
72
+ level: issue.level,
73
+ message: issue.message,
74
+ where: issue.where,
75
+ }));
76
+ }
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ // If constraint analysis fails, fall back to provenance-only report.
82
+ }
83
+ const report = constraintsSummary ? { ...baseReport, constraints: constraintsSummary } : baseReport;
35
84
  const format = options.format || 'json';
36
85
  if (format === 'table') {
37
86
  const { pad, trunc } = await import('../../core/cli-format.js');
38
87
  console.log(`\n=== Token Analysis: ${target} ===`);
39
88
  console.log(`Value: ${report.value}`);
40
- console.log(`Raw: ${report.raw || 'N/A'}`);
89
+ console.log(`Raw: ${report.raw ?? 'N/A'}`);
41
90
  console.log(`Provenance: ${report.provenance}`);
42
91
  if (report.dependsOn && report.dependsOn.length > 0) {
43
92
  console.log(`\nDependencies (${report.dependsOn.length}):`);
44
93
  console.log(pad('TOKEN', 30) + pad('VALUE', 20) + 'TYPE');
45
94
  console.log('-'.repeat(70));
46
- report.dependsOn.forEach((depId) => { const dep = flat[depId]; if (dep)
47
- console.log(pad(trunc(depId, 28), 30) + pad(trunc(String(dep.value), 18), 20) + (dep.type || 'unknown')); });
95
+ report.dependsOn.forEach((depId) => {
96
+ const dep = flat[depId];
97
+ if (dep) {
98
+ console.log(pad(trunc(depId, 28), 30) + pad(trunc(String(dep.value), 18), 20) + (dep.type || 'unknown'));
99
+ }
100
+ });
48
101
  }
49
102
  if (report.dependents && report.dependents.length > 0) {
50
103
  console.log(`\nDependents (${report.dependents.length}):`);
51
104
  console.log(pad('TOKEN', 30) + pad('VALUE', 20) + 'TYPE');
52
105
  console.log('-'.repeat(70));
53
- report.dependents.forEach((depId) => { const dep = flat[depId]; if (dep)
54
- console.log(pad(trunc(depId, 28), 30) + pad(trunc(String(dep.value), 18), 20) + (dep.type || 'unknown')); });
106
+ report.dependents.forEach((depId) => {
107
+ const dep = flat[depId];
108
+ if (dep) {
109
+ console.log(pad(trunc(depId, 28), 30) + pad(trunc(String(dep.value), 18), 20) + (dep.type || 'unknown'));
110
+ }
111
+ });
55
112
  }
56
- if (report.refs && report.refs.length > 0)
113
+ if (report.refs && report.refs.length > 0) {
57
114
  console.log(`\nReferences: ${report.refs.join(', ')}`);
58
- if (report.chain && report.chain.length > 0)
59
- console.log(`\nReference Chain: ${report.chain.join(' ')}`);
115
+ }
116
+ if (report.chain && report.chain.length > 0) {
117
+ console.log(`\nReference Chain: ${report.chain.join(' -> ')}`);
118
+ }
119
+ if (constraintsSummary && constraintsSummary.length > 0) {
120
+ console.log('\nConstraints (violations involving this token):');
121
+ constraintsSummary.forEach((c) => {
122
+ const where = c.where ? ` @ ${c.where}` : '';
123
+ console.log(`- [${c.level}] ${c.ruleId}${where}: ${c.message}`);
124
+ });
125
+ }
60
126
  }
61
127
  else {
62
128
  console.log(JSON.stringify(report, null, 2));