design-constraint-validator 2.0.1 → 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 (118) hide show
  1. package/README.md +89 -23
  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 +35 -18
  7. package/cli/commands/graph.ts +30 -17
  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 +45 -23
  20. package/cli/commands/validate.ts +47 -26
  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 +171 -166
  25. package/cli/config-schema.d.ts.map +1 -1
  26. package/cli/config-schema.js +29 -7
  27. package/cli/config-schema.ts +31 -7
  28. package/cli/config.d.ts.map +1 -1
  29. package/cli/config.js +8 -2
  30. package/cli/config.ts +8 -2
  31. package/cli/constraint-registry.d.ts +16 -0
  32. package/cli/constraint-registry.d.ts.map +1 -1
  33. package/cli/constraint-registry.js +115 -44
  34. package/cli/constraint-registry.ts +118 -47
  35. package/cli/cross-axis-loader.d.ts +62 -0
  36. package/cli/cross-axis-loader.d.ts.map +1 -1
  37. package/cli/cross-axis-loader.js +186 -31
  38. package/cli/cross-axis-loader.ts +199 -24
  39. package/cli/dcv.js +31 -25
  40. package/cli/dcv.ts +31 -21
  41. package/cli/json-output.d.ts +3 -1
  42. package/cli/json-output.d.ts.map +1 -1
  43. package/cli/json-output.js +11 -4
  44. package/cli/json-output.ts +13 -4
  45. package/cli/types.d.ts +21 -9
  46. package/cli/types.d.ts.map +1 -1
  47. package/cli/types.ts +25 -10
  48. package/cli/validate-api.d.ts +40 -0
  49. package/cli/validate-api.d.ts.map +1 -0
  50. package/cli/validate-api.js +90 -0
  51. package/cli/validate-api.ts +131 -0
  52. package/core/breakpoints.d.ts +8 -2
  53. package/core/breakpoints.d.ts.map +1 -1
  54. package/core/breakpoints.js +24 -3
  55. package/core/breakpoints.ts +22 -3
  56. package/core/color.js +4 -4
  57. package/core/color.ts +4 -4
  58. package/core/constraints/cross-axis.d.ts.map +1 -1
  59. package/core/constraints/cross-axis.js +37 -9
  60. package/core/constraints/cross-axis.ts +37 -9
  61. package/core/constraints/monotonic-lightness.d.ts.map +1 -1
  62. package/core/constraints/monotonic-lightness.js +9 -5
  63. package/core/constraints/monotonic-lightness.ts +9 -4
  64. package/core/constraints/monotonic.d.ts.map +1 -1
  65. package/core/constraints/monotonic.js +32 -8
  66. package/core/constraints/monotonic.ts +29 -8
  67. package/core/constraints/threshold.d.ts.map +1 -1
  68. package/core/constraints/threshold.js +24 -4
  69. package/core/constraints/threshold.ts +23 -4
  70. package/core/constraints/wcag.d.ts.map +1 -1
  71. package/core/constraints/wcag.js +7 -1
  72. package/core/constraints/wcag.ts +7 -1
  73. package/core/dtcg.d.ts +38 -0
  74. package/core/dtcg.d.ts.map +1 -0
  75. package/core/dtcg.js +88 -0
  76. package/core/dtcg.ts +102 -0
  77. package/core/engine.d.ts +6 -0
  78. package/core/engine.d.ts.map +1 -1
  79. package/core/engine.ts +7 -0
  80. package/core/flatten.d.ts +5 -3
  81. package/core/flatten.d.ts.map +1 -1
  82. package/core/flatten.js +32 -10
  83. package/core/flatten.ts +48 -16
  84. package/core/image-export.d.ts.map +1 -1
  85. package/core/image-export.js +10 -7
  86. package/core/image-export.ts +9 -6
  87. package/core/index.d.ts +2 -0
  88. package/core/index.d.ts.map +1 -1
  89. package/core/index.js +4 -0
  90. package/core/index.ts +6 -0
  91. package/core/poset.d.ts +6 -1
  92. package/core/poset.d.ts.map +1 -1
  93. package/core/poset.js +7 -2
  94. package/core/poset.ts +7 -2
  95. package/core/why.d.ts +1 -1
  96. package/core/why.d.ts.map +1 -1
  97. package/core/why.ts +1 -1
  98. package/mcp/contracts.d.ts +1561 -0
  99. package/mcp/contracts.d.ts.map +1 -0
  100. package/mcp/contracts.js +74 -0
  101. package/mcp/contracts.ts +105 -0
  102. package/mcp/index.d.ts +11 -0
  103. package/mcp/index.d.ts.map +1 -0
  104. package/mcp/index.js +35 -0
  105. package/mcp/index.ts +97 -0
  106. package/mcp/insights.d.ts +94 -0
  107. package/mcp/insights.d.ts.map +1 -0
  108. package/mcp/insights.js +445 -0
  109. package/mcp/insights.ts +541 -0
  110. package/mcp/tools.d.ts +63 -0
  111. package/mcp/tools.d.ts.map +1 -0
  112. package/mcp/tools.js +299 -0
  113. package/mcp/tools.ts +431 -0
  114. package/package.json +36 -26
  115. package/server.json +21 -0
  116. package/cli/constraints-loader.d.ts.map +0 -1
  117. package/cli/engine-helpers.d.ts.map +0 -1
  118. package/core/cross-axis-config.d.ts.map +0 -1
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/design-constraint-validator.svg)](https://www.npmjs.com/package/design-constraint-validator)
7
7
  [![CI](https://github.com/CseperkePapp/design-constraint-validator/actions/workflows/ci.yml/badge.svg)](https://github.com/CseperkePapp/design-constraint-validator/actions/workflows/ci.yml)
8
8
  [![SBOM](https://img.shields.io/badge/SBOM-CycloneDX-brightgreen)](https://github.com/CseperkePapp/design-constraint-validator/actions/workflows/sbom.yml)
9
+ [![Supply Chain Security](https://img.shields.io/badge/security-hardened-blue)](SECURITY.md)
9
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
11
  [![Node](https://img.shields.io/badge/node-%3E%3D18.x-339933.svg)](#)
11
12
 
@@ -25,38 +26,70 @@ This is **not** a schema linter; it's a **reasoning validator** for values and r
25
26
  # Local (recommended)
26
27
  npm i -D design-constraint-validator
27
28
 
28
- # One-off run
29
- npx dcv --help
29
+ # One-off run, no install (the bin name `dcv` belongs to an unrelated package)
30
+ npx design-constraint-validator --help
30
31
  ```
31
32
 
33
+ After a local install, the shorter `dcv` bin is available (e.g. `npx dcv --help`).
34
+
32
35
  **Requirements:** Node.js ≥ 18.x (ESM)
33
36
 
34
37
  ---
35
38
 
36
39
  ## Quick Start
37
40
 
41
+ DCV validates **your** tokens against **your** constraints. From an empty directory:
42
+
38
43
  ```bash
39
- # Validate tokens with default constraints
40
- npx dcv validate ./tokens/tokens.json
44
+ # 1. Your design tokens (DTCG-style "$value")
45
+ cat > tokens.json <<'JSON'
46
+ {
47
+ "color": {
48
+ "text": { "$value": "#888888" },
49
+ "bg": { "$value": "#999999" }
50
+ }
51
+ }
52
+ JSON
53
+
54
+ # 2. Your constraints — auto-discovered as dcv.config.json in the cwd
55
+ cat > dcv.config.json <<'JSON'
56
+ {
57
+ "constraints": {
58
+ "enableBuiltInWcagDefaults": false,
59
+ "enableBuiltInThreshold": false,
60
+ "wcag": [
61
+ { "foreground": "color.text", "background": "color.bg", "ratio": 4.5, "description": "Body text on background" }
62
+ ]
63
+ }
64
+ }
65
+ JSON
41
66
 
42
- # Explain failures
43
- npx dcv why --format table
67
+ # 3. Validate (positional path or --tokens; exits non-zero on violations)
68
+ npx design-constraint-validator validate tokens.json --summary table
44
69
 
45
- # Export dependency graph
46
- npx dcv graph --format mermaid > graph.mmd
70
+ # Explain one token (the tokenId is required)
71
+ npx design-constraint-validator why color.text --tokens tokens.json --format table
72
+
73
+ # Export the dependency graph
74
+ npx design-constraint-validator graph --tokens tokens.json --format mermaid > graph.mmd
47
75
  ```
48
76
 
49
- **Example Output:**
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 …`.
50
80
 
51
- ```
52
- Constraint Status Details
53
- ──────────────────────────── ────── ─────────────────────────────────────────────
54
- WCAG Contrast ≥ 4.5:1 FAIL text.primary(#5A5A5A) on bg.body(#F5F5F5) ⇒ 3.8
55
- Typography monotonic scale FAIL h3(22) < body(18) < h2(21) < h1(34) ✖ out-of-order: h2<h3
56
- Cross-axis (weight vs size) PASS all headings satisfy min weight for size bucket
57
- Exit code: 1 (violations found)
81
+ **Example output** (`validate`):
82
+
83
+ ```text
84
+ validate: 1 error(s), 0 warning(s)
85
+ ERROR wcag-contrast color.text|color.bg @ Body text on background Contrast 1.24:1 < 4.5:1
86
+ scope rules warnings errors
87
+ ------ ----- -------- ------
88
+ global 1 0 1
58
89
  ```
59
90
 
91
+ Exit code is `1` when violations are found, `0` when clean (use `--fail-on off` to always exit `0`). The built-in WCAG/threshold defaults target the bundled example token ids, so disable them (as above) when validating your own token names.
92
+
60
93
  ---
61
94
 
62
95
  ## Programmatic API
@@ -64,14 +97,15 @@ Exit code: 1 (violations found)
64
97
  ```ts
65
98
  import { validate } from 'design-constraint-validator';
66
99
 
67
- const result = await validate({
68
- tokensPath: './tokens/tokens.json',
69
- policyPath: './themes/policies/aa.json'
100
+ // Synchronous. Point at files, or pass `tokens` / `constraints` inline.
101
+ const result = validate({
102
+ tokensPath: './tokens.json',
103
+ configPath: './dcv.config.json', // omit to auto-discover dcv.config.json in the cwd
70
104
  });
71
105
 
72
106
  if (!result.ok) {
73
107
  for (const v of result.violations) {
74
- console.log(`[${v.kind}] ${v.message}`, v.context);
108
+ console.log(`[${v.ruleId}] ${v.message}`);
75
109
  }
76
110
  process.exitCode = 1;
77
111
  }
@@ -81,6 +115,36 @@ See **[API Reference](docs/API.md)** for complete programmatic usage.
81
115
 
82
116
  ---
83
117
 
118
+ ## Use from AI agents (MCP)
119
+
120
+ DCV ships a second binary, `dcv-mcp`, that exposes the validator over MCP stdio for agent clients. Add it to a Claude Desktop or generic MCP client config like this:
121
+
122
+ ```json
123
+ {
124
+ "mcpServers": {
125
+ "dcv": {
126
+ "command": "npx",
127
+ "args": ["-y", "--package", "design-constraint-validator", "dcv-mcp"]
128
+ }
129
+ }
130
+ }
131
+ ```
132
+
133
+ The server exposes six read-only, JSON-returning tools:
134
+
135
+ - `validate` - validate inline `tokens` or a `tokensPath` against inline `constraints` or a config file.
136
+ - `why` - explain provenance, aliases, dependencies, dependents, and alias chain for one token id.
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.
143
+
144
+ Tool failures are returned as structured JSON: `{ "ok": false, "error": { "code": "...", "message": "..." } }`.
145
+
146
+ ---
147
+
84
148
  ## Documentation
85
149
 
86
150
  ### For Everyone
@@ -103,6 +167,7 @@ See **[API Reference](docs/API.md)** for complete programmatic usage.
103
167
  - **[Prior Art / Method](docs/prior-art/)** - Design rationale (Decision Themes, receipts)
104
168
  - **[AI Guide](docs/AI-GUIDE.md)** - Using DCV with ChatGPT/Claude/Copilot
105
169
  - **[Contributing](CONTRIBUTING.md)** - Contribution guidelines
170
+ - **[Security](SECURITY.md)** - Supply chain security measures
106
171
 
107
172
  ---
108
173
 
@@ -140,7 +205,7 @@ Adapters normalize common ecosystems:
140
205
 
141
206
  - **Style Dictionary** - See [examples/style-dictionary/](examples/style-dictionary/)
142
207
  - **Tokens Studio JSON** - See [examples/tokens-studio/](examples/tokens-studio/)
143
- - **DTCG** (Design Tokens Community Group) - See [examples/dtcg/](examples/dtcg/)
208
+ - **DTCG** (Design Tokens Community Group) — reads the **2025.10 stable spec** (structured sRGB colors, structured dimensions, `{alias}` references, `$extensions` passthrough; non-sRGB spaces warn rather than mis-calculate; composite types out of scope). See [examples/dtcg/](examples/dtcg/)
144
209
 
145
210
  Full adapter documentation: **[Adapters](docs/Adapters.md)**
146
211
 
@@ -180,7 +245,8 @@ These documents keep the method openly implementable and prevent patent lock-up.
180
245
  DCV generates CycloneDX-compliant SBOMs for supply chain transparency:
181
246
 
182
247
  - **CI Builds:** SBOM artifacts on every CI run (90-day retention)
183
- - **Releases:** SBOM files (JSON + XML) attached to GitHub releases
248
+ - **Version tags:** SBOM artifacts for release tags
249
+ - **GitHub Releases:** SBOM files (JSON + XML) attached when a GitHub Release is created
184
250
  - **Manual:** Run `npx @cyclonedx/cyclonedx-npm` in project root
185
251
 
186
252
  **Download:**
@@ -194,7 +260,7 @@ DCV generates CycloneDX-compliant SBOMs for supply chain transparency:
194
260
  - Plugin API for **custom constraints**
195
261
  - **VS Code** diagnostics (inline explain)
196
262
  - **Cross-axis packs** (typography × weight × contrast)
197
- - **Receipts & provenance** (hashes, signable reports)
263
+ - **Signed / attestable receipts** — `dcv validate --receipt` already emits environment + input content hashes today; cryptographic **signing** is the roadmap part
198
264
  - UI graph explorer (node inspector, violations focus)
199
265
 
200
266
  ---
@@ -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();
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();
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);
@@ -100,7 +108,7 @@ export async function graphCommand(options) {
100
108
  let edgeLabels;
101
109
  if (onlyViolations || highlightViolations || labelViolations) {
102
110
  const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
103
- const tokens = loadTokensWithBreakpoint(breakpoint);
111
+ const tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
104
112
  const { flattenTokens } = await import('../../core/flatten.js');
105
113
  const { Engine } = await import('../../core/engine.js');
106
114
  const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.js');
@@ -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
  }
@@ -211,7 +228,7 @@ export async function graphCommand(options) {
211
228
  }
212
229
  for (const breakpoint of plan) {
213
230
  const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
214
- const tokens = loadTokensWithBreakpoint(breakpoint);
231
+ const tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
215
232
  const { edges } = flattenTokens(tokens);
216
233
  let filteredEdges = edges;
217
234
  if (options.filter) {
@@ -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');
@@ -69,7 +77,7 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
69
77
  let highlight: { nodes: Set<string>; edges: Set<string>; color?: string } | undefined; let edgeLabels: Map<string,string> | undefined;
70
78
  if (onlyViolations || highlightViolations || labelViolations) {
71
79
  const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
72
- const tokens = loadTokensWithBreakpoint(breakpoint);
80
+ const tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
73
81
  const { flattenTokens } = await import('../../core/flatten.js');
74
82
  const { Engine } = await import('../../core/engine.js');
75
83
  const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.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
  }
@@ -170,7 +183,7 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
170
183
  return; }
171
184
  for (const breakpoint of plan) {
172
185
  const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
173
- const tokens = loadTokensWithBreakpoint(breakpoint); const { edges } = flattenTokens(tokens);
186
+ const tokens = loadTokensWithBreakpoint(breakpoint, options.tokens); const { edges } = flattenTokens(tokens);
174
187
  let filteredEdges = edges;
175
188
  if (options.filter) { const filterRegex = new RegExp(options.filter); filteredEdges = edges.filter(([from,to]) => filterRegex.test(from) || filterRegex.test(to)); }
176
189
  const format = options.format || 'json'; const graph = generateDependencyGraph(filteredEdges, format);
@@ -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"}