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,65 +1,65 @@
1
- import { join, dirname, resolve } from 'node:path';
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
- import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
- import { valuesToCss, type ManifestRow } from '../../adapters/css.js';
5
- import { emitJSON } from '../../adapters/json.js';
6
- import { emitJS } from '../../adapters/js.js';
7
- import type { BuildOptions } from '../types.js';
8
-
9
- export async function buildCommand(options: BuildOptions & { [k: string]: any }): Promise<void> {
10
- const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
11
- const tokens = loadTokensWithBreakpoint();
12
- const { flat } = flattenTokens(tokens);
13
- let allValues = Object.fromEntries(Object.values(flat).map(t => [t.id, (t as FlatToken).value]));
14
- if (options.theme) {
15
- const themePath = join('tokens/themes', `${options.theme}.json`);
16
- if (existsSync(themePath)) {
17
- const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
18
- Object.assign(allValues, themeTokens);
19
- }
20
- }
21
- const format = options.format || 'css';
22
- const defaultOutput = `dist/tokens.${format}`;
23
- let manifest: ManifestRow[] | undefined;
24
- if (options.mapper) {
25
- try {
26
- const mp = resolve(options.mapper);
27
- if (!existsSync(mp)) throw new Error(`mapper file not found: ${mp}`);
28
- manifest = JSON.parse(readFileSync(mp, 'utf8')) as ManifestRow[];
29
- if (!Array.isArray(manifest)) throw new Error('mapper manifest must be an array');
30
- } catch (e) {
31
- const msg = e instanceof Error ? e.message : String(e);
32
- console.error(`Failed to load mapper manifest: ${msg}`);
33
- process.exit(1);
34
- }
35
- }
36
- const allFormats = options.allFormats ?? options['all-formats'];
37
- if (allFormats) {
38
- const dir = 'dist'; if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
- const css = valuesToCss(allValues, { manifest });
40
- writeFileSync('dist/tokens.css', css, 'utf8');
41
- if (options.dryRun) console.log(css);
42
- writeFileSync('dist/tokens.json', emitJSON(allValues, manifest), 'utf8');
43
- writeFileSync('dist/tokens.js', emitJS(allValues, manifest), 'utf8');
44
- console.log(`Tokens written (all formats) to dist/ (css/json/js)${manifest ? ' with mapper' : ''}`);
45
- return;
46
- }
47
- const dryRun = options.dryRun ?? options['dry-run'];
48
- if (format === 'css') {
49
- const css = valuesToCss(allValues, { manifest });
50
- if (dryRun) { console.log(css); return; }
51
- const outPath = options.output || defaultOutput; const dir = dirname(outPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
52
- writeFileSync(outPath, css, 'utf8');
53
- console.log(`CSS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
54
- } else if (format === 'json') {
55
- const outPath = options.output || defaultOutput; const dir = dirname(outPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
56
- if (dryRun) { console.log(emitJSON(allValues, manifest)); return; }
57
- writeFileSync(outPath, emitJSON(allValues, manifest), 'utf8');
58
- console.log(`JSON tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
59
- } else if (format === 'js') {
60
- const outPath = options.output || defaultOutput; const dir = dirname(outPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
61
- if (dryRun) { console.log(emitJS(allValues, manifest)); return; }
62
- writeFileSync(outPath, emitJS(allValues, manifest), 'utf8');
63
- console.log(`JS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
64
- }
65
- }
1
+ import { join, dirname, resolve } from 'node:path';
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
+ import { valuesToCss, type ManifestRow } from '../../adapters/css.js';
5
+ import { emitJSON } from '../../adapters/json.js';
6
+ import { emitJS } from '../../adapters/js.js';
7
+ import type { BuildOptions } from '../types.js';
8
+
9
+ export async function buildCommand(options: BuildOptions & { [k: string]: any }): Promise<void> {
10
+ const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
11
+ const tokens = loadTokensWithBreakpoint();
12
+ const { flat } = flattenTokens(tokens);
13
+ let allValues = Object.fromEntries(Object.values(flat).map(t => [t.id, (t as FlatToken).value]));
14
+ if (options.theme) {
15
+ const themePath = join('tokens/themes', `${options.theme}.json`);
16
+ if (existsSync(themePath)) {
17
+ const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
18
+ Object.assign(allValues, themeTokens);
19
+ }
20
+ }
21
+ const format = options.format || 'css';
22
+ const defaultOutput = `dist/tokens.${format}`;
23
+ let manifest: ManifestRow[] | undefined;
24
+ if (options.mapper) {
25
+ try {
26
+ const mp = resolve(options.mapper);
27
+ if (!existsSync(mp)) throw new Error(`mapper file not found: ${mp}`);
28
+ manifest = JSON.parse(readFileSync(mp, 'utf8')) as ManifestRow[];
29
+ if (!Array.isArray(manifest)) throw new Error('mapper manifest must be an array');
30
+ } catch (e) {
31
+ const msg = e instanceof Error ? e.message : String(e);
32
+ console.error(`Failed to load mapper manifest: ${msg}`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+ const allFormats = options.allFormats ?? options['all-formats'];
37
+ if (allFormats) {
38
+ const dir = 'dist'; if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
+ const css = valuesToCss(allValues, { manifest });
40
+ writeFileSync('dist/tokens.css', css, 'utf8');
41
+ if (options.dryRun) console.log(css);
42
+ writeFileSync('dist/tokens.json', emitJSON(allValues, manifest), 'utf8');
43
+ writeFileSync('dist/tokens.js', emitJS(allValues, manifest), 'utf8');
44
+ console.log(`Tokens written (all formats) to dist/ (css/json/js)${manifest ? ' with mapper' : ''}`);
45
+ return;
46
+ }
47
+ const dryRun = options.dryRun ?? options['dry-run'];
48
+ if (format === 'css') {
49
+ const css = valuesToCss(allValues, { manifest });
50
+ if (dryRun) { console.log(css); return; }
51
+ const outPath = options.output || defaultOutput; const dir = dirname(outPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
52
+ writeFileSync(outPath, css, 'utf8');
53
+ console.log(`CSS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
54
+ } else if (format === 'json') {
55
+ const outPath = options.output || defaultOutput; const dir = dirname(outPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
56
+ if (dryRun) { console.log(emitJSON(allValues, manifest)); return; }
57
+ writeFileSync(outPath, emitJSON(allValues, manifest), 'utf8');
58
+ console.log(`JSON tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
59
+ } else if (format === 'js') {
60
+ const outPath = options.output || defaultOutput; const dir = dirname(outPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
61
+ if (dryRun) { console.log(emitJS(allValues, manifest)); return; }
62
+ writeFileSync(outPath, emitJS(allValues, manifest), 'utf8');
63
+ console.log(`JS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
64
+ }
65
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["graph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAyBhD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA8GvE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["graph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA2BhD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAuJvE"}
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { flattenTokens } from '../../core/flatten.js';
3
3
  import { exportGraphImage } from '../../core/image-export.js';
4
+ import { setupConstraints } from '../constraint-registry.js';
5
+ import { loadConfig } from '../config.js';
4
6
  // Local helper for non-poset dependency graphs
5
7
  function generateDependencyGraph(edges, format) {
6
8
  switch (format) {
@@ -103,12 +105,14 @@ export async function graphCommand(options) {
103
105
  const { Engine } = await import('../../core/engine.js');
104
106
  const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.js');
105
107
  const { MonotonicLightness } = await import('../../core/constraints/monotonic-lightness.js');
106
- const { ThresholdPlugin } = await import('../../core/constraints/threshold.js');
107
- const { flat, edges } = flattenTokens(tokens);
108
+ const { flat, edges: depEdges } = flattenTokens(tokens);
108
109
  const init = {};
109
- Object.values(flat).forEach(t => { const ft = t; init[ft.id] = ft.value; });
110
- const engine = new Engine(init, edges);
111
- const allIdsInHasse = new Set([...h.keys(), ...Array.from(h.values()).flatMap(s => [...s])]);
110
+ Object.values(flat).forEach((t) => {
111
+ const ft = t;
112
+ init[ft.id] = ft.value;
113
+ });
114
+ const engine = new Engine(init, depEdges);
115
+ const allIdsInHasse = new Set([...h.keys(), ...Array.from(h.values()).flatMap((s) => [...s])]);
112
116
  let issues = [];
113
117
  if (name === 'color') {
114
118
  const colorOrders = order;
@@ -118,10 +122,20 @@ export async function graphCommand(options) {
118
122
  const numericOrders = order;
119
123
  issues = MonotonicPlugin(numericOrders, parseSize, 'monotonic').evaluate(engine, allIdsInHasse);
120
124
  }
121
- const thresholdIssues = ThresholdPlugin([{ id: 'control.size.min', op: '>=', valuePx: 44, where: 'Touch target (WCAG / Apple HIG)' }]).evaluate(engine, allIdsInHasse);
122
- issues.push(...thresholdIssues);
125
+ // Attach threshold (and any other runtime constraints) respecting config flags
126
+ const cfgRes = loadConfig(undefined);
127
+ if (cfgRes.ok) {
128
+ const config = cfgRes.value;
129
+ const knownIds = new Set(Object.keys(flat));
130
+ setupConstraints(engine, { config, bp: breakpoint }, { knownIds });
131
+ const runtimeIssues = engine.evaluate(allIdsInHasse);
132
+ issues.push(...runtimeIssues);
133
+ }
123
134
  const severityRank = { error: 2, warn: 1 };
124
- const filteredIssues = issues.filter(it => { const level = it.level || 'error'; return severityRank[level] >= severityRank[minSeverity]; });
135
+ const filteredIssues = issues.filter((it) => {
136
+ const level = it.level || 'error';
137
+ return severityRank[level] >= severityRank[minSeverity];
138
+ });
125
139
  const edgeViol = new Set();
126
140
  const nodeViol = new Set();
127
141
  if (labelViolations)
@@ -134,13 +148,15 @@ export async function graphCommand(options) {
134
148
  nodeViol.add(b);
135
149
  if (labelViolations && edgeLabels) {
136
150
  let label = it.message ?? 'violation';
137
- if (labelTruncate > 0 && label.length > labelTruncate)
151
+ if (labelTruncate > 0 && label.length > labelTruncate) {
138
152
  label = label.slice(0, labelTruncate - 1) + '…';
153
+ }
139
154
  edgeLabels.set(`${a}|${b}`, label);
140
155
  }
141
156
  }
142
- else if (typeof it.id === 'string')
157
+ else if (typeof it.id === 'string') {
143
158
  nodeViol.add(it.id);
159
+ }
144
160
  }
145
161
  highlight = { nodes: nodeViol, edges: edgeViol, color: violationColor };
146
162
  if (onlyViolations) {
@@ -1,137 +1,180 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
- import type { GraphOptions } from '../types.js';
3
- import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
- import { exportGraphImage } from '../../core/image-export.js';
5
-
6
- // Local helper for non-poset dependency graphs
7
- function generateDependencyGraph(edges: Array<[string, string]>, format: string): string {
8
- switch (format) {
9
- case 'dot': {
10
- const dotNodes = new Set<string>(); edges.forEach(([f,t]) => { dotNodes.add(f); dotNodes.add(t); });
11
- let dot = 'digraph tokens {\n rankdir=LR;\n node [shape=box, style=rounded];\n\n';
12
- dotNodes.forEach(n => { dot += ` "${n}";\n`; });
13
- dot += '\n'; edges.forEach(([f,t]) => { dot += ` "${f}" -> "${t}";\n`; });
14
- return dot + '}\n'; }
15
- case 'mermaid': {
16
- let mermaid = 'graph LR\n'; const mermaidNodes = new Set<string>();
17
- edges.forEach(([from,to]) => { const fromId = from.replace(/[^a-zA-Z0-9]/g,'_'); const toId = to.replace(/[^a-zA-Z0-9]/g,'_');
18
- if (!mermaidNodes.has(fromId)) { mermaid += ` ${fromId}["${from}"]\n`; mermaidNodes.add(fromId); }
19
- if (!mermaidNodes.has(toId)) { mermaid += ` ${toId}["${to}"]\n`; mermaidNodes.add(toId); }
20
- mermaid += ` ${fromId} --> ${toId}\n`; });
21
- return mermaid; }
22
- case 'json':
23
- default: return JSON.stringify({ nodes: Array.from(new Set(edges.flat())), edges }, null, 2);
24
- }
25
- }
26
-
27
- export async function graphCommand(options: GraphOptions): Promise<void> {
28
- const { parseBreakpoints } = await import('../../core/breakpoints.js');
29
- const bps = parseBreakpoints(process.argv);
30
- const plan = bps.length ? bps : [undefined];
31
- if (options.hasse) {
32
- const name = options.hasse;
33
- const bundle = (options as any).bundle;
34
- const fmt = (options.format === 'json' ? 'mermaid' : options.format) as 'mermaid' | 'dot' | 'svg' | 'png';
35
- const imageFrom = options.imageFrom || 'mermaid';
36
- const filterPrefixes = options.filterPrefix ? options.filterPrefix.split(',').map(s=>s.trim()).filter(Boolean) : [];
37
- const excludePrefixes = options.excludePrefix ? options.excludePrefix.split(',').map(s=>s.trim()).filter(Boolean) : [];
38
- const onlyViolations = options.onlyViolations || false;
39
- const highlightViolations = options.highlightViolations || false;
40
- const violationColor = options.violationColor || '#ff2d55';
41
- const labelViolations = options.labelViolations || false;
42
- const labelTruncate = Math.max(0, options.labelTruncate || 0);
43
- const minSeverity = options.minSeverity || 'warn';
44
- const focus = options.focus; const radius = Math.max(0, options.radius || 1);
45
- for (const breakpoint of plan) {
46
- const suffixParts: string[] = [];
47
- if (breakpoint) suffixParts.push(breakpoint);
48
- if (filterPrefixes.length) suffixParts.push(filterPrefixes.join('_'));
49
- if (excludePrefixes.length) suffixParts.push('not-' + excludePrefixes.join('_'));
50
- if (focus) suffixParts.push(`focus-${focus.replace(/[^\w.*-]/g,'_')}-r${radius}`);
51
- if (onlyViolations) suffixParts.push('violations'); else if (highlightViolations) suffixParts.push('highlight-violations');
52
- if (labelViolations) suffixParts.push('labeled');
53
- const suffix = suffixParts.length ? '-' + suffixParts.map(s=>s.replace(/[^\w.-]/g,'_')).join('__') : '';
54
- const baseFmt = fmt === 'svg' || fmt === 'png' ? (imageFrom === 'dot' ? 'dot' : 'mermaid') : fmt;
55
- const ext = baseFmt === 'mermaid' ? 'mmd' : 'dot';
56
- const outDir = 'dist/graphs'; const baseFile = `${outDir}/${name}${suffix}-hasse.${ext}`;
57
- try {
58
- const src = `themes/${name}.order.json`;
59
- if (!existsSync(src)) { console.error(`❌ Order constraint file not found: ${src}`); process.exit(1); }
60
- const { order } = JSON.parse(readFileSync(src, 'utf8'));
61
- const { buildPoset, transitiveReduction, toMermaidHasseStyled, toDotHasseStyled, filterByPrefix, filterExcludePrefix, khopSubgraph, pickSeedsByPattern } = await import('../../core/poset.js');
62
- let g = buildPoset(order);
63
- if (filterPrefixes.length) g = filterByPrefix(g, filterPrefixes);
64
- if (excludePrefixes.length) g = filterExcludePrefix(g, excludePrefixes);
65
- let h = transitiveReduction(g);
66
- if (focus) { const nodes = new Set<string>([...h.keys(), ...Array.from(h.values()).flatMap(s=>[...s])]); const seeds = pickSeedsByPattern(nodes, focus); h = khopSubgraph(h, seeds, radius); }
67
- let highlight: { nodes: Set<string>; edges: Set<string>; color?: string } | undefined; let edgeLabels: Map<string,string> | undefined;
68
- if (onlyViolations || highlightViolations || labelViolations) {
69
- const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
70
- const tokens = loadTokensWithBreakpoint(breakpoint); const { flattenTokens } = await import('../../core/flatten.js');
71
- const { Engine } = await import('../../core/engine.js');
72
- const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.js');
73
- const { MonotonicLightness } = await import('../../core/constraints/monotonic-lightness.js');
74
- const { ThresholdPlugin } = await import('../../core/constraints/threshold.js');
75
- const { flat, edges } = flattenTokens(tokens); const init: Record<string,string|number> = {}; Object.values(flat).forEach(t => { const ft = t as FlatToken; init[ft.id] = ft.value; });
76
- const engine = new Engine(init, edges);
77
- const allIdsInHasse = new Set<string>([...h.keys(), ...Array.from(h.values()).flatMap(s=>[...s])]);
78
- let issues: any[] = [];
79
- if (name === 'color') {
80
- const colorOrders = order as [string, '<='|'>=', string][]; issues = MonotonicLightness(colorOrders).evaluate(engine, allIdsInHasse);
81
- } else {
82
- const numericOrders = order as [string, '<='|'>=', string][]; issues = MonotonicPlugin(numericOrders, parseSize, 'monotonic').evaluate(engine, allIdsInHasse);
83
- }
84
- const thresholdIssues = ThresholdPlugin([{ id: 'control.size.min', op: '>=', valuePx: 44, where: 'Touch target (WCAG / Apple HIG)' }]).evaluate(engine, allIdsInHasse); issues.push(...thresholdIssues);
85
- const severityRank = { error: 2, warn: 1 } as const;
86
- const filteredIssues = issues.filter(it => { const level = (it.level as 'warn'|'error') || 'error'; return severityRank[level] >= severityRank[minSeverity]; });
87
- const edgeViol = new Set<string>(); const nodeViol = new Set<string>(); if (labelViolations) edgeLabels = new Map<string,string>();
88
- for (const it of filteredIssues) {
89
- if (typeof it.id === 'string' && it.id.includes('|')) { const [a,b] = it.id.split('|'); edgeViol.add(`${a}|${b}`); nodeViol.add(a); nodeViol.add(b); if (labelViolations && edgeLabels) { let label = it.message ?? 'violation'; if (labelTruncate > 0 && label.length > labelTruncate) label = label.slice(0, labelTruncate - 1) + '…'; edgeLabels.set(`${a}|${b}`, label); } }
90
- else if (typeof it.id === 'string') nodeViol.add(it.id);
91
- }
92
- highlight = { nodes: nodeViol, edges: edgeViol, color: violationColor };
93
- if (onlyViolations) {
94
- const pruned: Map<string, Set<string>> = new Map();
95
- for (const [u, vs] of h) {
96
- for (const v of vs) {
97
- if (edgeViol.has(`${u}|${v}`) || nodeViol.has(u) || nodeViol.has(v)) { if (!pruned.has(u)) pruned.set(u, new Set()); pruned.get(u)!.add(v); if (!pruned.has(v)) pruned.set(v, new Set()); }
98
- }
99
- }
100
- h = pruned;
101
- }
102
- }
103
- const bpLabel = breakpoint ? ` @${breakpoint}` : '';
104
- const title = `${name}${suffix ? ' ' + suffix : ''}${bpLabel} (Hasse)`;
105
- const mermaidContent = toMermaidHasseStyled(h, { title, highlight, labels: edgeLabels });
106
- const dotContent = toDotHasseStyled(h, { title, highlight, labels: edgeLabels });
107
- mkdirSync(outDir, { recursive: true });
108
- if (bundle) {
109
- const mFile = baseFile.replace(/\.dot$/,'-bundle.mmd').replace(/\.mmd$/,'-bundle.mmd');
110
- const dFile = baseFile.replace(/\.mmd$/,'-bundle.dot').replace(/\.dot$/,'-bundle.dot');
111
- writeFileSync(mFile, mermaidContent);
112
- writeFileSync(dFile, dotContent);
113
- }
114
- const baseContent = baseFmt === 'mermaid' ? mermaidContent : dotContent;
115
- writeFileSync(baseFile, baseContent);
116
- if (fmt === 'svg' || fmt === 'png') {
117
- const imgFile = `${outDir}/${name}${suffix}-hasse.${fmt}`;
118
- const { ok, hint } = exportGraphImage(baseFile, imgFile, fmt, imageFrom);
119
- if (ok) console.log(`✓ Wrote ${baseFile} and ${imgFile}`); else console.log(`✓ Wrote ${baseFile} (image export skipped). ${hint}`);
120
- } else {
121
- const hasViolations = highlight && (highlight.edges.size > 0 || highlight.nodes.size > 0);
122
- const message = (onlyViolations || highlightViolations) && !hasViolations ? `✓ Wrote ${baseFile} (no violations in slice)` : `✓ Wrote ${baseFile}`;
123
- console.log(message);
124
- }
125
- } catch (error) { console.error(`❌ Error generating Hasse diagram for ${breakpoint}:`, error); process.exit(1); }
126
- }
127
- return; }
128
- for (const breakpoint of plan) {
129
- const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
130
- const tokens = loadTokensWithBreakpoint(breakpoint); const { edges } = flattenTokens(tokens);
131
- let filteredEdges = edges;
132
- if (options.filter) { const filterRegex = new RegExp(options.filter); filteredEdges = edges.filter(([from,to]) => filterRegex.test(from) || filterRegex.test(to)); }
133
- const format = options.format || 'json'; const graph = generateDependencyGraph(filteredEdges, format);
134
- if (options.output) { const bpSuffix = breakpoint ? `.${breakpoint}` : ''; const outputPath = options.output.replace(/(\.[^.]+)$/ , `${bpSuffix}$1`); writeFileSync(outputPath, graph, 'utf8'); console.log(`Dependency graph written to: ${outputPath}`); }
135
- else { if (breakpoint) console.log(`\n=== ${breakpoint.toUpperCase()} ===`); console.log(graph); }
136
- }
137
- }
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import type { GraphOptions } from '../types.js';
3
+ import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
+ import { exportGraphImage } from '../../core/image-export.js';
5
+ import { setupConstraints } from '../constraint-registry.js';
6
+ import { loadConfig } from '../config.js';
7
+
8
+ // Local helper for non-poset dependency graphs
9
+ function generateDependencyGraph(edges: Array<[string, string]>, format: string): string {
10
+ switch (format) {
11
+ case 'dot': {
12
+ const dotNodes = new Set<string>(); edges.forEach(([f,t]) => { dotNodes.add(f); dotNodes.add(t); });
13
+ let dot = 'digraph tokens {\n rankdir=LR;\n node [shape=box, style=rounded];\n\n';
14
+ dotNodes.forEach(n => { dot += ` "${n}";\n`; });
15
+ dot += '\n'; edges.forEach(([f,t]) => { dot += ` "${f}" -> "${t}";\n`; });
16
+ return dot + '}\n'; }
17
+ case 'mermaid': {
18
+ let mermaid = 'graph LR\n'; const mermaidNodes = new Set<string>();
19
+ edges.forEach(([from,to]) => { const fromId = from.replace(/[^a-zA-Z0-9]/g,'_'); const toId = to.replace(/[^a-zA-Z0-9]/g,'_');
20
+ if (!mermaidNodes.has(fromId)) { mermaid += ` ${fromId}["${from}"]\n`; mermaidNodes.add(fromId); }
21
+ if (!mermaidNodes.has(toId)) { mermaid += ` ${toId}["${to}"]\n`; mermaidNodes.add(toId); }
22
+ mermaid += ` ${fromId} --> ${toId}\n`; });
23
+ return mermaid; }
24
+ case 'json':
25
+ default: return JSON.stringify({ nodes: Array.from(new Set(edges.flat())), edges }, null, 2);
26
+ }
27
+ }
28
+
29
+ export async function graphCommand(options: GraphOptions): Promise<void> {
30
+ const { parseBreakpoints } = await import('../../core/breakpoints.js');
31
+ const bps = parseBreakpoints(process.argv);
32
+ const plan = bps.length ? bps : [undefined];
33
+ if (options.hasse) {
34
+ const name = options.hasse;
35
+ const bundle = (options as any).bundle;
36
+ const fmt = (options.format === 'json' ? 'mermaid' : options.format) as 'mermaid' | 'dot' | 'svg' | 'png';
37
+ const imageFrom = options.imageFrom || 'mermaid';
38
+ const filterPrefixes = options.filterPrefix ? options.filterPrefix.split(',').map(s=>s.trim()).filter(Boolean) : [];
39
+ const excludePrefixes = options.excludePrefix ? options.excludePrefix.split(',').map(s=>s.trim()).filter(Boolean) : [];
40
+ const onlyViolations = options.onlyViolations || false;
41
+ const highlightViolations = options.highlightViolations || false;
42
+ const violationColor = options.violationColor || '#ff2d55';
43
+ const labelViolations = options.labelViolations || false;
44
+ const labelTruncate = Math.max(0, options.labelTruncate || 0);
45
+ const minSeverity = options.minSeverity || 'warn';
46
+ const focus = options.focus; const radius = Math.max(0, options.radius || 1);
47
+ for (const breakpoint of plan) {
48
+ const suffixParts: string[] = [];
49
+ if (breakpoint) suffixParts.push(breakpoint);
50
+ if (filterPrefixes.length) suffixParts.push(filterPrefixes.join('_'));
51
+ if (excludePrefixes.length) suffixParts.push('not-' + excludePrefixes.join('_'));
52
+ if (focus) suffixParts.push(`focus-${focus.replace(/[^\w.*-]/g,'_')}-r${radius}`);
53
+ if (onlyViolations) suffixParts.push('violations'); else if (highlightViolations) suffixParts.push('highlight-violations');
54
+ if (labelViolations) suffixParts.push('labeled');
55
+ const suffix = suffixParts.length ? '-' + suffixParts.map(s=>s.replace(/[^\w.-]/g,'_')).join('__') : '';
56
+ const baseFmt = fmt === 'svg' || fmt === 'png' ? (imageFrom === 'dot' ? 'dot' : 'mermaid') : fmt;
57
+ const ext = baseFmt === 'mermaid' ? 'mmd' : 'dot';
58
+ const outDir = 'dist/graphs'; const baseFile = `${outDir}/${name}${suffix}-hasse.${ext}`;
59
+ try {
60
+ const src = `themes/${name}.order.json`;
61
+ if (!existsSync(src)) { console.error(`❌ Order constraint file not found: ${src}`); process.exit(1); }
62
+ const { order } = JSON.parse(readFileSync(src, 'utf8'));
63
+ const { buildPoset, transitiveReduction, toMermaidHasseStyled, toDotHasseStyled, filterByPrefix, filterExcludePrefix, khopSubgraph, pickSeedsByPattern } = await import('../../core/poset.js');
64
+ let g = buildPoset(order);
65
+ if (filterPrefixes.length) g = filterByPrefix(g, filterPrefixes);
66
+ if (excludePrefixes.length) g = filterExcludePrefix(g, excludePrefixes);
67
+ let h = transitiveReduction(g);
68
+ if (focus) { const nodes = new Set<string>([...h.keys(), ...Array.from(h.values()).flatMap(s=>[...s])]); const seeds = pickSeedsByPattern(nodes, focus); h = khopSubgraph(h, seeds, radius); }
69
+ let highlight: { nodes: Set<string>; edges: Set<string>; color?: string } | undefined; let edgeLabels: Map<string,string> | undefined;
70
+ if (onlyViolations || highlightViolations || labelViolations) {
71
+ const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
72
+ const tokens = loadTokensWithBreakpoint(breakpoint);
73
+ const { flattenTokens } = await import('../../core/flatten.js');
74
+ const { Engine } = await import('../../core/engine.js');
75
+ const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.js');
76
+ const { MonotonicLightness } = await import('../../core/constraints/monotonic-lightness.js');
77
+
78
+ const { flat, edges: depEdges } = flattenTokens(tokens);
79
+ const init: Record<string, string | number> = {};
80
+ Object.values(flat).forEach((t) => {
81
+ const ft = t as FlatToken;
82
+ init[ft.id] = ft.value;
83
+ });
84
+ const engine = new Engine(init, depEdges);
85
+
86
+ const allIdsInHasse = new Set<string>([...h.keys(), ...Array.from(h.values()).flatMap((s) => [...s])]);
87
+ let issues: any[] = [];
88
+ if (name === 'color') {
89
+ const colorOrders = order as [string, '<=' | '>=', string][];
90
+ issues = MonotonicLightness(colorOrders).evaluate(engine, allIdsInHasse);
91
+ } else {
92
+ const numericOrders = order as [string, '<=' | '>=', string][];
93
+ issues = MonotonicPlugin(numericOrders, parseSize, 'monotonic').evaluate(engine, allIdsInHasse);
94
+ }
95
+
96
+ // Attach threshold (and any other runtime constraints) respecting config flags
97
+ const cfgRes = loadConfig(undefined);
98
+ if (cfgRes.ok) {
99
+ const config = cfgRes.value;
100
+ const knownIds = new Set(Object.keys(flat as Record<string, FlatToken>));
101
+ setupConstraints(engine, { config, bp: breakpoint }, { knownIds });
102
+ const runtimeIssues = engine.evaluate(allIdsInHasse);
103
+ issues.push(...runtimeIssues);
104
+ }
105
+
106
+ const severityRank = { error: 2, warn: 1 } as const;
107
+ const filteredIssues = issues.filter((it) => {
108
+ const level = (it.level as 'warn' | 'error') || 'error';
109
+ return severityRank[level] >= severityRank[minSeverity];
110
+ });
111
+ const edgeViol = new Set<string>();
112
+ const nodeViol = new Set<string>();
113
+ if (labelViolations) edgeLabels = new Map<string, string>();
114
+ for (const it of filteredIssues) {
115
+ if (typeof it.id === 'string' && it.id.includes('|')) {
116
+ const [a, b] = it.id.split('|');
117
+ edgeViol.add(`${a}|${b}`);
118
+ nodeViol.add(a);
119
+ nodeViol.add(b);
120
+ if (labelViolations && edgeLabels) {
121
+ let label = it.message ?? 'violation';
122
+ if (labelTruncate > 0 && label.length > labelTruncate) {
123
+ label = label.slice(0, labelTruncate - 1) + '…';
124
+ }
125
+ edgeLabels.set(`${a}|${b}`, label);
126
+ }
127
+ } else if (typeof it.id === 'string') {
128
+ nodeViol.add(it.id);
129
+ }
130
+ }
131
+ highlight = { nodes: nodeViol, edges: edgeViol, color: violationColor };
132
+ if (onlyViolations) {
133
+ const pruned: Map<string, Set<string>> = new Map();
134
+ for (const [u, vs] of h) {
135
+ for (const v of vs) {
136
+ if (edgeViol.has(`${u}|${v}`) || nodeViol.has(u) || nodeViol.has(v)) {
137
+ if (!pruned.has(u)) pruned.set(u, new Set());
138
+ pruned.get(u)!.add(v);
139
+ if (!pruned.has(v)) pruned.set(v, new Set());
140
+ }
141
+ }
142
+ }
143
+ h = pruned;
144
+ }
145
+ }
146
+ const bpLabel = breakpoint ? ` @${breakpoint}` : '';
147
+ const title = `${name}${suffix ? ' ' + suffix : ''}${bpLabel} (Hasse)`;
148
+ const mermaidContent = toMermaidHasseStyled(h, { title, highlight, labels: edgeLabels });
149
+ const dotContent = toDotHasseStyled(h, { title, highlight, labels: edgeLabels });
150
+ mkdirSync(outDir, { recursive: true });
151
+ if (bundle) {
152
+ const mFile = baseFile.replace(/\.dot$/,'-bundle.mmd').replace(/\.mmd$/,'-bundle.mmd');
153
+ const dFile = baseFile.replace(/\.mmd$/,'-bundle.dot').replace(/\.dot$/,'-bundle.dot');
154
+ writeFileSync(mFile, mermaidContent);
155
+ writeFileSync(dFile, dotContent);
156
+ }
157
+ const baseContent = baseFmt === 'mermaid' ? mermaidContent : dotContent;
158
+ writeFileSync(baseFile, baseContent);
159
+ if (fmt === 'svg' || fmt === 'png') {
160
+ const imgFile = `${outDir}/${name}${suffix}-hasse.${fmt}`;
161
+ const { ok, hint } = exportGraphImage(baseFile, imgFile, fmt, imageFrom);
162
+ if (ok) console.log(`✓ Wrote ${baseFile} and ${imgFile}`); else console.log(`✓ Wrote ${baseFile} (image export skipped). ${hint}`);
163
+ } else {
164
+ const hasViolations = highlight && (highlight.edges.size > 0 || highlight.nodes.size > 0);
165
+ const message = (onlyViolations || highlightViolations) && !hasViolations ? `✓ Wrote ${baseFile} (no violations in slice)` : `✓ Wrote ${baseFile}`;
166
+ console.log(message);
167
+ }
168
+ } catch (error) { console.error(`❌ Error generating Hasse diagram for ${breakpoint}:`, error); process.exit(1); }
169
+ }
170
+ return; }
171
+ for (const breakpoint of plan) {
172
+ const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
173
+ const tokens = loadTokensWithBreakpoint(breakpoint); const { edges } = flattenTokens(tokens);
174
+ let filteredEdges = edges;
175
+ if (options.filter) { const filterRegex = new RegExp(options.filter); filteredEdges = edges.filter(([from,to]) => filterRegex.test(from) || filterRegex.test(to)); }
176
+ const format = options.format || 'json'; const graph = generateDependencyGraph(filteredEdges, format);
177
+ if (options.output) { const bpSuffix = breakpoint ? `.${breakpoint}` : ''; const outputPath = options.output.replace(/(\.[^.]+)$/ , `${bpSuffix}$1`); writeFileSync(outputPath, graph, 'utf8'); console.log(`Dependency graph written to: ${outputPath}`); }
178
+ else { if (breakpoint) console.log(`\n=== ${breakpoint.toUpperCase()} ===`); console.log(graph); }
179
+ }
180
+ }
@@ -1,7 +1,7 @@
1
- export { setCommand } from './set.js';
2
- export { buildCommand } from './build.js';
3
- export { validateCommand } from './validate.js';
4
- export { graphCommand } from './graph.js';
5
- export { whyCommand } from './why.js';
6
- export { patchCommand } from './patch.js';
7
- export { patchApplyCommand } from './patch-apply.js';
1
+ export { setCommand } from './set.js';
2
+ export { buildCommand } from './build.js';
3
+ export { validateCommand } from './validate.js';
4
+ export { graphCommand } from './graph.js';
5
+ export { whyCommand } from './why.js';
6
+ export { patchCommand } from './patch.js';
7
+ export { patchApplyCommand } from './patch-apply.js';