design-constraint-validator 2.2.0 → 2.3.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 (44) hide show
  1. package/README.md +8 -0
  2. package/adapters/css.d.ts +14 -0
  3. package/adapters/css.d.ts.map +1 -1
  4. package/adapters/css.js +33 -2
  5. package/adapters/css.ts +36 -2
  6. package/adapters/js.d.ts.map +1 -1
  7. package/adapters/js.js +3 -1
  8. package/adapters/js.ts +3 -1
  9. package/adapters/json.d.ts.map +1 -1
  10. package/adapters/json.js +3 -1
  11. package/adapters/json.ts +3 -1
  12. package/cli/commands/build.d.ts.map +1 -1
  13. package/cli/commands/build.js +8 -6
  14. package/cli/commands/build.ts +8 -6
  15. package/cli/commands/graph.d.ts.map +1 -1
  16. package/cli/commands/graph.js +14 -3
  17. package/cli/commands/graph.ts +14 -3
  18. package/cli/commands/patch-apply.d.ts.map +1 -1
  19. package/cli/commands/patch-apply.js +28 -18
  20. package/cli/commands/patch-apply.ts +26 -18
  21. package/cli/commands/validate.d.ts.map +1 -1
  22. package/cli/commands/validate.js +9 -5
  23. package/cli/commands/validate.ts +9 -5
  24. package/cli/commands/why.d.ts.map +1 -1
  25. package/cli/commands/why.js +16 -4
  26. package/cli/commands/why.ts +16 -4
  27. package/cli/dcv.js +28 -8
  28. package/cli/dcv.ts +28 -8
  29. package/cli/types.d.ts +4 -0
  30. package/cli/types.d.ts.map +1 -1
  31. package/cli/types.ts +4 -0
  32. package/core/breakpoints.d.ts +1 -0
  33. package/core/breakpoints.d.ts.map +1 -1
  34. package/core/breakpoints.js +11 -2
  35. package/core/breakpoints.ts +12 -1
  36. package/core/dtcg.d.ts.map +1 -1
  37. package/core/dtcg.js +7 -1
  38. package/core/dtcg.ts +6 -1
  39. package/core/patch.d.ts +7 -0
  40. package/core/patch.d.ts.map +1 -1
  41. package/core/patch.js +15 -4
  42. package/core/patch.ts +16 -4
  43. package/package.json +2 -2
  44. package/server.json +3 -3
package/README.md CHANGED
@@ -18,6 +18,10 @@
18
18
 
19
19
  This is **not** a schema linter; it's a **reasoning validator** for values and relationships.
20
20
 
21
+ ![DCV at a glance — one engine with three entry points (CLI, Library API, MCP server) over a shared validation core that flattens tokens, builds the engine, loads constraints, runs plugins, and reports errors/warnings.](https://raw.githubusercontent.com/CseperkePapp/design-constraint-validator/main/images/dcv-at-a-glance.png)
22
+
23
+ ![DCV validation pipeline — token files → flatten & resolve → build engine → load constraints → run plugins → report results; CLI, API, and MCP all use the same flow.](https://raw.githubusercontent.com/CseperkePapp/design-constraint-validator/main/images/dcv-pipeline.png)
24
+
21
25
  ---
22
26
 
23
27
  ## Installation
@@ -183,6 +187,8 @@ Conventional linters catch **schema** issues ("has a value, has a type").
183
187
 
184
188
  This transforms tokens from "bags of numbers" into a **formal design system**.
185
189
 
190
+ ![What DCV checks — five plugin families: WCAG contrast, monotonic order, lightness ordering, thresholds, and cross-axis rules; every plugin returns a structured issue (rule, level, message, involved tokens, metadata).](https://raw.githubusercontent.com/CseperkePapp/design-constraint-validator/main/images/dcv-plugins.png)
191
+
186
192
  ---
187
193
 
188
194
  ## Comparison: Schema Linters vs DCV
@@ -194,6 +200,8 @@ This transforms tokens from "bags of numbers" into a **formal design system**.
194
200
  | **Purpose** | Format compliance | Design system integrity |
195
201
  | **Examples** | DTCG schema validator | WCAG checks, monotonic scales |
196
202
 
203
+ ![Mathematical Integrity for Design Systems — the DCV engine: the three core constraint types (accessibility & contrast, monotonic & lightness ordering, thresholds & cross-axis rules), one engine with three interfaces (CLI, library API, MCP server), and the difference between schema linters (format compliance) and DCV (mathematical relationships / design-system integrity).](https://raw.githubusercontent.com/CseperkePapp/design-constraint-validator/main/images/dcv-overview.png)
204
+
197
205
  > DCV is not affiliated with Anima's `design-tokens-validator` (schema-focused).
198
206
 
199
207
  ---
package/adapters/css.d.ts CHANGED
@@ -2,6 +2,20 @@ export type TokenId = string;
2
2
  export type TokenValue = string | number;
3
3
  export type VarMapper = (id: TokenId) => string | null;
4
4
  export declare const defaultVarMapper: VarMapper;
5
+ /**
6
+ * A CSS custom-property value derived from a token must not break out of its
7
+ * declaration (TASK-035 C). `;`, `{`, `}` and comment markers can't appear in a
8
+ * well-formed token value, so strip them — a malformed value can otherwise
9
+ * inject or corrupt sibling declarations.
10
+ */
11
+ export declare function sanitizeCssValue(v: string): string;
12
+ /**
13
+ * Detect non-injective var mapping (TASK-035 C): `defaultVarMapper` collapses
14
+ * both `.` and other separators to `-`, so distinct ids like `a.b` and `a-b` map
15
+ * to the same `--a-b` and would silently overwrite each other in CSS/JSON/JS.
16
+ * Throw a clear error naming the colliding ids rather than dropping a token.
17
+ */
18
+ export declare function assertNoVarCollisions(ids: Iterable<string>, resolve: (id: string) => string | null): void;
5
19
  export type ManifestRow = {
6
20
  id: string;
7
21
  canonicalVar?: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"css.d.ts","sourceRoot":"","sources":["css.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAC7B,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAIzC,MAAM,MAAM,SAAS,GAAG,CAAC,EAAE,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,CAAC;AAEvD,eAAO,MAAM,gBAAgB,EAAE,SAC4C,CAAC;AAG5E,MAAM,MAAM,WAAW,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAE9F,MAAM,WAAW,UAAU;IAAG,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE;AAEpE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAsBxG;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,SAAS,CAIxE;AAgCD;;GAEG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,EACnC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;CAAE,GACzF,MAAM,CAKR;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,EAClC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;CAAE,GACzF,MAAM,CAKR;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EAAE,OAAO,EAAE,MAAM,QAEzF"}
1
+ {"version":3,"file":"css.d.ts","sourceRoot":"","sources":["css.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAC7B,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAIzC,MAAM,MAAM,SAAS,GAAG,CAAC,EAAE,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,CAAC;AAEvD,eAAO,MAAM,gBAAgB,EAAE,SAC4C,CAAC;AAE5E;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,IAAI,CAczG;AAGD,MAAM,MAAM,WAAW,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAE9F,MAAM,WAAW,UAAU;IAAG,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE;AAEpE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAsBxG;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,SAAS,CAIxE;AAkCD;;GAEG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,EACnC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;CAAE,GACzF,MAAM,CAKR;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,EAClC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;CAAE,GACzF,MAAM,CAKR;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EAAE,OAAO,EAAE,MAAM,QAEzF"}
package/adapters/css.js CHANGED
@@ -1,4 +1,33 @@
1
1
  export const defaultVarMapper = (id) => `--${id.replace(/[^a-z0-9.]/gi, "-").replace(/\.+/g, "-").toLowerCase()}`;
2
+ /**
3
+ * A CSS custom-property value derived from a token must not break out of its
4
+ * declaration (TASK-035 C). `;`, `{`, `}` and comment markers can't appear in a
5
+ * well-formed token value, so strip them — a malformed value can otherwise
6
+ * inject or corrupt sibling declarations.
7
+ */
8
+ export function sanitizeCssValue(v) {
9
+ return v.replace(/\/\*|\*\//g, "").replace(/[;{}]/g, "").trim();
10
+ }
11
+ /**
12
+ * Detect non-injective var mapping (TASK-035 C): `defaultVarMapper` collapses
13
+ * both `.` and other separators to `-`, so distinct ids like `a.b` and `a-b` map
14
+ * to the same `--a-b` and would silently overwrite each other in CSS/JSON/JS.
15
+ * Throw a clear error naming the colliding ids rather than dropping a token.
16
+ */
17
+ export function assertNoVarCollisions(ids, resolve) {
18
+ const seen = new Map();
19
+ for (const id of ids) {
20
+ const v = resolve(id);
21
+ if (!v)
22
+ continue; // intentionally unmapped
23
+ const prev = seen.get(v);
24
+ if (prev !== undefined && prev !== id) {
25
+ throw new Error(`Variable name collision: tokens "${prev}" and "${id}" both map to "${v}". ` +
26
+ `Rename one id or provide a manifest with distinct canonicalVar values.`);
27
+ }
28
+ seen.set(v, id);
29
+ }
30
+ }
2
31
  ;
3
32
  /**
4
33
  * Build a mapping of token id -> {canonical, aliases} from an optional manifest.
@@ -42,11 +71,12 @@ function buildCssBlock(values, opts) {
42
71
  const decls = [];
43
72
  if (opts.manifest) {
44
73
  const mapping = buildVarMapping(Object.keys(values), opts.manifest);
74
+ assertNoVarCollisions(Object.keys(values), (id) => mapping.get(id)?.canonical ?? null);
45
75
  for (const [id, val] of Object.entries(values)) {
46
76
  const m = mapping.get(id);
47
77
  if (!m)
48
78
  continue;
49
- const v = String(val).trim();
79
+ const v = sanitizeCssValue(String(val));
50
80
  if (!v)
51
81
  continue;
52
82
  decls.push(`${m.canonical}: ${v};`);
@@ -57,11 +87,12 @@ function buildCssBlock(values, opts) {
57
87
  }
58
88
  else {
59
89
  const mapVar = opts.mapVar ?? defaultVarMapper;
90
+ assertNoVarCollisions(Object.keys(values), mapVar);
60
91
  for (const [id, val] of Object.entries(values)) {
61
92
  const cssVar = mapVar(id);
62
93
  if (!cssVar)
63
94
  continue; // skip if intentionally unmapped
64
- const v = String(val).trim();
95
+ const v = sanitizeCssValue(String(val));
65
96
  if (v)
66
97
  decls.push(`${cssVar}: ${v};`);
67
98
  }
package/adapters/css.ts CHANGED
@@ -9,6 +9,38 @@ export type VarMapper = (id: TokenId) => string | null;
9
9
  export const defaultVarMapper: VarMapper = (id) =>
10
10
  `--${id.replace(/[^a-z0-9.]/gi, "-").replace(/\.+/g, "-").toLowerCase()}`;
11
11
 
12
+ /**
13
+ * A CSS custom-property value derived from a token must not break out of its
14
+ * declaration (TASK-035 C). `;`, `{`, `}` and comment markers can't appear in a
15
+ * well-formed token value, so strip them — a malformed value can otherwise
16
+ * inject or corrupt sibling declarations.
17
+ */
18
+ export function sanitizeCssValue(v: string): string {
19
+ return v.replace(/\/\*|\*\//g, "").replace(/[;{}]/g, "").trim();
20
+ }
21
+
22
+ /**
23
+ * Detect non-injective var mapping (TASK-035 C): `defaultVarMapper` collapses
24
+ * both `.` and other separators to `-`, so distinct ids like `a.b` and `a-b` map
25
+ * to the same `--a-b` and would silently overwrite each other in CSS/JSON/JS.
26
+ * Throw a clear error naming the colliding ids rather than dropping a token.
27
+ */
28
+ export function assertNoVarCollisions(ids: Iterable<string>, resolve: (id: string) => string | null): void {
29
+ const seen = new Map<string, string>();
30
+ for (const id of ids) {
31
+ const v = resolve(id);
32
+ if (!v) continue; // intentionally unmapped
33
+ const prev = seen.get(v);
34
+ if (prev !== undefined && prev !== id) {
35
+ throw new Error(
36
+ `Variable name collision: tokens "${prev}" and "${id}" both map to "${v}". ` +
37
+ `Rename one id or provide a manifest with distinct canonicalVar values.`,
38
+ );
39
+ }
40
+ seen.set(v, id);
41
+ }
42
+ }
43
+
12
44
  // Manifest driven mapping allowing canonical + legacy aliases.
13
45
  export type ManifestRow = { id: string; canonicalVar?: string | null; legacyVars?: string[] };
14
46
 
@@ -56,10 +88,11 @@ function buildCssBlock(
56
88
  const decls: string[] = [];
57
89
  if (opts.manifest) {
58
90
  const mapping = buildVarMapping(Object.keys(values), opts.manifest);
91
+ assertNoVarCollisions(Object.keys(values), (id) => mapping.get(id)?.canonical ?? null);
59
92
  for (const [id, val] of Object.entries(values)) {
60
93
  const m = mapping.get(id);
61
94
  if (!m) continue;
62
- const v = String(val).trim();
95
+ const v = sanitizeCssValue(String(val));
63
96
  if (!v) continue;
64
97
  decls.push(`${m.canonical}: ${v};`);
65
98
  for (const alias of m.aliases) {
@@ -68,10 +101,11 @@ function buildCssBlock(
68
101
  }
69
102
  } else {
70
103
  const mapVar = opts.mapVar ?? defaultVarMapper;
104
+ assertNoVarCollisions(Object.keys(values), mapVar);
71
105
  for (const [id, val] of Object.entries(values)) {
72
106
  const cssVar = mapVar(id);
73
107
  if (!cssVar) continue; // skip if intentionally unmapped
74
- const v = String(val).trim();
108
+ const v = sanitizeCssValue(String(val));
75
109
  if (v) decls.push(`${cssVar}: ${v};`);
76
110
  }
77
111
  }
@@ -1 +1 @@
1
- {"version":3,"file":"js.d.ts","sourceRoot":"","sources":["js.ts"],"names":[],"mappings":"AACA,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAE/E,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,GAAG,MAAM,CAUpF"}
1
+ {"version":3,"file":"js.d.ts","sourceRoot":"","sources":["js.ts"],"names":[],"mappings":"AACA,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAEtG,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,GAAG,MAAM,CAYpF"}
package/adapters/js.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // adapters/js.ts
2
- import { buildVarMapping, defaultVarMapper } from './css.js';
2
+ import { buildVarMapping, defaultVarMapper, assertNoVarCollisions } from './css.js';
3
3
  export function emitJS(values, manifest) {
4
4
  const ids = Object.keys(values).sort();
5
5
  let mapping;
6
6
  if (manifest)
7
7
  mapping = buildVarMapping(ids, manifest);
8
+ // Distinct ids that collapse to the same key would silently overwrite (TASK-035 C).
9
+ assertNoVarCollisions(ids, (id) => (mapping ? mapping.get(id).canonical : defaultVarMapper(id)));
8
10
  const lines = [];
9
11
  for (const id of ids) {
10
12
  const canonical = mapping ? mapping.get(id).canonical : defaultVarMapper(id);
package/adapters/js.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // adapters/js.ts
2
- import { buildVarMapping, defaultVarMapper, type ManifestRow } from './css.js';
2
+ import { buildVarMapping, defaultVarMapper, assertNoVarCollisions, type ManifestRow } from './css.js';
3
3
 
4
4
  export function emitJS(values: Record<string, any>, manifest?: ManifestRow[]): string {
5
5
  const ids = Object.keys(values).sort();
6
6
  let mapping: Map<string, { canonical: string; aliases: string[] }> | undefined;
7
7
  if (manifest) mapping = buildVarMapping(ids, manifest);
8
+ // Distinct ids that collapse to the same key would silently overwrite (TASK-035 C).
9
+ assertNoVarCollisions(ids, (id) => (mapping ? mapping.get(id)!.canonical : defaultVarMapper(id)));
8
10
  const lines: string[] = [];
9
11
  for (const id of ids) {
10
12
  const canonical = mapping ? mapping.get(id)!.canonical : defaultVarMapper(id);
@@ -1 +1 @@
1
- {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["json.ts"],"names":[],"mappings":"AACA,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAE/E,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,GAAG,MAAM,CAUtF;AAED,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAEzE;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,CAAC;CAC7B,GAAG,MAAM,CAQT;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;IACnC,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB,GAAG,MAAM,CAKT"}
1
+ {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["json.ts"],"names":[],"mappings":"AACA,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAEtG,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,GAAG,MAAM,CAYtF;AAED,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAEzE;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,CAAC;CAC7B,GAAG,MAAM,CAQT;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;IACnC,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB,GAAG,MAAM,CAKT"}
package/adapters/json.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // adapters/json.ts
2
- import { buildVarMapping, defaultVarMapper } from './css.js';
2
+ import { buildVarMapping, defaultVarMapper, assertNoVarCollisions } from './css.js';
3
3
  export function emitJSON(values, manifest) {
4
4
  const ids = Object.keys(values).sort();
5
5
  let mapping;
6
6
  if (manifest)
7
7
  mapping = buildVarMapping(ids, manifest);
8
+ // Distinct ids that collapse to the same key would silently overwrite (TASK-035 C).
9
+ assertNoVarCollisions(ids, (id) => (mapping ? mapping.get(id).canonical : defaultVarMapper(id)));
8
10
  const out = {};
9
11
  for (const id of ids) {
10
12
  const canonical = mapping ? mapping.get(id).canonical : defaultVarMapper(id);
package/adapters/json.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // adapters/json.ts
2
- import { buildVarMapping, defaultVarMapper, type ManifestRow } from './css.js';
2
+ import { buildVarMapping, defaultVarMapper, assertNoVarCollisions, type ManifestRow } from './css.js';
3
3
 
4
4
  export function emitJSON(values: Record<string, any>, manifest?: ManifestRow[]): string {
5
5
  const ids = Object.keys(values).sort();
6
6
  let mapping: Map<string, { canonical: string; aliases: string[] }> | undefined;
7
7
  if (manifest) mapping = buildVarMapping(ids, manifest);
8
+ // Distinct ids that collapse to the same key would silently overwrite (TASK-035 C).
9
+ assertNoVarCollisions(ids, (id) => (mapping ? mapping.get(id)!.canonical : defaultVarMapper(id)));
8
10
  const out: Record<string, any> = {};
9
11
  for (const id of ids) {
10
12
  const canonical = mapping ? mapping.get(id)!.canonical : defaultVarMapper(id);
@@ -1 +1 @@
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
+ {"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,CAiE9F"}
@@ -1,4 +1,4 @@
1
- import { dirname, resolve } from 'node:path';
1
+ import { dirname, resolve, join } from 'node:path';
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { flattenTokens } from '../../core/flatten.js';
4
4
  import { mergeTokens } from '../../core/breakpoints.js';
@@ -48,13 +48,15 @@ export async function buildCommand(options) {
48
48
  console.log(js);
49
49
  return;
50
50
  }
51
- const dir = 'dist';
51
+ // --output is honored as the target DIRECTORY for --all-formats (TASK-035 C:
52
+ // it was previously ignored and dist/ hardcoded). Defaults to dist/.
53
+ const dir = options.output || 'dist';
52
54
  if (!existsSync(dir))
53
55
  mkdirSync(dir, { recursive: true });
54
- writeFileSync('dist/tokens.css', css, 'utf8');
55
- writeFileSync('dist/tokens.json', json, 'utf8');
56
- writeFileSync('dist/tokens.js', js, 'utf8');
57
- console.log(`Tokens written (all formats) to dist/ (css/json/js)${manifest ? ' with mapper' : ''}`);
56
+ writeFileSync(join(dir, 'tokens.css'), css, 'utf8');
57
+ writeFileSync(join(dir, 'tokens.json'), json, 'utf8');
58
+ writeFileSync(join(dir, 'tokens.js'), js, 'utf8');
59
+ console.log(`Tokens written (all formats) to ${dir}/ (css/json/js)${manifest ? ' with mapper' : ''}`);
58
60
  return;
59
61
  }
60
62
  if (format === 'css') {
@@ -1,4 +1,4 @@
1
- import { dirname, resolve } from 'node:path';
1
+ import { dirname, resolve, join } from 'node:path';
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { flattenTokens, type FlatToken } from '../../core/flatten.js';
4
4
  import { mergeTokens } from '../../core/breakpoints.js';
@@ -47,11 +47,13 @@ export async function buildCommand(options: BuildOptions & { [k: string]: any })
47
47
  console.log(js);
48
48
  return;
49
49
  }
50
- const dir = 'dist'; if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
51
- writeFileSync('dist/tokens.css', css, 'utf8');
52
- writeFileSync('dist/tokens.json', json, 'utf8');
53
- writeFileSync('dist/tokens.js', js, 'utf8');
54
- console.log(`Tokens written (all formats) to dist/ (css/json/js)${manifest ? ' with mapper' : ''}`);
50
+ // --output is honored as the target DIRECTORY for --all-formats (TASK-035 C:
51
+ // it was previously ignored and dist/ hardcoded). Defaults to dist/.
52
+ const dir = options.output || 'dist'; if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
53
+ writeFileSync(join(dir, 'tokens.css'), css, 'utf8');
54
+ writeFileSync(join(dir, 'tokens.json'), json, 'utf8');
55
+ writeFileSync(join(dir, 'tokens.js'), js, 'utf8');
56
+ console.log(`Tokens written (all formats) to ${dir}/ (css/json/js)${manifest ? ' with mapper' : ''}`);
55
57
  return;
56
58
  }
57
59
  if (format === 'css') {
@@ -1 +1 @@
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"}
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,CA4KvE"}
@@ -86,7 +86,11 @@ export async function graphCommand(options) {
86
86
  const outDir = 'dist/graphs';
87
87
  const baseFile = `${outDir}/${name}${suffix}-hasse.${ext}`;
88
88
  try {
89
- const src = `${constraintsDir}/${name}.order.json`;
89
+ // Mirror discoverConstraints (TASK-034/035 B): under --breakpoint use the
90
+ // per-bp order file when present, else fall back to the global one — so the
91
+ // bp-labeled Hasse output actually reflects the bp poset, not the global.
92
+ const bpSrc = breakpoint ? `${constraintsDir}/${name}.${breakpoint}.order.json` : undefined;
93
+ const src = bpSrc && existsSync(bpSrc) ? bpSrc : `${constraintsDir}/${name}.order.json`;
90
94
  if (!existsSync(src)) {
91
95
  console.error(`❌ Order constraint file not found: ${src}`);
92
96
  process.exit(1);
@@ -107,8 +111,15 @@ export async function graphCommand(options) {
107
111
  let highlight;
108
112
  let edgeLabels;
109
113
  if (onlyViolations || highlightViolations || labelViolations) {
110
- const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
111
- const tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
114
+ const { loadTokensWithBreakpoint, mergeTokens } = await import('../../core/breakpoints.js');
115
+ let tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
116
+ // Theme overlay affects VALUES, so violation overlays must reflect it
117
+ // (TASK-025) — merge-then-flatten like build/validate. The dependency
118
+ // graph itself is value-independent and intentionally unthemed.
119
+ if (options.theme) {
120
+ const { loadThemeTokens } = await import('./utils.js');
121
+ tokens = mergeTokens(tokens, loadThemeTokens(options.theme));
122
+ }
112
123
  const { flattenTokens } = await import('../../core/flatten.js');
113
124
  const { Engine } = await import('../../core/engine.js');
114
125
  const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.js');
@@ -65,7 +65,11 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
65
65
  const ext = baseFmt === 'mermaid' ? 'mmd' : 'dot';
66
66
  const outDir = 'dist/graphs'; const baseFile = `${outDir}/${name}${suffix}-hasse.${ext}`;
67
67
  try {
68
- const src = `${constraintsDir}/${name}.order.json`;
68
+ // Mirror discoverConstraints (TASK-034/035 B): under --breakpoint use the
69
+ // per-bp order file when present, else fall back to the global one — so the
70
+ // bp-labeled Hasse output actually reflects the bp poset, not the global.
71
+ const bpSrc = breakpoint ? `${constraintsDir}/${name}.${breakpoint}.order.json` : undefined;
72
+ const src = bpSrc && existsSync(bpSrc) ? bpSrc : `${constraintsDir}/${name}.order.json`;
69
73
  if (!existsSync(src)) { console.error(`❌ Order constraint file not found: ${src}`); process.exit(1); }
70
74
  const { order } = JSON.parse(readFileSync(src, 'utf8'));
71
75
  const { buildPoset, transitiveReduction, toMermaidHasseStyled, toDotHasseStyled, filterByPrefix, filterExcludePrefix, khopSubgraph, pickSeedsByPattern } = await import('../../core/poset.js');
@@ -76,8 +80,15 @@ export async function graphCommand(options: GraphOptions): Promise<void> {
76
80
  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); }
77
81
  let highlight: { nodes: Set<string>; edges: Set<string>; color?: string } | undefined; let edgeLabels: Map<string,string> | undefined;
78
82
  if (onlyViolations || highlightViolations || labelViolations) {
79
- const { loadTokensWithBreakpoint } = await import('../../core/breakpoints.js');
80
- const tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
83
+ const { loadTokensWithBreakpoint, mergeTokens } = await import('../../core/breakpoints.js');
84
+ let tokens = loadTokensWithBreakpoint(breakpoint, options.tokens);
85
+ // Theme overlay affects VALUES, so violation overlays must reflect it
86
+ // (TASK-025) — merge-then-flatten like build/validate. The dependency
87
+ // graph itself is value-independent and intentionally unthemed.
88
+ if (options.theme) {
89
+ const { loadThemeTokens } = await import('./utils.js');
90
+ tokens = mergeTokens(tokens, loadThemeTokens(options.theme));
91
+ }
81
92
  const { flattenTokens } = await import('../../core/flatten.js');
82
93
  const { Engine } = await import('../../core/engine.js');
83
94
  const { MonotonicPlugin, parseSize } = await import('../../core/constraints/monotonic.js');
@@ -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,CA+C9E"}
1
+ {"version":3,"file":"patch-apply.d.ts","sourceRoot":"","sources":["patch-apply.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAqCrD,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoD9E"}
@@ -1,7 +1,6 @@
1
1
  import { loadTokens, outputResult } from './utils.js';
2
2
  import fs from 'node:fs';
3
- import { flattenTokens } from '../../core/flatten.js';
4
- import { createHash } from 'node:crypto';
3
+ import { computeBaseTokensHash } from '../../core/patch.js';
5
4
  function applyChange(root, id, to, type) {
6
5
  const parts = id.split('.');
7
6
  let cur = root;
@@ -10,7 +9,12 @@ function applyChange(root, id, to, type) {
10
9
  if (i === parts.length - 1) {
11
10
  if (type === 'remove') {
12
11
  if (cur[p] && typeof cur[p] === 'object') {
13
- delete cur[p].$value; // delete leaf value
12
+ delete cur[p].$value;
13
+ delete cur[p].$type; // drop the now-orphaned type with the value
14
+ // Remove the node entirely if nothing else remains (no dangling
15
+ // type-only node left behind — TASK-035 D).
16
+ if (Object.keys(cur[p]).length === 0)
17
+ delete cur[p];
14
18
  }
15
19
  }
16
20
  else {
@@ -28,30 +32,36 @@ function applyChange(root, id, to, type) {
28
32
  }
29
33
  export async function patchApplyCommand(opts) {
30
34
  const tokens = loadTokens(opts.tokens || 'tokens/tokens.example.json');
31
- // Compute current base tokens hash for drift detection (same logic as buildPatch)
32
- function computeBaseHash(toks) {
33
- const flat = flattenTokens(JSON.parse(JSON.stringify(toks))).flat;
34
- const values = {};
35
- Object.keys(flat).sort().forEach(id => { values[id] = flat[id]?.value; });
36
- // Keep deterministic ordering
37
- const ordered = Object.keys(values).sort().reduce((acc, k) => { acc[k] = values[k]; return acc; }, {});
38
- return createHash('sha256').update(JSON.stringify(ordered)).digest('hex');
39
- }
40
- // Parse patch
41
- let patchDoc;
35
+ // Parse patch (friendly errors instead of a raw SyntaxError/TypeError TASK-035 D)
36
+ let raw;
42
37
  if (fs.existsSync(opts.patch)) {
43
- patchDoc = JSON.parse(fs.readFileSync(opts.patch, 'utf8'));
38
+ raw = fs.readFileSync(opts.patch, 'utf8');
44
39
  }
45
40
  else if (opts.patch.trim().startsWith('{')) {
46
- patchDoc = JSON.parse(opts.patch);
41
+ raw = opts.patch;
47
42
  }
48
43
  else {
49
44
  throw new Error(`Patch not found: ${opts.patch}`);
50
45
  }
46
+ let patchDoc;
47
+ try {
48
+ patchDoc = JSON.parse(raw);
49
+ }
50
+ catch (e) {
51
+ throw new Error(`Patch is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
52
+ }
53
+ // Shape validation before use, so a malformed doc gives a clear message rather
54
+ // than "changes is not iterable" / a raw TypeError (TASK-035 D).
55
+ if (!patchDoc || typeof patchDoc !== 'object' || Array.isArray(patchDoc)) {
56
+ throw new Error('Patch document must be a JSON object.');
57
+ }
51
58
  if (patchDoc.version !== 1)
52
- throw new Error('Unsupported patch version');
59
+ throw new Error(`Unsupported patch version: ${patchDoc.version}. Expected 1.`);
60
+ if (!Array.isArray(patchDoc.changes)) {
61
+ throw new Error('Patch document is missing a "changes" array.');
62
+ }
53
63
  if (patchDoc.baseTokensHash) {
54
- const currentHash = computeBaseHash(tokens);
64
+ const currentHash = computeBaseTokensHash(tokens);
55
65
  if (currentHash !== patchDoc.baseTokensHash) {
56
66
  console.warn(`⚠ Base tokens hash mismatch. Patch built against ${patchDoc.baseTokensHash} but current base is ${currentHash}. Proceeding (use --dry-run to inspect first).`);
57
67
  }
@@ -2,8 +2,7 @@ import { loadTokens, outputResult } from './utils.js';
2
2
  import type { PatchApplyOptions } from '../types.js';
3
3
  import type { TokenNode } from '../../core/flatten.js';
4
4
  import fs from 'node:fs';
5
- import { flattenTokens } from '../../core/flatten.js';
6
- import { createHash } from 'node:crypto';
5
+ import { computeBaseTokensHash } from '../../core/patch.js';
7
6
 
8
7
  interface PatchDocumentV1 {
9
8
  version: 1;
@@ -20,7 +19,11 @@ function applyChange(root: any, id: string, to: any, type: 'modify'|'add'|'remov
20
19
  if (i === parts.length - 1) {
21
20
  if (type === 'remove') {
22
21
  if (cur[p] && typeof cur[p] === 'object') {
23
- delete cur[p].$value; // delete leaf value
22
+ delete cur[p].$value;
23
+ delete cur[p].$type; // drop the now-orphaned type with the value
24
+ // Remove the node entirely if nothing else remains (no dangling
25
+ // type-only node left behind — TASK-035 D).
26
+ if (Object.keys(cur[p]).length === 0) delete cur[p];
24
27
  }
25
28
  } else {
26
29
  if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
@@ -35,27 +38,32 @@ function applyChange(root: any, id: string, to: any, type: 'modify'|'add'|'remov
35
38
 
36
39
  export async function patchApplyCommand(opts: PatchApplyOptions): Promise<void> {
37
40
  const tokens: TokenNode = loadTokens(opts.tokens || 'tokens/tokens.example.json');
38
- // Compute current base tokens hash for drift detection (same logic as buildPatch)
39
- function computeBaseHash(toks: TokenNode): string {
40
- const flat = flattenTokens(JSON.parse(JSON.stringify(toks))).flat as Record<string, any>;
41
- const values: Record<string, any> = {};
42
- Object.keys(flat).sort().forEach(id => { values[id] = flat[id]?.value; });
43
- // Keep deterministic ordering
44
- const ordered = Object.keys(values).sort().reduce((acc, k) => { acc[k] = values[k]; return acc; }, {} as Record<string, any>);
45
- return createHash('sha256').update(JSON.stringify(ordered)).digest('hex');
46
- }
47
- // Parse patch
48
- let patchDoc: PatchDocumentV1;
41
+ // Parse patch (friendly errors instead of a raw SyntaxError/TypeError TASK-035 D)
42
+ let raw: string;
49
43
  if (fs.existsSync(opts.patch)) {
50
- patchDoc = JSON.parse(fs.readFileSync(opts.patch, 'utf8'));
44
+ raw = fs.readFileSync(opts.patch, 'utf8');
51
45
  } else if (opts.patch.trim().startsWith('{')) {
52
- patchDoc = JSON.parse(opts.patch);
46
+ raw = opts.patch;
53
47
  } else {
54
48
  throw new Error(`Patch not found: ${opts.patch}`);
55
49
  }
56
- if (patchDoc.version !== 1) throw new Error('Unsupported patch version');
50
+ let patchDoc: PatchDocumentV1;
51
+ try {
52
+ patchDoc = JSON.parse(raw);
53
+ } catch (e) {
54
+ throw new Error(`Patch is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
55
+ }
56
+ // Shape validation before use, so a malformed doc gives a clear message rather
57
+ // than "changes is not iterable" / a raw TypeError (TASK-035 D).
58
+ if (!patchDoc || typeof patchDoc !== 'object' || Array.isArray(patchDoc)) {
59
+ throw new Error('Patch document must be a JSON object.');
60
+ }
61
+ if (patchDoc.version !== 1) throw new Error(`Unsupported patch version: ${(patchDoc as any).version}. Expected 1.`);
62
+ if (!Array.isArray(patchDoc.changes)) {
63
+ throw new Error('Patch document is missing a "changes" array.');
64
+ }
57
65
  if (patchDoc.baseTokensHash) {
58
- const currentHash = computeBaseHash(tokens);
66
+ const currentHash = computeBaseTokensHash(tokens);
59
67
  if (currentHash !== patchDoc.baseTokensHash) {
60
68
  console.warn(`⚠ Base tokens hash mismatch. Patch built against ${patchDoc.baseTokensHash} but current base is ${currentHash}. Proceeding (use --dry-run to inspect first).`);
61
69
  }
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["validate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAOnD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA4L9E"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["validate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAOnD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAgM9E"}
@@ -8,8 +8,9 @@ import { setupConstraints, collectReferencedIds } from '../constraint-registry.j
8
8
  import { printVersionBanner } from '../version-banner.js';
9
9
  import { loadThemeTokens } from './utils.js';
10
10
  export async function validateCommand(_options) {
11
- // Show version banner (subtle, dimmed)
12
- printVersionBanner({ quiet: _options.format === 'json' });
11
+ // Show version banner (subtle, dimmed). Stay quiet whenever stdout must be
12
+ // machine-parseable JSON output OR a JSON summary (TASK-035 B).
13
+ printVersionBanner({ quiet: _options.format === 'json' || _options.summary === 'json' });
13
14
  try {
14
15
  const bps = parseBreakpoints(process.argv);
15
16
  const crossAxisDebug = process.argv.includes('--cross-axis-debug');
@@ -109,12 +110,15 @@ export async function validateCommand(_options) {
109
110
  pushRow(bp ?? 'global', { rules: rulesEvaluated, warnings: warns.length, errors: errs.length });
110
111
  const dur = globalThis.performance.now() - tStart;
111
112
  perBpTimings.push({ bp: bp ?? 'global', ms: dur });
112
- // Only print text output if not in JSON mode
113
+ // Only print text output if not in JSON mode. When --summary json is set,
114
+ // route the human lines to STDERR so stdout carries only the parseable JSON
115
+ // summary (TASK-035 B: per-issue lines previously polluted the summary).
113
116
  if (outputFormat !== 'json') {
114
- console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
117
+ const line = summaryFmt === 'json' ? console.error : console.log;
118
+ line(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
115
119
  for (const it of issues) {
116
120
  const tag = it.level === 'error' ? 'ERROR' : 'WARN ';
117
- console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`);
121
+ line(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`);
118
122
  }
119
123
  }
120
124
  }
@@ -11,8 +11,9 @@ import { printVersionBanner } from '../version-banner.js';
11
11
  import { loadThemeTokens } from './utils.js';
12
12
 
13
13
  export async function validateCommand(_options: ValidateOptions): Promise<void> {
14
- // Show version banner (subtle, dimmed)
15
- printVersionBanner({ quiet: _options.format === 'json' });
14
+ // Show version banner (subtle, dimmed). Stay quiet whenever stdout must be
15
+ // machine-parseable JSON output OR a JSON summary (TASK-035 B).
16
+ printVersionBanner({ quiet: _options.format === 'json' || _options.summary === 'json' });
16
17
 
17
18
  try {
18
19
  const bps = parseBreakpoints(process.argv);
@@ -115,10 +116,13 @@ export async function validateCommand(_options: ValidateOptions): Promise<void>
115
116
  const dur = globalThis.performance.now() - tStart;
116
117
  perBpTimings.push({ bp: bp ?? 'global', ms: dur });
117
118
 
118
- // Only print text output if not in JSON mode
119
+ // Only print text output if not in JSON mode. When --summary json is set,
120
+ // route the human lines to STDERR so stdout carries only the parseable JSON
121
+ // summary (TASK-035 B: per-issue lines previously polluted the summary).
119
122
  if (outputFormat !== 'json') {
120
- console.log(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
121
- for (const it of issues) { const tag = it.level === 'error' ? 'ERROR' : 'WARN '; console.log(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`); }
123
+ const line = summaryFmt === 'json' ? console.error : console.log;
124
+ line(`validate${bp ? ` [bp=${bp}]` : ''}: ${errs.length} error(s), ${warns.length} warning(s)${_options.perf ? ` (${dur.toFixed(2)}ms)` : ''}`);
125
+ for (const it of issues) { const tag = it.level === 'error' ? 'ERROR' : 'WARN '; line(`${tag} ${it.rule} ${it.id}${it.where ? ' @ ' + it.where : ''}${bp ? ` [${bp}]` : ''} — ${it.message}`); }
122
126
  }
123
127
  }
124
128
  const totalMs = globalThis.performance.now() - tStartTotal;
@@ -1 +1 @@
1
- {"version":3,"file":"why.d.ts","sourceRoot":"","sources":["why.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAO9C,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA8JnE"}
1
+ {"version":3,"file":"why.d.ts","sourceRoot":"","sources":["why.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAQ9C,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAyKnE"}
@@ -1,13 +1,24 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { flattenTokens } from '../../core/flatten.js';
3
3
  import { explain } from '../../core/why.js';
4
- import { loadTokens } from './utils.js';
4
+ import { loadThemeTokens } from './utils.js';
5
+ import { loadTokensWithBreakpoint, mergeTokens, parseBreakpoints } from '../../core/breakpoints.js';
5
6
  import { Engine } from '../../core/engine.js';
6
7
  import { loadConfig } from '../config.js';
7
8
  import { setupConstraints } from '../constraint-registry.js';
8
9
  export async function whyCommand(options) {
9
- const tokensPath = options.tokens || 'tokens/tokens.json';
10
- const tokens = loadTokens(tokensPath);
10
+ const tokensPath = options.tokens || 'tokens/tokens.example.json';
11
+ // Theme/breakpoint awareness (TASK-025): mirror validate's merge-then-flatten so
12
+ // `why` resolves the same values a themed/breakpoint validate would. Both loaders
13
+ // fail closed on a missing/malformed file.
14
+ const bp = (options.breakpoint ?? parseBreakpoints(process.argv)[0]);
15
+ let tokens = loadTokensWithBreakpoint(bp, tokensPath);
16
+ let themeFlat;
17
+ if (options.theme) {
18
+ const themeTokens = loadThemeTokens(options.theme);
19
+ tokens = mergeTokens(tokens, themeTokens);
20
+ themeFlat = flattenTokens(themeTokens).flat;
21
+ }
11
22
  const { flat, edges } = flattenTokens(tokens);
12
23
  const target = options.tokenId;
13
24
  if (!flat[target]) {
@@ -45,6 +56,7 @@ export async function whyCommand(options) {
45
56
  const overrides = safeLoad('tokens/overrides/local.json');
46
57
  const baseReport = explain(target, flat, edges, {
47
58
  overrides: overrides?.overrides ?? overrides,
59
+ theme: themeFlat,
48
60
  });
49
61
  // Best-effort constraint summary: which rules currently implicate this token
50
62
  let constraintsSummary;
@@ -70,7 +82,7 @@ export async function whyCommand(options) {
70
82
  const knownIds = new Set(Object.keys(init));
71
83
  // Discover and attach all constraints via centralized registry.
72
84
  // Honor --constraints-dir, matching `validate` (default: themes).
73
- setupConstraints(engine, { config, constraintsDir: options['constraints-dir'] ?? 'themes' }, { knownIds });
85
+ setupConstraints(engine, { config, constraintsDir: options['constraints-dir'] ?? 'themes', bp }, { knownIds });
74
86
  const candidates = new Set([target]);
75
87
  const allIssues = engine.evaluate(candidates);
76
88
  if (allIssues.length) {
@@ -2,15 +2,26 @@ import { readFileSync } from 'node:fs';
2
2
  import { flattenTokens, type FlatToken } from '../../core/flatten.js';
3
3
  import { explain } from '../../core/why.js';
4
4
  import type { WhyOptions } from '../types.js';
5
- import { loadTokens } from './utils.js';
5
+ import { loadThemeTokens } from './utils.js';
6
+ import { loadTokensWithBreakpoint, mergeTokens, parseBreakpoints, type Breakpoint } from '../../core/breakpoints.js';
6
7
  import { Engine } from '../../core/engine.js';
7
8
  import { loadConfig } from '../config.js';
8
9
  import type { ConstraintIssue } from '../../core/engine.js';
9
10
  import { setupConstraints } from '../constraint-registry.js';
10
11
 
11
12
  export async function whyCommand(options: WhyOptions): Promise<void> {
12
- const tokensPath = options.tokens || 'tokens/tokens.json';
13
- const tokens = loadTokens(tokensPath);
13
+ const tokensPath = options.tokens || 'tokens/tokens.example.json';
14
+ // Theme/breakpoint awareness (TASK-025): mirror validate's merge-then-flatten so
15
+ // `why` resolves the same values a themed/breakpoint validate would. Both loaders
16
+ // fail closed on a missing/malformed file.
17
+ const bp = (options.breakpoint ?? parseBreakpoints(process.argv)[0]) as Breakpoint | undefined;
18
+ let tokens = loadTokensWithBreakpoint(bp, tokensPath);
19
+ let themeFlat: Record<string, FlatToken> | undefined;
20
+ if (options.theme) {
21
+ const themeTokens = loadThemeTokens(options.theme);
22
+ tokens = mergeTokens(tokens, themeTokens);
23
+ themeFlat = flattenTokens(themeTokens).flat as Record<string, FlatToken>;
24
+ }
14
25
  const { flat, edges } = flattenTokens(tokens);
15
26
  const target = options.tokenId;
16
27
 
@@ -50,6 +61,7 @@ export async function whyCommand(options: WhyOptions): Promise<void> {
50
61
 
51
62
  const baseReport = explain(target, flat, edges, {
52
63
  overrides: (overrides as any)?.overrides ?? overrides,
64
+ theme: themeFlat,
53
65
  });
54
66
 
55
67
  // Best-effort constraint summary: which rules currently implicate this token
@@ -87,7 +99,7 @@ export async function whyCommand(options: WhyOptions): Promise<void> {
87
99
  // Honor --constraints-dir, matching `validate` (default: themes).
88
100
  setupConstraints(
89
101
  engine,
90
- { config, constraintsDir: options['constraints-dir'] ?? 'themes' },
102
+ { config, constraintsDir: options['constraints-dir'] ?? 'themes', bp },
91
103
  { knownIds },
92
104
  );
93
105
 
package/cli/dcv.js CHANGED
@@ -4,6 +4,23 @@ import yargs from 'yargs/yargs';
4
4
  import { hideBin } from 'yargs/helpers';
5
5
  import { setCommand, buildCommand, validateCommand, graphCommand, whyCommand, patchCommand, patchApplyCommand } from './commands/index.js';
6
6
  import { getVersionInfo } from './version-banner.js';
7
+ // Uniform handler wrapper (TASK-035 B): without this, a thrown IO/config error
8
+ // in a command (e.g. patch:apply on a missing file) escapes as an unhandled
9
+ // rejection — Node prints a raw stack trace and exits 1. Wrapping gives every
10
+ // command the clean "exit 2 + one-line message" contract that `validate` already
11
+ // has and docs/JSON-OUTPUT.md promises. Commands that call process.exit()
12
+ // themselves are unaffected (they terminate before the catch).
13
+ function run(fn) {
14
+ return async (a) => {
15
+ try {
16
+ await fn(a);
17
+ }
18
+ catch (e) {
19
+ console.error(`dcv: ${e instanceof Error ? e.message : String(e)}`);
20
+ process.exit(2);
21
+ }
22
+ };
23
+ }
7
24
  const cli = yargs(hideBin(process.argv))
8
25
  .scriptName('dcv')
9
26
  // camel-case-expansion is intentionally OFF, so the CLI delivers flags only
@@ -27,7 +44,7 @@ cli.command('set [expressions..]', 'Set token values', y => y
27
44
  .option('output', { type: 'string' })
28
45
  .option('theme', { type: 'string' })
29
46
  .option('debug-set', { type: 'boolean', hidden: true }) // hidden debug aid (also DCV_DEBUG_SET=1)
30
- .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }), a => setCommand(a));
47
+ .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }), run(setCommand));
31
48
  cli.command('build', 'Build token outputs', y => y
32
49
  .option('format', { type: 'string', choices: ['css', 'json', 'js'], default: 'css' })
33
50
  .option('all-formats', { type: 'boolean', default: false })
@@ -35,7 +52,7 @@ cli.command('build', 'Build token outputs', y => y
35
52
  .option('mapper', { type: 'string' })
36
53
  .option('theme', { type: 'string' })
37
54
  .option('dry-run', { type: 'boolean', default: false })
38
- .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' }), a => buildCommand(a));
55
+ .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' }), run(buildCommand));
39
56
  cli.command('validate [tokens-path]', 'Validate constraints', y => y
40
57
  .positional('tokens-path', { type: 'string', describe: 'Path to a tokens file (positional alias for --tokens)' })
41
58
  .option('constraints-dir', { type: 'string', describe: 'Directory holding order / cross-axis constraint files (default: themes)' })
@@ -46,12 +63,12 @@ cli.command('validate [tokens-path]', 'Validate constraints', y => y
46
63
  .option('receipt', { type: 'string', describe: 'Generate validation receipt with audit trail' })
47
64
  .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' })
48
65
  .option('theme', { type: 'string', describe: 'Apply named theme tokens before validation' })
49
- .option('breakpoint', { type: 'string' })
66
+ .option('breakpoint', { type: 'string', choices: ['sm', 'md', 'lg'] })
50
67
  .option('all-breakpoints', { type: 'boolean' })
51
68
  .option('cross-axis-debug', { type: 'boolean', hidden: true }) // hidden debug aid
52
69
  .option('perf', { type: 'boolean', describe: 'Print timing info' })
53
70
  .option('budget-total-ms', { type: 'number', describe: 'Fail if total validation exceeds this (ms)' })
54
- .option('budget-per-bp-ms', { type: 'number', describe: 'Fail if any single breakpoint exceeds this (ms)' }), a => validateCommand(a));
71
+ .option('budget-per-bp-ms', { type: 'number', describe: 'Fail if any single breakpoint exceeds this (ms)' }), run(validateCommand));
55
72
  cli.command('graph', 'Generate dependency / constraint graph', y => y
56
73
  .option('format', { type: 'string', choices: ['json', 'mermaid', 'dot', 'svg', 'png'], default: 'json' })
57
74
  .option('bundle', { type: 'boolean', describe: 'When used with --hasse export mermaid+dot (+image if svg/png requested)' })
@@ -71,22 +88,25 @@ cli.command('graph', 'Generate dependency / constraint graph', y => y
71
88
  .option('image-from', { type: 'string', choices: ['mermaid', 'dot'], describe: 'Source format for svg/png export (default: mermaid)' })
72
89
  .option('focus', { type: 'string' })
73
90
  .option('radius', { type: 'number', default: 1 })
74
- .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' }), a => graphCommand(a));
91
+ .option('theme', { type: 'string', describe: 'Apply named theme tokens before computing --hasse violation overlays (dependency graph is unaffected)' })
92
+ .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' }), run(graphCommand));
75
93
  cli.command('why <tokenId>', 'Explain token provenance', y => y
76
94
  .positional('tokenId', { type: 'string', demandOption: true })
77
95
  .option('format', { type: 'string', choices: ['json', 'table'], default: 'json' })
78
96
  .option('constraints-dir', { type: 'string', describe: 'Directory holding order / cross-axis constraint files for the constraint summary (default: themes)' })
79
- .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }), a => whyCommand(a));
97
+ .option('theme', { type: 'string', describe: 'Apply named theme tokens (tokens/themes/<name>.json) before resolving values' })
98
+ .option('breakpoint', { type: 'string', choices: ['sm', 'md', 'lg'], describe: 'Resolve values for a breakpoint override' })
99
+ .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }), run(whyCommand));
80
100
  cli.command('patch', 'Export patch (diff) from overrides', y => y
81
101
  .option('overrides', { type: 'string', describe: 'Path or inline JSON of flat overrides' })
82
102
  .option('format', { type: 'string', choices: ['json', 'css', 'js'], default: 'json' })
83
103
  .option('output', { type: 'string' })
84
- .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }), a => patchCommand(a));
104
+ .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }), run(patchCommand));
85
105
  cli.command('patch:apply <patch>', 'Apply patch document to tokens', y => y
86
106
  .positional('patch', { type: 'string', demandOption: true })
87
107
  .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' })
88
108
  .option('output', { type: 'string', describe: 'Write updated tokens to this file' })
89
- .option('dry-run', { type: 'boolean', default: false }), a => patchApplyCommand(a));
109
+ .option('dry-run', { type: 'boolean', default: false }), run(patchApplyCommand));
90
110
  // Wire `--version` to the real package version (same source as the banner).
91
111
  // Without this, yargs can't locate package.json from the installed bin and
92
112
  // prints "unknown" (TASK-021 release smoke-test finding).
package/cli/dcv.ts CHANGED
@@ -6,6 +6,23 @@ import { type SetOptions, type BuildOptions, type ValidateOptions, type GraphOpt
6
6
  import { setCommand, buildCommand, validateCommand, graphCommand, whyCommand, patchCommand, patchApplyCommand } from './commands/index.js';
7
7
  import { getVersionInfo } from './version-banner.js';
8
8
 
9
+ // Uniform handler wrapper (TASK-035 B): without this, a thrown IO/config error
10
+ // in a command (e.g. patch:apply on a missing file) escapes as an unhandled
11
+ // rejection — Node prints a raw stack trace and exits 1. Wrapping gives every
12
+ // command the clean "exit 2 + one-line message" contract that `validate` already
13
+ // has and docs/JSON-OUTPUT.md promises. Commands that call process.exit()
14
+ // themselves are unaffected (they terminate before the catch).
15
+ function run<T>(fn: (a: T) => void | Promise<void>): (a: T) => Promise<void> {
16
+ return async (a: T) => {
17
+ try {
18
+ await fn(a);
19
+ } catch (e) {
20
+ console.error(`dcv: ${e instanceof Error ? e.message : String(e)}`);
21
+ process.exit(2);
22
+ }
23
+ };
24
+ }
25
+
9
26
  const cli = yargs(hideBin(process.argv))
10
27
  .scriptName('dcv')
11
28
  // camel-case-expansion is intentionally OFF, so the CLI delivers flags only
@@ -31,7 +48,7 @@ cli.command<SetOptions>('set [expressions..]', 'Set token values', y => y
31
48
  .option('theme', { type: 'string' })
32
49
  .option('debug-set', { type: 'boolean', hidden: true }) // hidden debug aid (also DCV_DEBUG_SET=1)
33
50
  .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }),
34
- a => setCommand(a)
51
+ run(setCommand)
35
52
  );
36
53
 
37
54
  cli.command<BuildOptions>('build', 'Build token outputs', y => y
@@ -42,7 +59,7 @@ cli.command<BuildOptions>('build', 'Build token outputs', y => y
42
59
  .option('theme', { type: 'string' })
43
60
  .option('dry-run', { type: 'boolean', default: false })
44
61
  .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' }),
45
- a => buildCommand(a)
62
+ run(buildCommand)
46
63
  );
47
64
 
48
65
  cli.command<ValidateOptions>('validate [tokens-path]', 'Validate constraints', y => y
@@ -55,13 +72,13 @@ cli.command<ValidateOptions>('validate [tokens-path]', 'Validate constraints', y
55
72
  .option('receipt', { type: 'string', describe: 'Generate validation receipt with audit trail' })
56
73
  .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' })
57
74
  .option('theme', { type: 'string', describe: 'Apply named theme tokens before validation' })
58
- .option('breakpoint', { type: 'string' })
75
+ .option('breakpoint', { type: 'string', choices: ['sm','md','lg'] })
59
76
  .option('all-breakpoints', { type: 'boolean' })
60
77
  .option('cross-axis-debug', { type: 'boolean', hidden: true }) // hidden debug aid
61
78
  .option('perf', { type: 'boolean', describe: 'Print timing info' })
62
79
  .option('budget-total-ms', { type: 'number', describe: 'Fail if total validation exceeds this (ms)' })
63
80
  .option('budget-per-bp-ms', { type: 'number', describe: 'Fail if any single breakpoint exceeds this (ms)' }),
64
- a => validateCommand(a)
81
+ run(validateCommand)
65
82
  );
66
83
 
67
84
  cli.command<GraphOptions>('graph', 'Generate dependency / constraint graph', y => y
@@ -83,16 +100,19 @@ cli.command<GraphOptions>('graph', 'Generate dependency / constraint graph', y =
83
100
  .option('image-from', { type: 'string', choices: ['mermaid','dot'], describe: 'Source format for svg/png export (default: mermaid)' })
84
101
  .option('focus', { type: 'string' })
85
102
  .option('radius', { type: 'number', default: 1 })
103
+ .option('theme', { type: 'string', describe: 'Apply named theme tokens before computing --hasse violation overlays (dependency graph is unaffected)' })
86
104
  .option('tokens', { type: 'string', describe: 'Path to a tokens file (defaults to tokens/tokens.example.json)' }),
87
- a => graphCommand(a)
105
+ run(graphCommand)
88
106
  );
89
107
 
90
108
  cli.command<WhyOptions>('why <tokenId>', 'Explain token provenance', y => y
91
109
  .positional('tokenId', { type: 'string', demandOption: true })
92
110
  .option('format', { type: 'string', choices: ['json','table'], default: 'json' })
93
111
  .option('constraints-dir', { type: 'string', describe: 'Directory holding order / cross-axis constraint files for the constraint summary (default: themes)' })
112
+ .option('theme', { type: 'string', describe: 'Apply named theme tokens (tokens/themes/<name>.json) before resolving values' })
113
+ .option('breakpoint', { type: 'string', choices: ['sm','md','lg'], describe: 'Resolve values for a breakpoint override' })
94
114
  .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }),
95
- a => whyCommand(a)
115
+ run(whyCommand)
96
116
  );
97
117
 
98
118
  cli.command<PatchOptions>('patch', 'Export patch (diff) from overrides', y => y
@@ -100,7 +120,7 @@ cli.command<PatchOptions>('patch', 'Export patch (diff) from overrides', y => y
100
120
  .option('format', { type: 'string', choices: ['json','css','js'], default: 'json' })
101
121
  .option('output', { type: 'string' })
102
122
  .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' }),
103
- a => patchCommand(a)
123
+ run(patchCommand)
104
124
  );
105
125
 
106
126
  cli.command<PatchApplyOptions>('patch:apply <patch>', 'Apply patch document to tokens', y => y
@@ -108,7 +128,7 @@ cli.command<PatchApplyOptions>('patch:apply <patch>', 'Apply patch document to t
108
128
  .option('tokens', { type: 'string', default: 'tokens/tokens.example.json' })
109
129
  .option('output', { type: 'string', describe: 'Write updated tokens to this file' })
110
130
  .option('dry-run', { type: 'boolean', default: false }),
111
- a => patchApplyCommand(a)
131
+ run(patchApplyCommand)
112
132
  );
113
133
 
114
134
  // Wire `--version` to the real package version (same source as the banner).
package/cli/types.d.ts CHANGED
@@ -81,11 +81,15 @@ export interface GraphOptions extends GlobalOptions {
81
81
  focus?: string;
82
82
  radius?: number;
83
83
  tokens?: string;
84
+ theme?: string;
85
+ breakpoint?: 'sm' | 'md' | 'lg';
84
86
  }
85
87
  export interface WhyOptions extends GlobalOptions {
86
88
  tokenId: string;
87
89
  'constraints-dir'?: string;
88
90
  format?: 'json' | 'table';
91
+ theme?: string;
92
+ breakpoint?: 'sm' | 'md' | 'lg';
89
93
  }
90
94
  export interface PatchOptions extends GlobalOptions {
91
95
  overrides?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"}
package/cli/types.ts CHANGED
@@ -77,11 +77,15 @@ export interface GraphOptions extends GlobalOptions {
77
77
  focus?: string;
78
78
  radius?: number;
79
79
  tokens?: string;
80
+ theme?: string;
81
+ breakpoint?: 'sm' | 'md' | 'lg';
80
82
  }
81
83
  export interface WhyOptions extends GlobalOptions {
82
84
  tokenId: string;
83
85
  'constraints-dir'?: string;
84
86
  format?: 'json' | 'table';
87
+ theme?: string;
88
+ breakpoint?: 'sm' | 'md' | 'lg';
85
89
  }
86
90
  export interface PatchOptions extends GlobalOptions {
87
91
  overrides?: string; // path to flat overrides json or inline json
@@ -1,5 +1,6 @@
1
1
  import type { TokenNode } from "./flatten.js";
2
2
  export type Breakpoint = "sm" | "md" | "lg";
3
+ export declare const BREAKPOINTS: readonly Breakpoint[];
3
4
  export declare function parseBreakpoints(argv: string[]): Breakpoint[];
4
5
  export declare function loadJsonSafe<T = unknown>(path: string): T | null;
5
6
  export declare function loadOrders(axis: string, bp?: Breakpoint): [string, "<=" | ">=", string][];
@@ -1 +1 @@
1
- {"version":3,"file":"breakpoints.d.ts","sourceRoot":"","sources":["breakpoints.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE5C,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,EAAE,CAM7D;AAED,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAOhE;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,UAAU,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,CAKzF;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,CAQtE;AAED,gFAAgF;AAChF;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAkBxF"}
1
+ {"version":3,"file":"breakpoints.d.ts","sourceRoot":"","sources":["breakpoints.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE5C,eAAO,MAAM,WAAW,EAAE,SAAS,UAAU,EAAuB,CAAC;AAErE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,EAAE,CAe7D;AAED,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAOhE;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,UAAU,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,CAKzF;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,CAQtE;AAED,gFAAgF;AAChF;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAkBxF"}
@@ -1,12 +1,21 @@
1
1
  // core/breakpoints.ts
2
2
  import fs from "node:fs";
3
+ export const BREAKPOINTS = ["sm", "md", "lg"];
3
4
  export function parseBreakpoints(argv) {
4
5
  const allIdx = argv.indexOf("--all-breakpoints");
5
6
  if (allIdx >= 0)
6
7
  return ["sm", "md", "lg"];
7
8
  const bpIdx = argv.indexOf("--breakpoint");
8
- if (bpIdx >= 0)
9
- return [argv[bpIdx + 1]];
9
+ if (bpIdx >= 0) {
10
+ // Validate the value (TASK-035 B): an unknown/missing breakpoint must not be
11
+ // accepted — it would load a non-existent override and validate the BASE
12
+ // tokens as if the breakpoint applied (a confident false green on a typo).
13
+ const val = argv[bpIdx + 1];
14
+ if (!val || !BREAKPOINTS.includes(val)) {
15
+ throw new Error(`Invalid --breakpoint "${val ?? ""}". Expected one of: ${BREAKPOINTS.join(", ")}.`);
16
+ }
17
+ return [val];
18
+ }
10
19
  return []; // no BP slicing requested
11
20
  }
12
21
  export function loadJsonSafe(path) {
@@ -4,11 +4,22 @@ import type { TokenNode } from "./flatten.js";
4
4
 
5
5
  export type Breakpoint = "sm" | "md" | "lg";
6
6
 
7
+ export const BREAKPOINTS: readonly Breakpoint[] = ["sm", "md", "lg"];
8
+
7
9
  export function parseBreakpoints(argv: string[]): Breakpoint[] {
8
10
  const allIdx = argv.indexOf("--all-breakpoints");
9
11
  if (allIdx >= 0) return ["sm", "md", "lg"];
10
12
  const bpIdx = argv.indexOf("--breakpoint");
11
- if (bpIdx >= 0) return [argv[bpIdx + 1] as Breakpoint];
13
+ if (bpIdx >= 0) {
14
+ // Validate the value (TASK-035 B): an unknown/missing breakpoint must not be
15
+ // accepted — it would load a non-existent override and validate the BASE
16
+ // tokens as if the breakpoint applied (a confident false green on a typo).
17
+ const val = argv[bpIdx + 1];
18
+ if (!val || !BREAKPOINTS.includes(val as Breakpoint)) {
19
+ throw new Error(`Invalid --breakpoint "${val ?? ""}". Expected one of: ${BREAKPOINTS.join(", ")}.`);
20
+ }
21
+ return [val as Breakpoint];
22
+ }
12
23
  return []; // no BP slicing requested
13
24
  }
14
25
 
@@ -1 +1 @@
1
- {"version":3,"file":"dtcg.d.ts","sourceRoot":"","sources":["dtcg.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAsDF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAU/E"}
1
+ {"version":3,"file":"dtcg.d.ts","sourceRoot":"","sources":["dtcg.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AA2DF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAU/E"}
package/core/dtcg.js CHANGED
@@ -64,7 +64,13 @@ function isColorObject(obj, type) {
64
64
  'hex' in obj);
65
65
  }
66
66
  function isDimensionObject(obj, type) {
67
- return (type ?? '').toLowerCase() === 'dimension' || ('value' in obj && 'unit' in obj);
67
+ if ((type ?? '').toLowerCase() === 'dimension')
68
+ return true;
69
+ // A bare { value: <number> } (with or without `unit`) is a dimension even when
70
+ // $type is absent — normalizeDimension defaults the unit to px. Previously this
71
+ // required `unit` too, so a unit-less dimension became an <unsupported> sentinel
72
+ // (TASK-035 E). isColorObject runs first, so a numeric `value` here is a length.
73
+ return 'value' in obj && typeof obj.value === 'number';
68
74
  }
69
75
  /**
70
76
  * Normalize a raw DTCG `$value` to the string/number form the engine expects.
package/core/dtcg.ts CHANGED
@@ -80,7 +80,12 @@ function isColorObject(obj: Record<string, unknown>, type?: string): boolean {
80
80
  }
81
81
 
82
82
  function isDimensionObject(obj: Record<string, unknown>, type?: string): boolean {
83
- return (type ?? '').toLowerCase() === 'dimension' || ('value' in obj && 'unit' in obj);
83
+ if ((type ?? '').toLowerCase() === 'dimension') return true;
84
+ // A bare { value: <number> } (with or without `unit`) is a dimension even when
85
+ // $type is absent — normalizeDimension defaults the unit to px. Previously this
86
+ // required `unit` too, so a unit-less dimension became an <unsupported> sentinel
87
+ // (TASK-035 E). isColorObject runs first, so a numeric `value` here is a length.
88
+ return 'value' in obj && typeof obj.value === 'number';
84
89
  }
85
90
 
86
91
  /**
package/core/patch.d.ts CHANGED
@@ -23,6 +23,13 @@ export interface BuildPatchOptions {
23
23
  breakpoint?: string;
24
24
  includeUnchanged?: boolean;
25
25
  }
26
+ /**
27
+ * Canonical hash of a token set's flattened id→value map. Shared by buildPatch
28
+ * (to stamp baseTokensHash) and patch:apply (to detect drift). They MUST use the
29
+ * exact same serialization — previously patch:apply used a different
30
+ * JSON.stringify form, so the drift warning fired on every apply (TASK-035 D).
31
+ */
32
+ export declare function computeBaseTokensHash(tokens: TokenNode): string;
26
33
  export declare function applyFlatOverrides(tokens: TokenNode, overrides?: Record<string, any>): void;
27
34
  export declare function buildPatch(opts: BuildPatchOptions): PatchDocument;
28
35
  //# sourceMappingURL=patch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"patch.d.ts","sourceRoot":"","sources":["patch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1D,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC;IACpC,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC;IAClC,IAAI,EAAE,QAAQ,GAAG,KAAK,GAAG,QAAQ,CAAC;CACnC;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,CAAC,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAMD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAuB3F;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAyEjE"}
1
+ {"version":3,"file":"patch.d.ts","sourceRoot":"","sources":["patch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1D,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC;IACpC,EAAE,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC;IAClC,IAAI,EAAE,QAAQ,GAAG,KAAK,GAAG,QAAQ,CAAC;CACnC;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,CAAC,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAMD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAK/D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAuB3F;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAwEjE"}
package/core/patch.js CHANGED
@@ -3,6 +3,18 @@ import { flattenTokens } from './flatten.js';
3
3
  function canonicalString(obj) {
4
4
  return JSON.stringify(obj, Object.keys(obj).sort(), 2);
5
5
  }
6
+ /**
7
+ * Canonical hash of a token set's flattened id→value map. Shared by buildPatch
8
+ * (to stamp baseTokensHash) and patch:apply (to detect drift). They MUST use the
9
+ * exact same serialization — previously patch:apply used a different
10
+ * JSON.stringify form, so the drift warning fired on every apply (TASK-035 D).
11
+ */
12
+ export function computeBaseTokensHash(tokens) {
13
+ const flat = flattenTokens(JSON.parse(JSON.stringify(tokens))).flat;
14
+ const values = {};
15
+ Object.keys(flat).sort().forEach((id) => { values[id] = flat[id]?.value; });
16
+ return createHash('sha256').update(canonicalString(values)).digest('hex');
17
+ }
6
18
  export function applyFlatOverrides(tokens, overrides) {
7
19
  if (!overrides)
8
20
  return;
@@ -36,10 +48,9 @@ export function buildPatch(opts) {
36
48
  const cloned = JSON.parse(JSON.stringify(opts.tokens));
37
49
  // Flatten original
38
50
  const baseFlat = flattenTokens(cloned).flat;
39
- // Canonical base tokens hash (id -> value) for drift detection when applying patch later
40
- const baseFlatValues = {};
41
- Object.keys(baseFlat).sort().forEach(id => { baseFlatValues[id] = baseFlat[id]?.value; });
42
- const baseTokensHash = createHash('sha256').update(canonicalString(baseFlatValues)).digest('hex');
51
+ // Canonical base tokens hash (id -> value) for drift detection when applying
52
+ // the patch later — uses the SHARED hash so patch:apply agrees (TASK-035 D).
53
+ const baseTokensHash = computeBaseTokensHash(opts.tokens);
43
54
  // Apply overrides on a fresh clone for diffing
44
55
  const modified = JSON.parse(JSON.stringify(opts.tokens));
45
56
  const removalIds = new Set();
package/core/patch.ts CHANGED
@@ -33,6 +33,19 @@ function canonicalString(obj: any): string {
33
33
  return JSON.stringify(obj, Object.keys(obj).sort(), 2);
34
34
  }
35
35
 
36
+ /**
37
+ * Canonical hash of a token set's flattened id→value map. Shared by buildPatch
38
+ * (to stamp baseTokensHash) and patch:apply (to detect drift). They MUST use the
39
+ * exact same serialization — previously patch:apply used a different
40
+ * JSON.stringify form, so the drift warning fired on every apply (TASK-035 D).
41
+ */
42
+ export function computeBaseTokensHash(tokens: TokenNode): string {
43
+ const flat = flattenTokens(JSON.parse(JSON.stringify(tokens))).flat as Record<string, any>;
44
+ const values: Record<string, any> = {};
45
+ Object.keys(flat).sort().forEach((id) => { values[id] = flat[id]?.value; });
46
+ return createHash('sha256').update(canonicalString(values)).digest('hex');
47
+ }
48
+
36
49
  export function applyFlatOverrides(tokens: TokenNode, overrides?: Record<string, any>): void {
37
50
  if (!overrides) return;
38
51
  for (const [id, val] of Object.entries(overrides)) {
@@ -62,10 +75,9 @@ export function buildPatch(opts: BuildPatchOptions): PatchDocument {
62
75
  const cloned = JSON.parse(JSON.stringify(opts.tokens));
63
76
  // Flatten original
64
77
  const baseFlat = flattenTokens(cloned as any).flat as Record<string, any>;
65
- // Canonical base tokens hash (id -> value) for drift detection when applying patch later
66
- const baseFlatValues: Record<string, any> = {};
67
- Object.keys(baseFlat).sort().forEach(id => { baseFlatValues[id] = baseFlat[id]?.value; });
68
- const baseTokensHash = createHash('sha256').update(canonicalString(baseFlatValues)).digest('hex');
78
+ // Canonical base tokens hash (id -> value) for drift detection when applying
79
+ // the patch later — uses the SHARED hash so patch:apply agrees (TASK-035 D).
80
+ const baseTokensHash = computeBaseTokensHash(opts.tokens);
69
81
  // Apply overrides on a fresh clone for diffing
70
82
  const modified = JSON.parse(JSON.stringify(opts.tokens));
71
83
  const removalIds = new Set<string>();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-constraint-validator",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Mathematical constraint validator for design systems — ensuring consistency, accessibility, and logical coherence",
5
5
  "type": "module",
6
6
  "engines": {
@@ -26,7 +26,7 @@
26
26
  "design-constraint-validator": "./cli/index.js",
27
27
  "dcv-mcp": "./mcp/index.js"
28
28
  },
29
- "mcpName": "io.github.cseperkepapp/design-constraint-validator",
29
+ "mcpName": "io.github.CseperkePapp/design-constraint-validator",
30
30
  "scripts": {
31
31
  "test": "vitest run --exclude \"**/*.test.js\"",
32
32
  "test:watch": "vitest --exclude \"**/*.test.js\"",
package/server.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
- "name": "io.github.cseperkepapp/design-constraint-validator",
3
+ "name": "io.github.CseperkePapp/design-constraint-validator",
4
4
  "title": "Design Constraint Validator",
5
5
  "description": "Validate design tokens for accessibility, scales, and design-system constraint consistency.",
6
- "version": "2.2.0",
6
+ "version": "2.3.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/CseperkePapp/design-constraint-validator",
9
9
  "source": "github"
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "design-constraint-validator",
15
- "version": "2.2.0",
15
+ "version": "2.3.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }