design-constraint-validator 2.1.0 → 2.2.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 (78) hide show
  1. package/README.md +17 -6
  2. package/cli/commands/build.d.ts.map +1 -1
  3. package/cli/commands/build.js +32 -24
  4. package/cli/commands/build.ts +26 -17
  5. package/cli/commands/graph.d.ts.map +1 -1
  6. package/cli/commands/graph.js +33 -16
  7. package/cli/commands/graph.ts +28 -15
  8. package/cli/commands/patch-apply.d.ts.map +1 -1
  9. package/cli/commands/patch-apply.js +4 -1
  10. package/cli/commands/patch-apply.ts +4 -1
  11. package/cli/commands/set.d.ts.map +1 -1
  12. package/cli/commands/set.js +18 -19
  13. package/cli/commands/set.ts +19 -19
  14. package/cli/commands/utils.d.ts +1 -0
  15. package/cli/commands/utils.d.ts.map +1 -1
  16. package/cli/commands/utils.js +20 -1
  17. package/cli/commands/utils.ts +23 -1
  18. package/cli/commands/validate.d.ts.map +1 -1
  19. package/cli/commands/validate.js +5 -17
  20. package/cli/commands/validate.ts +10 -18
  21. package/cli/commands/why.d.ts.map +1 -1
  22. package/cli/commands/why.js +22 -10
  23. package/cli/commands/why.ts +20 -9
  24. package/cli/config-schema.d.ts +144 -178
  25. package/cli/config-schema.d.ts.map +1 -1
  26. package/cli/config-schema.js +25 -5
  27. package/cli/config-schema.ts +27 -5
  28. package/cli/constraint-registry.d.ts.map +1 -1
  29. package/cli/constraint-registry.js +53 -15
  30. package/cli/constraint-registry.ts +53 -18
  31. package/cli/cross-axis-loader.d.ts +62 -0
  32. package/cli/cross-axis-loader.d.ts.map +1 -1
  33. package/cli/cross-axis-loader.js +186 -31
  34. package/cli/cross-axis-loader.ts +199 -24
  35. package/cli/dcv.js +23 -1
  36. package/cli/dcv.ts +23 -1
  37. package/cli/types.d.ts +19 -9
  38. package/cli/types.d.ts.map +1 -1
  39. package/cli/types.ts +23 -10
  40. package/cli/validate-api.d.ts.map +1 -1
  41. package/cli/validate-api.js +6 -1
  42. package/cli/validate-api.ts +6 -1
  43. package/core/constraints/cross-axis.d.ts.map +1 -1
  44. package/core/constraints/cross-axis.js +37 -9
  45. package/core/constraints/cross-axis.ts +37 -9
  46. package/core/constraints/monotonic.d.ts.map +1 -1
  47. package/core/constraints/monotonic.js +32 -8
  48. package/core/constraints/monotonic.ts +29 -8
  49. package/core/constraints/threshold.d.ts.map +1 -1
  50. package/core/constraints/threshold.js +24 -4
  51. package/core/constraints/threshold.ts +23 -4
  52. package/core/constraints/wcag.js +1 -1
  53. package/core/constraints/wcag.ts +1 -1
  54. package/core/flatten.d.ts.map +1 -1
  55. package/core/flatten.js +8 -0
  56. package/core/flatten.ts +9 -0
  57. package/core/poset.d.ts +6 -1
  58. package/core/poset.d.ts.map +1 -1
  59. package/core/poset.js +7 -2
  60. package/core/poset.ts +7 -2
  61. package/mcp/contracts.d.ts +1456 -13
  62. package/mcp/contracts.d.ts.map +1 -1
  63. package/mcp/contracts.js +45 -1
  64. package/mcp/contracts.ts +55 -1
  65. package/mcp/index.d.ts +6 -4
  66. package/mcp/index.d.ts.map +1 -1
  67. package/mcp/index.js +6 -3
  68. package/mcp/index.ts +28 -1
  69. package/mcp/insights.d.ts +94 -0
  70. package/mcp/insights.d.ts.map +1 -0
  71. package/mcp/insights.js +445 -0
  72. package/mcp/insights.ts +541 -0
  73. package/mcp/tools.d.ts +14 -3
  74. package/mcp/tools.d.ts.map +1 -1
  75. package/mcp/tools.js +133 -6
  76. package/mcp/tools.ts +188 -11
  77. package/package.json +1 -6
  78. package/server.json +2 -2
package/README.md CHANGED
@@ -26,10 +26,12 @@ This is **not** a schema linter; it's a **reasoning validator** for values and r
26
26
  # Local (recommended)
27
27
  npm i -D design-constraint-validator
28
28
 
29
- # One-off run
30
- npx dcv --help
29
+ # One-off run, no install (the bin name `dcv` belongs to an unrelated package)
30
+ npx design-constraint-validator --help
31
31
  ```
32
32
 
33
+ After a local install, the shorter `dcv` bin is available (e.g. `npx dcv --help`).
34
+
33
35
  **Requirements:** Node.js ≥ 18.x (ESM)
34
36
 
35
37
  ---
@@ -63,15 +65,19 @@ cat > dcv.config.json <<'JSON'
63
65
  JSON
64
66
 
65
67
  # 3. Validate (positional path or --tokens; exits non-zero on violations)
66
- npx dcv validate tokens.json --summary table
68
+ npx design-constraint-validator validate tokens.json --summary table
67
69
 
68
70
  # Explain one token (the tokenId is required)
69
- npx dcv why color.text --tokens tokens.json --format table
71
+ npx design-constraint-validator why color.text --tokens tokens.json --format table
70
72
 
71
73
  # Export the dependency graph
72
- npx dcv graph --tokens tokens.json --format mermaid > graph.mmd
74
+ npx design-constraint-validator graph --tokens tokens.json --format mermaid > graph.mmd
73
75
  ```
74
76
 
77
+ > These one-offs use the full package name because the bare `dcv` bin name on npm
78
+ > belongs to an unrelated package. After `npm i -D design-constraint-validator`,
79
+ > use the shorter `npx dcv …`.
80
+
75
81
  **Example output** (`validate`):
76
82
 
77
83
  ```text
@@ -124,11 +130,16 @@ DCV ships a second binary, `dcv-mcp`, that exposes the validator over MCP stdio
124
130
  }
125
131
  ```
126
132
 
127
- The server exposes exactly three JSON-returning tools:
133
+ The server exposes six read-only, JSON-returning tools:
128
134
 
129
135
  - `validate` - validate inline `tokens` or a `tokensPath` against inline `constraints` or a config file.
130
136
  - `why` - explain provenance, aliases, dependencies, dependents, and alias chain for one token id.
131
137
  - `graph` - return token dependency `nodes` and `edges`.
138
+ - `list-constraints` - enumerate the active constraints (WCAG pairs, thresholds, order/lightness scales, cross-axis) for the given input.
139
+ - `explain` - turn a violation into plain-English text plus machine-readable facts.
140
+ - `suggest-fix` - compute a verified satisfying value for a violation (WCAG color, threshold/monotonic boundary) without writing anything.
141
+
142
+ The three derivation tools (`list-constraints`, `explain`, `suggest-fix`) stay read-only — they return suggestions; applying them is up to you (`dcv set` / `dcv patch`). See **[AI Guide](docs/AI-GUIDE.md)** for the full agent loop.
132
143
 
133
144
  Tool failures are returned as structured JSON: `{ "ok": false, "error": { "code": "...", "message": "..." } }`.
134
145
 
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["build.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG;IAAE,CAAC,CAAC,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAwD9F"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["build.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGhD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG;IAAE,CAAC,CAAC,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA+D9F"}
@@ -1,21 +1,19 @@
1
- import { join, dirname, resolve } from 'node:path';
1
+ import { dirname, resolve } from 'node:path';
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { flattenTokens } from '../../core/flatten.js';
4
+ import { mergeTokens } from '../../core/breakpoints.js';
4
5
  import { valuesToCss } from '../../adapters/css.js';
5
6
  import { emitJSON } from '../../adapters/json.js';
6
7
  import { emitJS } from '../../adapters/js.js';
8
+ import { loadThemeTokens } from './utils.js';
7
9
  export async function buildCommand(options) {
8
10
  const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
9
- const tokens = loadTokensWithBreakpoint(undefined, options.tokens);
10
- const { flat } = flattenTokens(tokens);
11
- let allValues = Object.fromEntries(Object.values(flat).map(t => [t.id, t.value]));
11
+ let tokens = loadTokensWithBreakpoint(undefined, options.tokens);
12
12
  if (options.theme) {
13
- const themePath = join('tokens/themes', `${options.theme}.json`);
14
- if (existsSync(themePath)) {
15
- const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
16
- Object.assign(allValues, themeTokens);
17
- }
13
+ tokens = mergeTokens(tokens, loadThemeTokens(options.theme));
18
14
  }
15
+ const { flat } = flattenTokens(tokens);
16
+ const allValues = Object.fromEntries(Object.values(flat).map(t => [t.id, t.value]));
19
17
  const format = options.format || 'css';
20
18
  const defaultOutput = `dist/tokens.${format}`;
21
19
  let manifest;
@@ -34,21 +32,31 @@ export async function buildCommand(options) {
34
32
  process.exit(1);
35
33
  }
36
34
  }
37
- const allFormats = options.allFormats ?? options['all-formats'];
35
+ // Read both the kebab key (CLI, camel-case-expansion is off) and the camelCase
36
+ // key (programmatic callers, e.g. tests). Reading only one form silently no-ops
37
+ // for the other caller — that was the TASK-024 bug class.
38
+ const allFormats = options['all-formats'] ?? options.allFormats;
39
+ const dryRun = options['dry-run'] ?? options.dryRun;
38
40
  if (allFormats) {
41
+ const css = valuesToCss(allValues, { manifest });
42
+ const json = emitJSON(allValues, manifest);
43
+ const js = emitJS(allValues, manifest);
44
+ if (dryRun) {
45
+ // Dry run: print every format, write nothing.
46
+ console.log(css);
47
+ console.log(json);
48
+ console.log(js);
49
+ return;
50
+ }
39
51
  const dir = 'dist';
40
52
  if (!existsSync(dir))
41
53
  mkdirSync(dir, { recursive: true });
42
- const css = valuesToCss(allValues, { manifest });
43
54
  writeFileSync('dist/tokens.css', css, 'utf8');
44
- if (options.dryRun)
45
- console.log(css);
46
- writeFileSync('dist/tokens.json', emitJSON(allValues, manifest), 'utf8');
47
- writeFileSync('dist/tokens.js', emitJS(allValues, manifest), 'utf8');
55
+ writeFileSync('dist/tokens.json', json, 'utf8');
56
+ writeFileSync('dist/tokens.js', js, 'utf8');
48
57
  console.log(`Tokens written (all formats) to dist/ (css/json/js)${manifest ? ' with mapper' : ''}`);
49
58
  return;
50
59
  }
51
- const dryRun = options.dryRun ?? options['dry-run'];
52
60
  if (format === 'css') {
53
61
  const css = valuesToCss(allValues, { manifest });
54
62
  if (dryRun) {
@@ -63,26 +71,26 @@ export async function buildCommand(options) {
63
71
  console.log(`CSS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
64
72
  }
65
73
  else if (format === 'json') {
66
- const outPath = options.output || defaultOutput;
67
- const dir = dirname(outPath);
68
- if (!existsSync(dir))
69
- mkdirSync(dir, { recursive: true });
70
74
  if (dryRun) {
71
75
  console.log(emitJSON(allValues, manifest));
72
76
  return;
73
77
  }
74
- writeFileSync(outPath, emitJSON(allValues, manifest), 'utf8');
75
- console.log(`JSON tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
76
- }
77
- else if (format === 'js') {
78
78
  const outPath = options.output || defaultOutput;
79
79
  const dir = dirname(outPath);
80
80
  if (!existsSync(dir))
81
81
  mkdirSync(dir, { recursive: true });
82
+ writeFileSync(outPath, emitJSON(allValues, manifest), 'utf8');
83
+ console.log(`JSON tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
84
+ }
85
+ else if (format === 'js') {
82
86
  if (dryRun) {
83
87
  console.log(emitJS(allValues, manifest));
84
88
  return;
85
89
  }
90
+ const outPath = options.output || defaultOutput;
91
+ const dir = dirname(outPath);
92
+ if (!existsSync(dir))
93
+ mkdirSync(dir, { recursive: true });
86
94
  writeFileSync(outPath, emitJS(allValues, manifest), 'utf8');
87
95
  console.log(`JS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
88
96
  }
@@ -1,23 +1,21 @@
1
- import { join, dirname, resolve } from 'node:path';
1
+ import { dirname, resolve } from 'node:path';
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
+ import { mergeTokens } from '../../core/breakpoints.js';
4
5
  import { valuesToCss, type ManifestRow } from '../../adapters/css.js';
5
6
  import { emitJSON } from '../../adapters/json.js';
6
7
  import { emitJS } from '../../adapters/js.js';
7
8
  import type { BuildOptions } from '../types.js';
9
+ import { loadThemeTokens } from './utils.js';
8
10
 
9
11
  export async function buildCommand(options: BuildOptions & { [k: string]: any }): Promise<void> {
10
12
  const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
11
- const tokens = loadTokensWithBreakpoint(undefined, options.tokens);
12
- const { flat } = flattenTokens(tokens);
13
- let allValues = Object.fromEntries(Object.values(flat).map(t => [t.id, (t as FlatToken).value]));
13
+ let tokens = loadTokensWithBreakpoint(undefined, options.tokens);
14
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
- }
15
+ tokens = mergeTokens(tokens, loadThemeTokens(options.theme));
20
16
  }
17
+ const { flat } = flattenTokens(tokens);
18
+ const allValues = Object.fromEntries(Object.values(flat).map(t => [t.id, (t as FlatToken).value]));
21
19
  const format = options.format || 'css';
22
20
  const defaultOutput = `dist/tokens.${format}`;
23
21
  let manifest: ManifestRow[] | undefined;
@@ -33,18 +31,29 @@ export async function buildCommand(options: BuildOptions & { [k: string]: any })
33
31
  process.exit(1);
34
32
  }
35
33
  }
36
- const allFormats = options.allFormats ?? options['all-formats'];
34
+ // Read both the kebab key (CLI, camel-case-expansion is off) and the camelCase
35
+ // key (programmatic callers, e.g. tests). Reading only one form silently no-ops
36
+ // for the other caller — that was the TASK-024 bug class.
37
+ const allFormats = options['all-formats'] ?? options.allFormats;
38
+ const dryRun = options['dry-run'] ?? options.dryRun;
37
39
  if (allFormats) {
38
- const dir = 'dist'; if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
40
  const css = valuesToCss(allValues, { manifest });
41
+ const json = emitJSON(allValues, manifest);
42
+ const js = emitJS(allValues, manifest);
43
+ if (dryRun) {
44
+ // Dry run: print every format, write nothing.
45
+ console.log(css);
46
+ console.log(json);
47
+ console.log(js);
48
+ return;
49
+ }
50
+ const dir = 'dist'; if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
51
  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');
52
+ writeFileSync('dist/tokens.json', json, 'utf8');
53
+ writeFileSync('dist/tokens.js', js, 'utf8');
44
54
  console.log(`Tokens written (all formats) to dist/ (css/json/js)${manifest ? ' with mapper' : ''}`);
45
55
  return;
46
56
  }
47
- const dryRun = options.dryRun ?? options['dry-run'];
48
57
  if (format === 'css') {
49
58
  const css = valuesToCss(allValues, { manifest });
50
59
  if (dryRun) { console.log(css); return; }
@@ -52,13 +61,13 @@ export async function buildCommand(options: BuildOptions & { [k: string]: any })
52
61
  writeFileSync(outPath, css, 'utf8');
53
62
  console.log(`CSS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
54
63
  } else if (format === 'json') {
64
+ if (dryRun) { console.log(emitJSON(allValues, manifest)); return; }
55
65
  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
66
  writeFileSync(outPath, emitJSON(allValues, manifest), 'utf8');
58
67
  console.log(`JSON tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
59
68
  } else if (format === 'js') {
69
+ if (dryRun) { console.log(emitJS(allValues, manifest)); return; }
60
70
  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
71
  writeFileSync(outPath, emitJS(allValues, manifest), 'utf8');
63
72
  console.log(`JS tokens written to ${outPath}${manifest ? ' (manifest mapper applied)' : ''}`);
64
73
  }
@@ -1 +1 @@
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
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["graph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAiKvE"}
@@ -16,11 +16,14 @@ function generateDependencyGraph(edges, format) {
16
16
  return dot + '}\n';
17
17
  }
18
18
  case 'mermaid': {
19
+ // Injective id encoding so distinct token ids (e.g. a.b vs a_b) never
20
+ // collapse to the same Mermaid node (TASK-027).
21
+ const safe = (s) => s.replace(/[^a-zA-Z0-9]/g, (c) => `_${c.charCodeAt(0)}_`);
19
22
  let mermaid = 'graph LR\n';
20
23
  const mermaidNodes = new Set();
21
24
  edges.forEach(([from, to]) => {
22
- const fromId = from.replace(/[^a-zA-Z0-9]/g, '_');
23
- const toId = to.replace(/[^a-zA-Z0-9]/g, '_');
25
+ const fromId = safe(from);
26
+ const toId = safe(to);
24
27
  if (!mermaidNodes.has(fromId)) {
25
28
  mermaid += ` ${fromId}["${from}"]\n`;
26
29
  mermaidNodes.add(fromId);
@@ -44,16 +47,21 @@ export async function graphCommand(options) {
44
47
  if (options.hasse) {
45
48
  const name = options.hasse;
46
49
  const bundle = options.bundle;
50
+ const constraintsDir = options['constraints-dir'] ?? 'themes';
47
51
  const fmt = (options.format === 'json' ? 'mermaid' : options.format);
48
- const imageFrom = options.imageFrom || 'mermaid';
49
- const filterPrefixes = options.filterPrefix ? options.filterPrefix.split(',').map(s => s.trim()).filter(Boolean) : [];
50
- const excludePrefixes = options.excludePrefix ? options.excludePrefix.split(',').map(s => s.trim()).filter(Boolean) : [];
51
- const onlyViolations = options.onlyViolations || false;
52
- const highlightViolations = options.highlightViolations || false;
53
- const violationColor = options.violationColor || '#ff2d55';
54
- const labelViolations = options.labelViolations || false;
55
- const labelTruncate = Math.max(0, options.labelTruncate || 0);
56
- const minSeverity = options.minSeverity || 'warn';
52
+ const imageFrom = options['image-from'] || 'mermaid';
53
+ // The CLI runs with camel-case-expansion off, so kebab flags arrive only under
54
+ // their kebab keys. Read those (camelCase reads here silently no-op'd before).
55
+ const filterPrefix = options['filter-prefix'];
56
+ const excludePrefix = options['exclude-prefix'];
57
+ const filterPrefixes = filterPrefix ? filterPrefix.split(',').map(s => s.trim()).filter(Boolean) : [];
58
+ const excludePrefixes = excludePrefix ? excludePrefix.split(',').map(s => s.trim()).filter(Boolean) : [];
59
+ const onlyViolations = options['only-violations'] || false;
60
+ const highlightViolations = options['highlight-violations'] || false;
61
+ const violationColor = options['violation-color'] || '#ff2d55';
62
+ const labelViolations = options['label-violations'] || false;
63
+ const labelTruncate = Math.max(0, options['label-truncate'] || 0);
64
+ const minSeverity = options['min-severity'] || 'warn';
57
65
  const focus = options.focus;
58
66
  const radius = Math.max(0, options.radius || 1);
59
67
  for (const breakpoint of plan) {
@@ -78,7 +86,7 @@ export async function graphCommand(options) {
78
86
  const outDir = 'dist/graphs';
79
87
  const baseFile = `${outDir}/${name}${suffix}-hasse.${ext}`;
80
88
  try {
81
- const src = `themes/${name}.order.json`;
89
+ const src = `${constraintsDir}/${name}.order.json`;
82
90
  if (!existsSync(src)) {
83
91
  console.error(`❌ Order constraint file not found: ${src}`);
84
92
  process.exit(1);
@@ -122,12 +130,21 @@ export async function graphCommand(options) {
122
130
  const numericOrders = order;
123
131
  issues = MonotonicPlugin(numericOrders, parseSize, 'monotonic').evaluate(engine, allIdsInHasse);
124
132
  }
125
- // Attach threshold (and any other runtime constraints) respecting config flags
126
- const cfgRes = loadConfig(undefined);
127
- if (cfgRes.ok) {
133
+ // Attach threshold (and any other runtime constraints) respecting config flags.
134
+ // Honor the global --config and --constraints-dir, matching `validate`.
135
+ const cfgRes = loadConfig(options.config);
136
+ if (!cfgRes.ok) {
137
+ // An explicitly requested config that fails is a hard error (parity with
138
+ // validate); otherwise proceed with order-file violations only.
139
+ if (options.config) {
140
+ console.error(cfgRes.error);
141
+ process.exit(2);
142
+ }
143
+ }
144
+ else {
128
145
  const config = cfgRes.value;
129
146
  const knownIds = new Set(Object.keys(flat));
130
- setupConstraints(engine, { config, bp: breakpoint }, { knownIds });
147
+ setupConstraints(engine, { config, bp: breakpoint, constraintsDir }, { knownIds });
131
148
  const runtimeIssues = engine.evaluate(allIdsInHasse);
132
149
  issues.push(...runtimeIssues);
133
150
  }
@@ -15,8 +15,11 @@ function generateDependencyGraph(edges: Array<[string, string]>, format: string)
15
15
  dot += '\n'; edges.forEach(([f,t]) => { dot += ` "${f}" -> "${t}";\n`; });
16
16
  return dot + '}\n'; }
17
17
  case 'mermaid': {
18
+ // Injective id encoding so distinct token ids (e.g. a.b vs a_b) never
19
+ // collapse to the same Mermaid node (TASK-027).
20
+ const safe = (s: string) => s.replace(/[^a-zA-Z0-9]/g, (c) => `_${c.charCodeAt(0)}_`);
18
21
  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,'_');
22
+ edges.forEach(([from,to]) => { const fromId = safe(from); const toId = safe(to);
20
23
  if (!mermaidNodes.has(fromId)) { mermaid += ` ${fromId}["${from}"]\n`; mermaidNodes.add(fromId); }
21
24
  if (!mermaidNodes.has(toId)) { mermaid += ` ${toId}["${to}"]\n`; mermaidNodes.add(toId); }
22
25
  mermaid += ` ${fromId} --> ${toId}\n`; });
@@ -33,16 +36,21 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
33
36
  if (options.hasse) {
34
37
  const name = options.hasse;
35
38
  const bundle = (options as any).bundle;
39
+ const constraintsDir = options['constraints-dir'] ?? 'themes';
36
40
  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';
41
+ const imageFrom = options['image-from'] || 'mermaid';
42
+ // The CLI runs with camel-case-expansion off, so kebab flags arrive only under
43
+ // their kebab keys. Read those (camelCase reads here silently no-op'd before).
44
+ const filterPrefix = options['filter-prefix'];
45
+ const excludePrefix = options['exclude-prefix'];
46
+ const filterPrefixes = filterPrefix ? filterPrefix.split(',').map(s=>s.trim()).filter(Boolean) : [];
47
+ const excludePrefixes = excludePrefix ? excludePrefix.split(',').map(s=>s.trim()).filter(Boolean) : [];
48
+ const onlyViolations = options['only-violations'] || false;
49
+ const highlightViolations = options['highlight-violations'] || false;
50
+ const violationColor = options['violation-color'] || '#ff2d55';
51
+ const labelViolations = options['label-violations'] || false;
52
+ const labelTruncate = Math.max(0, options['label-truncate'] || 0);
53
+ const minSeverity = options['min-severity'] || 'warn';
46
54
  const focus = options.focus; const radius = Math.max(0, options.radius || 1);
47
55
  for (const breakpoint of plan) {
48
56
  const suffixParts: string[] = [];
@@ -57,7 +65,7 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
57
65
  const ext = baseFmt === 'mermaid' ? 'mmd' : 'dot';
58
66
  const outDir = 'dist/graphs'; const baseFile = `${outDir}/${name}${suffix}-hasse.${ext}`;
59
67
  try {
60
- const src = `themes/${name}.order.json`;
68
+ const src = `${constraintsDir}/${name}.order.json`;
61
69
  if (!existsSync(src)) { console.error(`❌ Order constraint file not found: ${src}`); process.exit(1); }
62
70
  const { order } = JSON.parse(readFileSync(src, 'utf8'));
63
71
  const { buildPoset, transitiveReduction, toMermaidHasseStyled, toDotHasseStyled, filterByPrefix, filterExcludePrefix, khopSubgraph, pickSeedsByPattern } = await import('../../core/poset.js');
@@ -93,12 +101,17 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
93
101
  issues = MonotonicPlugin(numericOrders, parseSize, 'monotonic').evaluate(engine, allIdsInHasse);
94
102
  }
95
103
 
96
- // Attach threshold (and any other runtime constraints) respecting config flags
97
- const cfgRes = loadConfig(undefined);
98
- if (cfgRes.ok) {
104
+ // Attach threshold (and any other runtime constraints) respecting config flags.
105
+ // Honor the global --config and --constraints-dir, matching `validate`.
106
+ const cfgRes = loadConfig(options.config);
107
+ if (!cfgRes.ok) {
108
+ // An explicitly requested config that fails is a hard error (parity with
109
+ // validate); otherwise proceed with order-file violations only.
110
+ if (options.config) { console.error(cfgRes.error); process.exit(2); }
111
+ } else {
99
112
  const config = cfgRes.value;
100
113
  const knownIds = new Set(Object.keys(flat as Record<string, FlatToken>));
101
- setupConstraints(engine, { config, bp: breakpoint }, { knownIds });
114
+ setupConstraints(engine, { config, bp: breakpoint, constraintsDir }, { knownIds });
102
115
  const runtimeIssues = engine.evaluate(allIdsInHasse);
103
116
  issues.push(...runtimeIssues);
104
117
  }
@@ -1 +1 @@
1
- {"version":3,"file":"patch-apply.d.ts","sourceRoot":"","sources":["patch-apply.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAkCrD,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4C9E"}
1
+ {"version":3,"file":"patch-apply.d.ts","sourceRoot":"","sources":["patch-apply.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAkCrD,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+C9E"}
@@ -60,7 +60,10 @@ export async function patchApplyCommand(opts) {
60
60
  for (const c of patchDoc.changes) {
61
61
  applyChange(tokens, c.id, c.to, c.type);
62
62
  }
63
- if (opts.dryRun) {
63
+ // Read both forms: kebab from the CLI (camel-case-expansion off) and camelCase
64
+ // from programmatic callers. Reading only opts.dryRun was dead for the CLI, so
65
+ // `--dry-run --output` silently wrote the file (TASK-024).
66
+ if (opts['dry-run'] ?? opts.dryRun) {
64
67
  outputResult(tokens, 'json');
65
68
  return;
66
69
  }
@@ -66,7 +66,10 @@ export async function patchApplyCommand(opts: PatchApplyOptions): Promise<void>
66
66
  applyChange(tokens, c.id, c.to, c.type);
67
67
  }
68
68
 
69
- if (opts.dryRun) {
69
+ // Read both forms: kebab from the CLI (camel-case-expansion off) and camelCase
70
+ // from programmatic callers. Reading only opts.dryRun was dead for the CLI, so
71
+ // `--dry-run --output` silently wrote the file (TASK-024).
72
+ if (opts['dry-run'] ?? opts.dryRun) {
70
73
  outputResult(tokens, 'json');
71
74
  return;
72
75
  }
@@ -1 +1 @@
1
- {"version":3,"file":"set.d.ts","sourceRoot":"","sources":["set.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAiB,UAAU,EAAe,MAAM,aAAa,CAAC;AAmE1E,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsKnE"}
1
+ {"version":3,"file":"set.d.ts","sourceRoot":"","sources":["set.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAiB,UAAU,EAAe,MAAM,aAAa,CAAC;AAmE1E,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAuKnE"}
@@ -1,9 +1,8 @@
1
- import { join } from 'node:path';
2
- import { readFileSync, existsSync } from 'node:fs';
3
1
  import { loadConfig } from '../config.js';
4
2
  import { Engine } from '../../core/engine.js';
5
3
  import { flattenTokens } from '../../core/flatten.js';
6
- import { loadTokens, outputResult } from './utils.js';
4
+ import { mergeTokens } from '../../core/breakpoints.js';
5
+ import { loadThemeTokens, loadTokens, outputResult } from './utils.js';
7
6
  import { setupConstraints } from '../constraint-registry.js';
8
7
  // Lightweight suggestion helpers (kept local – why command uses core formatter instead)
9
8
  function levenshtein(a, b) {
@@ -81,7 +80,10 @@ export async function setCommand(options) {
81
80
  process.exit(2);
82
81
  }
83
82
  const config = cfgRes.value;
84
- const tokens = loadTokens(tokensPath);
83
+ let tokens = loadTokens(tokensPath);
84
+ if (options.theme) {
85
+ tokens = mergeTokens(tokens, loadThemeTokens(options.theme));
86
+ }
85
87
  // Create engine with flattened tokens
86
88
  const { flat, edges } = flattenTokens(tokens);
87
89
  const init = {};
@@ -103,18 +105,6 @@ export async function setCommand(options) {
103
105
  process.exit(1);
104
106
  }
105
107
  }
106
- if (options.theme) {
107
- const themePath = join('tokens/themes', `${options.theme}.json`);
108
- if (existsSync(themePath)) {
109
- const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
110
- for (const [id, value] of Object.entries(themeTokens)) {
111
- engine.commit(id, value);
112
- }
113
- }
114
- else {
115
- console.warn(`Theme file not found: ${themePath}`);
116
- }
117
- }
118
108
  let finalResult = {};
119
109
  function setDeep(obj, parts, v) {
120
110
  let cur = obj;
@@ -210,7 +200,7 @@ export async function setCommand(options) {
210
200
  for (const e of entries)
211
201
  console.log(' ', e);
212
202
  }
213
- const dryRun = process.argv.includes('--dry-run');
203
+ const dryRun = !!(options['dry-run'] ?? options.dryRun);
214
204
  for (const { id, value, unset } of entries) {
215
205
  if (unset) {
216
206
  if (!options.quiet)
@@ -227,7 +217,8 @@ export async function setCommand(options) {
227
217
  }
228
218
  }
229
219
  const format = options.format || 'json';
230
- outputResult(finalResult, format, options.output);
220
+ // Dry-run prints the patch to stdout instead of writing --output (no file writes).
221
+ outputResult(finalResult, format, dryRun ? undefined : options.output);
231
222
  if (options.write && !dryRun) {
232
223
  const path = 'tokens/overrides/local.json';
233
224
  let local = {};
@@ -264,9 +255,17 @@ export async function setCommand(options) {
264
255
  }
265
256
  Object.assign(finalResult, result.patch);
266
257
  }
258
+ const dryRun = !!(options['dry-run'] ?? options.dryRun);
267
259
  const format = options.format || 'json';
268
- outputResult(finalResult, format, options.output);
260
+ // Dry-run prints the patch to stdout instead of writing --output (no file writes).
261
+ outputResult(finalResult, format, dryRun ? undefined : options.output);
269
262
  if (options.write || (options.unset && options.unset.length)) {
263
+ // --dry-run must not touch the filesystem (it previously persisted the
264
+ // override file on the positional path; the batch path already guarded it).
265
+ if (dryRun) {
266
+ console.log('Dry-run: changes not written');
267
+ return;
268
+ }
270
269
  const fs = await import('node:fs');
271
270
  const path = 'tokens/overrides/local.json';
272
271
  let local = {};
@@ -1,10 +1,9 @@
1
- import { join } from 'node:path';
2
- import { readFileSync, existsSync } from 'node:fs';
3
1
  import { loadConfig } from '../config.js';
4
2
  import { Engine } from '../../core/engine.js';
5
3
  import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
+ import { mergeTokens } from '../../core/breakpoints.js';
6
5
  import type { OverridesTree, SetOptions, ValuesPatch } from '../types.js';
7
- import { loadTokens, outputResult } from './utils.js';
6
+ import { loadThemeTokens, loadTokens, outputResult } from './utils.js';
8
7
  import { setupConstraints } from '../constraint-registry.js';
9
8
 
10
9
  // Lightweight suggestion helpers (kept local – why command uses core formatter instead)
@@ -76,7 +75,11 @@ export async function setCommand(options: SetOptions): Promise<void> {
76
75
  const cfgRes = loadConfig(options.config);
77
76
  if (!cfgRes.ok) { console.error(cfgRes.error); process.exit(2); }
78
77
  const config = cfgRes.value;
79
- const tokens = loadTokens(tokensPath);
78
+ let tokens = loadTokens(tokensPath);
79
+
80
+ if (options.theme) {
81
+ tokens = mergeTokens(tokens, loadThemeTokens(options.theme));
82
+ }
80
83
 
81
84
  // Create engine with flattened tokens
82
85
  const { flat, edges } = flattenTokens(tokens);
@@ -105,18 +108,6 @@ export async function setCommand(options: SetOptions): Promise<void> {
105
108
  }
106
109
  }
107
110
 
108
- if (options.theme) {
109
- const themePath = join('tokens/themes', `${options.theme}.json`);
110
- if (existsSync(themePath)) {
111
- const themeTokens = JSON.parse(readFileSync(themePath, 'utf8'));
112
- for (const [id, value] of Object.entries(themeTokens)) {
113
- engine.commit(id, value as string | number);
114
- }
115
- } else {
116
- console.warn(`Theme file not found: ${themePath}`);
117
- }
118
- }
119
-
120
111
  let finalResult: ValuesPatch = {};
121
112
 
122
113
  function setDeep(obj: OverridesTree, parts: string[], v: unknown) {
@@ -183,7 +174,7 @@ export async function setCommand(options: SetOptions): Promise<void> {
183
174
  for (const ent of entries) ensureKnownOrSuggest(ent.id);
184
175
  const debug = process.argv.includes('--debug-set') || process.env.DCV_DEBUG_SET === '1';
185
176
  if (debug) { console.log('[set:batch] parsed entries:'); for (const e of entries) console.log(' ', e); }
186
- const dryRun = process.argv.includes('--dry-run');
177
+ const dryRun = !!(options['dry-run'] ?? options.dryRun);
187
178
  for (const { id, value, unset } of entries) {
188
179
  if (unset) { if (!options.quiet) console.log(`preview: unset ${id}`); }
189
180
  else {
@@ -193,7 +184,8 @@ export async function setCommand(options: SetOptions): Promise<void> {
193
184
  }
194
185
  }
195
186
  const format = options.format || 'json';
196
- outputResult(finalResult, format, options.output);
187
+ // Dry-run prints the patch to stdout instead of writing --output (no file writes).
188
+ outputResult(finalResult, format, dryRun ? undefined : options.output);
197
189
  if (options.write && !dryRun) {
198
190
  const path = 'tokens/overrides/local.json';
199
191
  let local: OverridesTree = {} as OverridesTree;
@@ -218,9 +210,17 @@ export async function setCommand(options: SetOptions): Promise<void> {
218
210
  }
219
211
  Object.assign(finalResult, result.patch);
220
212
  }
213
+ const dryRun = !!(options['dry-run'] ?? options.dryRun);
221
214
  const format = options.format || 'json';
222
- outputResult(finalResult, format, options.output);
215
+ // Dry-run prints the patch to stdout instead of writing --output (no file writes).
216
+ outputResult(finalResult, format, dryRun ? undefined : options.output);
223
217
  if (options.write || (options.unset && options.unset.length)) {
218
+ // --dry-run must not touch the filesystem (it previously persisted the
219
+ // override file on the positional path; the batch path already guarded it).
220
+ if (dryRun) {
221
+ console.log('Dry-run: changes not written');
222
+ return;
223
+ }
224
224
  const fs = await import('node:fs');
225
225
  const path = 'tokens/overrides/local.json';
226
226
  let local: OverridesTree = {} as OverridesTree;
@@ -1,4 +1,5 @@
1
1
  import type { TokenNode } from '../../core/flatten.js';
2
2
  export declare function loadTokens(tokensPath: string): TokenNode;
3
+ export declare function loadThemeTokens(theme: string): TokenNode;
3
4
  export declare function outputResult(data: unknown, format: string, outputPath?: string): void;
4
5
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,uBAAuB,CAAC;AAInE,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAUxD;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CA8BrF"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,uBAAuB,CAAC;AAInE,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAUxD;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAoBxD;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CA8BrF"}