design-constraint-validator 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +17 -6
  2. package/cli/commands/build.d.ts.map +1 -1
  3. package/cli/commands/build.js +32 -24
  4. package/cli/commands/build.ts +26 -17
  5. package/cli/commands/graph.d.ts.map +1 -1
  6. package/cli/commands/graph.js +33 -16
  7. package/cli/commands/graph.ts +28 -15
  8. package/cli/commands/patch-apply.d.ts.map +1 -1
  9. package/cli/commands/patch-apply.js +4 -1
  10. package/cli/commands/patch-apply.ts +4 -1
  11. package/cli/commands/set.d.ts.map +1 -1
  12. package/cli/commands/set.js +18 -19
  13. package/cli/commands/set.ts +19 -19
  14. package/cli/commands/utils.d.ts +1 -0
  15. package/cli/commands/utils.d.ts.map +1 -1
  16. package/cli/commands/utils.js +20 -1
  17. package/cli/commands/utils.ts +23 -1
  18. package/cli/commands/validate.d.ts.map +1 -1
  19. package/cli/commands/validate.js +5 -17
  20. package/cli/commands/validate.ts +10 -18
  21. package/cli/commands/why.d.ts.map +1 -1
  22. package/cli/commands/why.js +22 -10
  23. package/cli/commands/why.ts +20 -9
  24. package/cli/config-schema.d.ts +144 -178
  25. package/cli/config-schema.d.ts.map +1 -1
  26. package/cli/config-schema.js +25 -5
  27. package/cli/config-schema.ts +27 -5
  28. package/cli/constraint-registry.d.ts.map +1 -1
  29. package/cli/constraint-registry.js +53 -15
  30. package/cli/constraint-registry.ts +53 -18
  31. package/cli/cross-axis-loader.d.ts +62 -0
  32. package/cli/cross-axis-loader.d.ts.map +1 -1
  33. package/cli/cross-axis-loader.js +186 -31
  34. package/cli/cross-axis-loader.ts +199 -24
  35. package/cli/dcv.js +23 -1
  36. package/cli/dcv.ts +23 -1
  37. package/cli/types.d.ts +19 -9
  38. package/cli/types.d.ts.map +1 -1
  39. package/cli/types.ts +23 -10
  40. package/cli/validate-api.d.ts.map +1 -1
  41. package/cli/validate-api.js +6 -1
  42. package/cli/validate-api.ts +6 -1
  43. package/core/constraints/cross-axis.d.ts.map +1 -1
  44. package/core/constraints/cross-axis.js +37 -9
  45. package/core/constraints/cross-axis.ts +37 -9
  46. package/core/constraints/monotonic.d.ts.map +1 -1
  47. package/core/constraints/monotonic.js +32 -8
  48. package/core/constraints/monotonic.ts +29 -8
  49. package/core/constraints/threshold.d.ts.map +1 -1
  50. package/core/constraints/threshold.js +24 -4
  51. package/core/constraints/threshold.ts +23 -4
  52. package/core/constraints/wcag.js +1 -1
  53. package/core/constraints/wcag.ts +1 -1
  54. package/core/flatten.d.ts.map +1 -1
  55. package/core/flatten.js +8 -0
  56. package/core/flatten.ts +9 -0
  57. package/core/poset.d.ts +6 -1
  58. package/core/poset.d.ts.map +1 -1
  59. package/core/poset.js +7 -2
  60. package/core/poset.ts +7 -2
  61. package/mcp/contracts.d.ts +1456 -13
  62. package/mcp/contracts.d.ts.map +1 -1
  63. package/mcp/contracts.js +45 -1
  64. package/mcp/contracts.ts +55 -1
  65. package/mcp/index.d.ts +6 -4
  66. package/mcp/index.d.ts.map +1 -1
  67. package/mcp/index.js +6 -3
  68. package/mcp/index.ts +28 -1
  69. package/mcp/insights.d.ts +94 -0
  70. package/mcp/insights.d.ts.map +1 -0
  71. package/mcp/insights.js +445 -0
  72. package/mcp/insights.ts +541 -0
  73. package/mcp/tools.d.ts +14 -3
  74. package/mcp/tools.d.ts.map +1 -1
  75. package/mcp/tools.js +133 -6
  76. package/mcp/tools.ts +188 -11
  77. package/package.json +2 -7
  78. package/server.json +3 -3
@@ -14,6 +14,7 @@ import { flattenTokens, type TokenNode, type FlatToken } from '../core/flatten.j
14
14
  import { Engine, type ConstraintIssue } from '../core/engine.js';
15
15
  import type { Breakpoint } from '../core/breakpoints.js';
16
16
  import { loadConfig } from './config.js';
17
+ import { validateConfig } from './config-schema.js';
17
18
  import { setupConstraints, collectReferencedIds } from './constraint-registry.js';
18
19
  import { formatViolation, type ConstraintViolation } from './json-output.js';
19
20
  import type { DcvConfig } from './types.js';
@@ -57,7 +58,11 @@ function readTokensFile(p: string): TokenNode {
57
58
 
58
59
  function resolveConfig(input: ValidateInput): DcvConfig {
59
60
  if (input.constraints !== undefined) {
60
- return { constraints: input.constraints };
61
+ const { value, errors } = validateConfig({ constraints: input.constraints });
62
+ if (errors) {
63
+ throw new Error(`Inline constraints validation failed:\n - ${errors.join('\n - ')}`);
64
+ }
65
+ return value!;
61
66
  }
62
67
  const res = loadConfig(input.configPath);
63
68
  if (!res.ok) {
@@ -1 +1 @@
1
- {"version":3,"file":"cross-axis.d.ts","sourceRoot":"","sources":["cross-axis.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GACrB;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;KAAE,CAAC;IACnD,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;QAAC,GAAG,EAAE,CAAC,CAAC,EAAC,MAAM,EAAE,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACrG,GACD;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAI,MAAM,CAAC;QAAC,KAAK,EAAE,CAAC,IAAI,EAAC,MAAM,EAAC,EAAE,EAAC,MAAM,EAAC,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACvH,CAAC;AAEN,MAAM,MAAM,GAAG,GAAG;IAChB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACjC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAqBF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAqDrF;AAGD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,aAAa,EAAE,CAkBlG"}
1
+ {"version":3,"file":"cross-axis.d.ts","sourceRoot":"","sources":["cross-axis.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GACrB;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;KAAE,CAAC;IACnD,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;QAAC,GAAG,EAAE,CAAC,CAAC,EAAC,MAAM,EAAE,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACrG,GACD;IACE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,GAAC,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACnD,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAI,MAAM,CAAC;QAAC,KAAK,EAAE,CAAC,IAAI,EAAC,MAAM,EAAC,EAAE,EAAC,MAAM,EAAC,GAAG,EAAE,GAAG,KAAI,MAAM,CAAA;KAAE,CAAC;CACvH,CAAC;AAEN,MAAM,MAAM,GAAG,GAAG;IAChB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACjC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAsCF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAgErF;AAGD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,SAAI,GAAG,aAAa,EAAE,CAkBlG"}
@@ -1,23 +1,40 @@
1
+ // Shares the hardened finite-size policy used by monotonic/threshold (TASK-037):
2
+ // bare numbers / unitless as px, `rem`/`em` as 16px-relative, real numbers only
3
+ // (rejects ".", "5.", "1.2.3px"), and non-finite guarded. The one addition over
4
+ // the canonical parser is a clamp()/min()/max()/calc() heuristic — but it is
5
+ // gated on a `(` so a malformed bare value like "1.2.3px" is rejected rather
6
+ // than silently yielding a partial parse.
1
7
  const px = (v) => {
2
8
  if (typeof v === 'number')
3
- return v;
9
+ return Number.isFinite(v) ? v : null;
4
10
  if (typeof v !== 'string')
5
11
  return null;
6
12
  const trimmed = v.trim();
7
- // Direct simple form
8
- let m = trimmed.match(/^([0-9.]+)\s*(px|rem)?$/i);
13
+ // Direct simple form: bare number / px / rem / em.
14
+ const m = trimmed.match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
9
15
  if (m) {
10
16
  const n = parseFloat(m[1]);
11
- return (m[2] || 'px').toLowerCase() === 'rem' ? n * 16 : n;
17
+ if (!Number.isFinite(n))
18
+ return null;
19
+ const unit = (m[2] || 'px').toLowerCase();
20
+ return unit === 'rem' || unit === 'em' ? n * 16 : n;
12
21
  }
13
- // Heuristic: extract first numeric size token (px or rem) inside complex expressions (e.g., clamp())
14
- const inner = trimmed.match(/([0-9.]+)\s*(px|rem)/i);
15
- if (inner) {
16
- const n = parseFloat(inner[1]);
17
- return inner[2].toLowerCase() === 'rem' ? n * 16 : n;
22
+ // Heuristic: first px/rem/em token inside a CSS function expression (clamp(),
23
+ // min(), max(), calc(), …). Gated on `(` so garbage simple values don't leak.
24
+ if (trimmed.includes('(')) {
25
+ const inner = trimmed.match(/(\d*\.?\d+)\s*(px|rem|em)/i);
26
+ if (inner) {
27
+ const n = parseFloat(inner[1]);
28
+ if (!Number.isFinite(n))
29
+ return null;
30
+ return inner[2].toLowerCase() === 'px' ? n : n * 16;
31
+ }
18
32
  }
19
33
  return null;
20
34
  };
35
+ // Distinguish "operand absent" (skip silently) from "operand present but
36
+ // unparseable" (warn loudly) — a silent skip of a present value is a false green.
37
+ const presentButUnparseable = (raw) => raw != null && raw !== '' && px(raw) === null;
21
38
  export function CrossAxisPlugin(rules, bp) {
22
39
  return {
23
40
  id: "cross-axis",
@@ -33,6 +50,17 @@ export function CrossAxisPlugin(rules, bp) {
33
50
  // Evaluate if either referenced id is among candidates (looser gating so global validate works)
34
51
  if (!candidates.has(r.when.id) && !candidates.has(r.require.id))
35
52
  continue;
53
+ // Present-but-unparseable operand → warn, don't silently skip (TASK-037).
54
+ if (presentButUnparseable(ctx.get(r.when.id)) || presentButUnparseable(ctx.get(r.require.id))) {
55
+ issues.push({
56
+ id: r.require.id,
57
+ rule: "cross-axis",
58
+ level: "warn",
59
+ where: r.where,
60
+ message: `Cannot check cross-axis rule "${r.id}": unparseable size value(s)`
61
+ });
62
+ continue;
63
+ }
36
64
  const wv = ctx.getPx(r.when.id);
37
65
  // compare-style loader rules set when.id = a, require.id = a; use same value if second missing
38
66
  let rv = ctx.getPx(r.require.id);
@@ -18,25 +18,42 @@ export type Ctx = {
18
18
  bp?: string;
19
19
  };
20
20
 
21
+ // Shares the hardened finite-size policy used by monotonic/threshold (TASK-037):
22
+ // bare numbers / unitless as px, `rem`/`em` as 16px-relative, real numbers only
23
+ // (rejects ".", "5.", "1.2.3px"), and non-finite guarded. The one addition over
24
+ // the canonical parser is a clamp()/min()/max()/calc() heuristic — but it is
25
+ // gated on a `(` so a malformed bare value like "1.2.3px" is rejected rather
26
+ // than silently yielding a partial parse.
21
27
  const px = (v: unknown): number | null => {
22
- if (typeof v === 'number') return v;
28
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
23
29
  if (typeof v !== 'string') return null;
24
30
  const trimmed = v.trim();
25
- // Direct simple form
26
- let m = trimmed.match(/^([0-9.]+)\s*(px|rem)?$/i);
31
+ // Direct simple form: bare number / px / rem / em.
32
+ const m = trimmed.match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
27
33
  if (m) {
28
34
  const n = parseFloat(m[1]);
29
- return (m[2] || 'px').toLowerCase() === 'rem' ? n * 16 : n;
35
+ if (!Number.isFinite(n)) return null;
36
+ const unit = (m[2] || 'px').toLowerCase();
37
+ return unit === 'rem' || unit === 'em' ? n * 16 : n;
30
38
  }
31
- // Heuristic: extract first numeric size token (px or rem) inside complex expressions (e.g., clamp())
32
- const inner = trimmed.match(/([0-9.]+)\s*(px|rem)/i);
33
- if (inner) {
34
- const n = parseFloat(inner[1]);
35
- return inner[2].toLowerCase() === 'rem' ? n * 16 : n;
39
+ // Heuristic: first px/rem/em token inside a CSS function expression (clamp(),
40
+ // min(), max(), calc(), …). Gated on `(` so garbage simple values don't leak.
41
+ if (trimmed.includes('(')) {
42
+ const inner = trimmed.match(/(\d*\.?\d+)\s*(px|rem|em)/i);
43
+ if (inner) {
44
+ const n = parseFloat(inner[1]);
45
+ if (!Number.isFinite(n)) return null;
46
+ return inner[2].toLowerCase() === 'px' ? n : n * 16;
47
+ }
36
48
  }
37
49
  return null;
38
50
  };
39
51
 
52
+ // Distinguish "operand absent" (skip silently) from "operand present but
53
+ // unparseable" (warn loudly) — a silent skip of a present value is a false green.
54
+ const presentButUnparseable = (raw: unknown): boolean =>
55
+ raw != null && raw !== '' && px(raw) === null;
56
+
40
57
  export function CrossAxisPlugin(rules: CrossAxisRule[], bp?: string): ConstraintPlugin {
41
58
  return {
42
59
  id: "cross-axis",
@@ -57,6 +74,17 @@ export function CrossAxisPlugin(rules: CrossAxisRule[], bp?: string): Constraint
57
74
  if ("when" in r) {
58
75
  // Evaluate if either referenced id is among candidates (looser gating so global validate works)
59
76
  if (!candidates.has(r.when.id) && !candidates.has(r.require.id)) continue;
77
+ // Present-but-unparseable operand → warn, don't silently skip (TASK-037).
78
+ if (presentButUnparseable(ctx.get(r.when.id)) || presentButUnparseable(ctx.get(r.require.id))) {
79
+ issues.push({
80
+ id: r.require.id,
81
+ rule: "cross-axis",
82
+ level: "warn",
83
+ where: r.where,
84
+ message: `Cannot check cross-axis rule "${r.id}": unparseable size value(s)`
85
+ });
86
+ continue;
87
+ }
60
88
  const wv = ctx.getPx(r.when.id);
61
89
  // compare-style loader rules set when.id = a, require.id = a; use same value if second missing
62
90
  let rv = ctx.getPx(r.require.id);
@@ -1 +1 @@
1
- {"version":3,"file":"monotonic.d.ts","sourceRoot":"","sources":["monotonic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,wBAAgB,eAAe,CAC7B,MAAM,EAAE,KAAK,EAAE,EACf,KAAK,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,EACpC,MAAM,SAAc,GACnB,gBAAgB,CAsBlB;AAGD,eAAO,MAAM,SAAS,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAO/C,CAAC;AAGF,eAAO,MAAM,WAAW,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAOjD,CAAC;AAGF,eAAO,MAAM,cAAc,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAoBpD,CAAC"}
1
+ {"version":3,"file":"monotonic.d.ts","sourceRoot":"","sources":["monotonic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,wBAAgB,eAAe,CAC7B,MAAM,EAAE,KAAK,EAAE,EACf,KAAK,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,EACpC,MAAM,SAAc,GACnB,gBAAgB,CAoClB;AAMD,eAAO,MAAM,SAAS,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAW/C,CAAC;AAGF,eAAO,MAAM,WAAW,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAOjD,CAAC;AAGF,eAAO,MAAM,cAAc,GAAI,GAAG,OAAO,KAAG,MAAM,GAAG,IAoBpD,CAAC"}
@@ -4,12 +4,27 @@ export function MonotonicPlugin(orders, parse, ruleId = "monotonic") {
4
4
  evaluate(engine, candidates) {
5
5
  const issues = [];
6
6
  for (const [a, op, b] of orders) {
7
- const va = parse(engine.get(a));
8
- const vb = parse(engine.get(b));
7
+ if (!candidates.has(a) && !candidates.has(b))
8
+ continue; // incremental
9
+ const rawA = engine.get(a);
10
+ const rawB = engine.get(b);
11
+ const va = parse(rawA);
12
+ const vb = parse(rawB);
13
+ // A present-but-unparseable size operand (e.g. "50%", "10vw") can't be
14
+ // compared — warn instead of silently skipping (TASK-031).
15
+ if ((rawA != null && rawA !== "" && va == null) || (rawB != null && rawB !== "" && vb == null)) {
16
+ issues.push({
17
+ id: `${a}|${b}`,
18
+ rule: "monotonic",
19
+ level: "warn",
20
+ message: `Cannot check ${a} ${op} ${b}: unparseable size value(s)`
21
+ });
22
+ continue;
23
+ }
9
24
  if (va == null || vb == null)
10
- continue; // skip unparseable
25
+ continue; // operand absent — nothing to check
11
26
  const ok = op === ">=" ? va >= vb : va <= vb;
12
- if (!ok && (candidates.has(a) || candidates.has(b))) {
27
+ if (!ok) {
13
28
  issues.push({
14
29
  id: `${a}|${b}`,
15
30
  rule: "monotonic",
@@ -22,16 +37,25 @@ export function MonotonicPlugin(orders, parse, ruleId = "monotonic") {
22
37
  }
23
38
  };
24
39
  }
25
- // a minimal parser for "rem"/"px" numbers
40
+ // Parse a size to px for comparison. Coerces bare numbers and unitless strings to
41
+ // px (TASK-031: numeric `$value` and aliases-to-numbers previously returned null,
42
+ // silently skipping the rule). `rem`/`em` are 16px-relative. Returns null only for
43
+ // genuinely unparseable operands (e.g. "50%", "10vw") — callers warn, not skip.
26
44
  export const parseSize = (v) => {
45
+ if (typeof v === "number")
46
+ return Number.isFinite(v) ? v : null; // bare number == px
27
47
  if (typeof v !== "string")
28
48
  return null;
29
- const m = v.trim().match(/^([0-9.]+)(rem|px)?$/i);
49
+ // Real number only: rejects ".", "5.", "1.2.3px" (which previously slipped
50
+ // through `[0-9.]+` + parseFloat as NaN/garbage and became spurious errors).
51
+ const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
30
52
  if (!m)
31
53
  return null;
32
54
  const num = parseFloat(m[1]);
33
- const unit = (m[2] || "rem").toLowerCase();
34
- return unit === "px" ? num : num * 16; // assume 1rem=16px for comparisons
55
+ if (!Number.isFinite(num))
56
+ return null;
57
+ const unit = (m[2] || "px").toLowerCase();
58
+ return unit === "rem" || unit === "em" ? num * 16 : num; // px and unitless as-is
35
59
  };
36
60
  // Parser for unitless numbers (like scale factors)
37
61
  export const parseNumber = (v) => {
@@ -12,11 +12,25 @@ export function MonotonicPlugin(
12
12
  evaluate(engine, candidates) {
13
13
  const issues = [];
14
14
  for (const [a, op, b] of orders) {
15
- const va = parse(engine.get(a));
16
- const vb = parse(engine.get(b));
17
- if (va == null || vb == null) continue; // skip unparseable
15
+ if (!candidates.has(a) && !candidates.has(b)) continue; // incremental
16
+ const rawA = engine.get(a);
17
+ const rawB = engine.get(b);
18
+ const va = parse(rawA);
19
+ const vb = parse(rawB);
20
+ // A present-but-unparseable size operand (e.g. "50%", "10vw") can't be
21
+ // compared — warn instead of silently skipping (TASK-031).
22
+ if ((rawA != null && rawA !== "" && va == null) || (rawB != null && rawB !== "" && vb == null)) {
23
+ issues.push({
24
+ id: `${a}|${b}`,
25
+ rule: "monotonic",
26
+ level: "warn" as const,
27
+ message: `Cannot check ${a} ${op} ${b}: unparseable size value(s)`
28
+ });
29
+ continue;
30
+ }
31
+ if (va == null || vb == null) continue; // operand absent — nothing to check
18
32
  const ok = op === ">=" ? va >= vb : va <= vb;
19
- if (!ok && (candidates.has(a) || candidates.has(b))) {
33
+ if (!ok) {
20
34
  issues.push({
21
35
  id: `${a}|${b}`,
22
36
  rule: "monotonic",
@@ -30,14 +44,21 @@ export function MonotonicPlugin(
30
44
  };
31
45
  }
32
46
 
33
- // a minimal parser for "rem"/"px" numbers
47
+ // Parse a size to px for comparison. Coerces bare numbers and unitless strings to
48
+ // px (TASK-031: numeric `$value` and aliases-to-numbers previously returned null,
49
+ // silently skipping the rule). `rem`/`em` are 16px-relative. Returns null only for
50
+ // genuinely unparseable operands (e.g. "50%", "10vw") — callers warn, not skip.
34
51
  export const parseSize = (v: unknown): number | null => {
52
+ if (typeof v === "number") return Number.isFinite(v) ? v : null; // bare number == px
35
53
  if (typeof v !== "string") return null;
36
- const m = v.trim().match(/^([0-9.]+)(rem|px)?$/i);
54
+ // Real number only: rejects ".", "5.", "1.2.3px" (which previously slipped
55
+ // through `[0-9.]+` + parseFloat as NaN/garbage and became spurious errors).
56
+ const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
37
57
  if (!m) return null;
38
58
  const num = parseFloat(m[1]);
39
- const unit = (m[2] || "rem").toLowerCase();
40
- return unit === "px" ? num : num * 16; // assume 1rem=16px for comparisons
59
+ if (!Number.isFinite(num)) return null;
60
+ const unit = (m[2] || "px").toLowerCase();
61
+ return unit === "rem" || unit === "em" ? num * 16 : num; // px and unitless as-is
41
62
  };
42
63
 
43
64
  // Parser for unitless numbers (like scale factors)
@@ -1 +1 @@
1
- {"version":3,"file":"threshold.d.ts","sourceRoot":"","sources":["threshold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,IAAI,GAAG,IAAI,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC1B,CAAC;AAWF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,MAAM,SAAc,GAAG,gBAAgB,CAuB9F"}
1
+ {"version":3,"file":"threshold.d.ts","sourceRoot":"","sources":["threshold.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,IAAI,GAAG,IAAI,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC1B,CAAC;AAiBF,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,MAAM,SAAc,GAAG,gBAAgB,CAoC9F"}
@@ -1,12 +1,20 @@
1
+ // Coerces bare numbers and unitless strings to px (TASK-031: numeric `$value`
2
+ // previously returned null, silently skipping the threshold). `rem`/`em` are
3
+ // 16px-relative. Returns null only for genuinely unparseable operands.
1
4
  const parseSizePx = (v) => {
5
+ if (typeof v === "number")
6
+ return Number.isFinite(v) ? v : null;
2
7
  if (typeof v !== "string")
3
8
  return null;
4
- const m = v.trim().match(/^([0-9.]+)\s*(px|rem)?$/i);
9
+ // Real number only — rejects ".", "5.", "1.2.3px" (TASK-034).
10
+ const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
5
11
  if (!m)
6
12
  return null;
7
13
  const n = parseFloat(m[1]);
14
+ if (!Number.isFinite(n))
15
+ return null;
8
16
  const unit = (m[2] || "px").toLowerCase();
9
- return unit === "rem" ? n * 16 : n;
17
+ return unit === "rem" || unit === "em" ? n * 16 : n;
10
18
  };
11
19
  export function ThresholdPlugin(rules, ruleId = "threshold") {
12
20
  return {
@@ -16,9 +24,21 @@ export function ThresholdPlugin(rules, ruleId = "threshold") {
16
24
  for (const r of rules) {
17
25
  if (!candidates.has(r.id))
18
26
  continue; // incremental
19
- const px = parseSizePx(engine.get(r.id));
20
- if (px == null)
27
+ const raw = engine.get(r.id);
28
+ const px = parseSizePx(raw);
29
+ if (px == null) {
30
+ // Present-but-unparseable size — warn instead of silently skipping (TASK-031).
31
+ if (raw != null && raw !== "") {
32
+ issues.push({
33
+ id: r.id,
34
+ rule: ruleId,
35
+ level: "warn",
36
+ where: r.where,
37
+ message: `Cannot check ${r.id} ${r.op} ${r.valuePx}px: unparseable size value`
38
+ });
39
+ }
21
40
  continue;
41
+ }
22
42
  const ok = r.op === ">=" ? px >= r.valuePx : px <= r.valuePx;
23
43
  if (!ok) {
24
44
  issues.push({
@@ -8,13 +8,19 @@ export type ThresholdRule = {
8
8
  level?: "error" | "warn"; // default error
9
9
  };
10
10
 
11
+ // Coerces bare numbers and unitless strings to px (TASK-031: numeric `$value`
12
+ // previously returned null, silently skipping the threshold). `rem`/`em` are
13
+ // 16px-relative. Returns null only for genuinely unparseable operands.
11
14
  const parseSizePx = (v: unknown): number | null => {
15
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
12
16
  if (typeof v !== "string") return null;
13
- const m = v.trim().match(/^([0-9.]+)\s*(px|rem)?$/i);
17
+ // Real number only — rejects ".", "5.", "1.2.3px" (TASK-034).
18
+ const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
14
19
  if (!m) return null;
15
20
  const n = parseFloat(m[1]);
21
+ if (!Number.isFinite(n)) return null;
16
22
  const unit = (m[2] || "px").toLowerCase();
17
- return unit === "rem" ? n * 16 : n;
23
+ return unit === "rem" || unit === "em" ? n * 16 : n;
18
24
  };
19
25
 
20
26
  export function ThresholdPlugin(rules: ThresholdRule[], ruleId = "threshold"): ConstraintPlugin {
@@ -24,8 +30,21 @@ export function ThresholdPlugin(rules: ThresholdRule[], ruleId = "threshold"): C
24
30
  const issues = [];
25
31
  for (const r of rules) {
26
32
  if (!candidates.has(r.id)) continue; // incremental
27
- const px = parseSizePx(engine.get(r.id));
28
- if (px == null) continue;
33
+ const raw = engine.get(r.id);
34
+ const px = parseSizePx(raw);
35
+ if (px == null) {
36
+ // Present-but-unparseable size — warn instead of silently skipping (TASK-031).
37
+ if (raw != null && raw !== "") {
38
+ issues.push({
39
+ id: r.id,
40
+ rule: ruleId,
41
+ level: "warn" as const,
42
+ where: r.where,
43
+ message: `Cannot check ${r.id} ${r.op} ${r.valuePx}px: unparseable size value`
44
+ });
45
+ }
46
+ continue;
47
+ }
29
48
  const ok = r.op === ">=" ? px >= r.valuePx : px <= r.valuePx;
30
49
  if (!ok) {
31
50
  issues.push({
@@ -3,7 +3,7 @@ function resolveColor(engineGet, x) {
3
3
  if (typeof x !== "string")
4
4
  return undefined;
5
5
  // If it's a CSS color literal, return as is; else treat as token id.
6
- const isLiteral = /^#|^rgb|^hsl|^oklch|^oklab|^transparent/i.test(x);
6
+ const isLiteral = /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|transparent$)/i.test(x);
7
7
  return isLiteral ? x : String(engineGet(x) ?? "");
8
8
  }
9
9
  export function WcagContrastPlugin(pairs) {
@@ -14,7 +14,7 @@ export type ContrastPair = {
14
14
  function resolveColor(engineGet: (id: TokenId)=>unknown, x: TokenId | string): string | undefined {
15
15
  if (typeof x !== "string") return undefined;
16
16
  // If it's a CSS color literal, return as is; else treat as token id.
17
- const isLiteral = /^#|^rgb|^hsl|^oklch|^oklab|^transparent/i.test(x);
17
+ const isLiteral = /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|transparent$)/i.test(x);
18
18
  return isLiteral ? x : String(engineGet(x) ?? "");
19
19
  }
20
20
 
@@ -1 +1 @@
1
- {"version":3,"file":"flatten.d.ts","sourceRoot":"","sources":["flatten.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAC7B,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAGzC,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC1D,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,UAAU,GAAG,mBAAmB,CAAC;IAC1C,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC5E,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,UAAU,CAAC;IAClB,GAAG,EAAE,UAAU,GAAG,mBAAmB,CAAC;IACtC,IAAI,EAAE,OAAO,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjC,KAAK,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC;CAC5C,CAAC;AAKF,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,GAAG,aAAa,CA4G5D"}
1
+ {"version":3,"file":"flatten.d.ts","sourceRoot":"","sources":["flatten.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAC7B,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAGzC,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC1D,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,UAAU,GAAG,mBAAmB,CAAC;IAC1C,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC5E,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,UAAU,CAAC;IAClB,GAAG,EAAE,UAAU,GAAG,mBAAmB,CAAC;IACtC,IAAI,EAAE,OAAO,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjC,KAAK,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC;CAC5C,CAAC;AAKF,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,GAAG,aAAa,CAqH5D"}
package/core/flatten.js CHANGED
@@ -2,6 +2,14 @@ import { normalizeDtcgValue } from "./dtcg.js";
2
2
  const REF_RE = /\{([a-z0-9_.-]+)\}/gi;
3
3
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4
4
  export function flattenTokens(root) {
5
+ // Fail closed on a malformed token root (TASK-017). A non-object root — null,
6
+ // an array, or a scalar — previously walked to a silently-empty set that
7
+ // "validated" with ok:true; that hid garbage input. The token root must be a
8
+ // JSON object. (An empty object `{}` is still valid — it just has no tokens.)
9
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
10
+ const got = root === null ? 'null' : Array.isArray(root) ? 'array' : typeof root;
11
+ throw new Error(`Token root must be a JSON object (got ${got}).`);
12
+ }
5
13
  const flat = {};
6
14
  const edges = [];
7
15
  // First pass: collect all tokens
package/core/flatten.ts CHANGED
@@ -29,6 +29,15 @@ const REF_RE = /\{([a-z0-9_.-]+)\}/gi;
29
29
  const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
30
 
31
31
  export function flattenTokens(root: TokenNode): FlattenResult {
32
+ // Fail closed on a malformed token root (TASK-017). A non-object root — null,
33
+ // an array, or a scalar — previously walked to a silently-empty set that
34
+ // "validated" with ok:true; that hid garbage input. The token root must be a
35
+ // JSON object. (An empty object `{}` is still valid — it just has no tokens.)
36
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
37
+ const got = root === null ? 'null' : Array.isArray(root) ? 'array' : typeof root;
38
+ throw new Error(`Token root must be a JSON object (got ${got}).`);
39
+ }
40
+
32
41
  const flat: Record<TokenId, FlatToken> = {};
33
42
  const edges: Array<[TokenId, TokenId]> = [];
34
43
 
package/core/poset.d.ts CHANGED
@@ -3,7 +3,12 @@ export type Comp = "<=" | ">=";
3
3
  export type Order = [Id, Comp, Id];
4
4
  export type Digraph = Map<Id, Set<Id>>;
5
5
  export type EdgeLabels = Map<string, string>;
6
- /** Safe ID for Mermaid/DOT node identifiers */
6
+ /**
7
+ * Safe, INJECTIVE node identifier for Mermaid/DOT. Each non-alphanumeric byte
8
+ * (including `_`) is encoded as `_<charCode>_`, so distinct token ids can never
9
+ * collapse to the same node id (e.g. `a.b` -> `a_46_b`, `a_b` -> `a_95_b`).
10
+ * A naive `[^a-zA-Z0-9_] -> _` replacement merged those, corrupting graphs.
11
+ */
7
12
  export declare function sanitizeId(id: string): string;
8
13
  export type Highlight = {
9
14
  nodes?: Set<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"poset.d.ts","sourceRoot":"","sources":["poset.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,EAAE,GAAG,MAAM,CAAC;AACxB,MAAM,MAAM,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAC/B,MAAM,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;AAEnC,MAAM,MAAM,OAAO,GAAG,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;AAGvC,MAAM,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAM7C,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,UAAU,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,OAAO,CAYnD;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAwBvD;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,EAAC,KAAe,EAAC;;CAAK,GAAG,MAAM,CAQ9E;AAGD,wBAAgB,oBAAoB,CAClC,CAAC,EAAE,OAAO,EACV,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAAO,GACxE,MAAM,CAuCR;AAED,wBAAgB,UAAU,CACxB,MAAM,EAAE,OAAO,EACf,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAAO,GACjD,MAAM,CAoBR;AAGD,wBAAgB,gBAAgB,CAC9B,CAAC,EAAE,OAAO,EACV,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAAO,GACxE,MAAM,CAyCR;AAGD,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAA;CAAE,CA2C7E;AAED,wBAAgB,aAAa,CAC3B,CAAC,EAAE,OAAO,EACV,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,GACjC,OAAO,CAYT;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAKtE;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAK3E;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,SAAI,GAAG,OAAO,CA8B3E;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAOxF"}
1
+ {"version":3,"file":"poset.d.ts","sourceRoot":"","sources":["poset.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,EAAE,GAAG,MAAM,CAAC;AACxB,MAAM,MAAM,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAC/B,MAAM,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;AAEnC,MAAM,MAAM,OAAO,GAAG,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;AAGvC,MAAM,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAM7C;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,UAAU,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,OAAO,CAYnD;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAwBvD;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,EAAC,KAAe,EAAC;;CAAK,GAAG,MAAM,CAQ9E;AAGD,wBAAgB,oBAAoB,CAClC,CAAC,EAAE,OAAO,EACV,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAAO,GACxE,MAAM,CAuCR;AAED,wBAAgB,UAAU,CACxB,MAAM,EAAE,OAAO,EACf,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAAO,GACjD,MAAM,CAoBR;AAGD,wBAAgB,gBAAgB,CAC9B,CAAC,EAAE,OAAO,EACV,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAA;CAAO,GACxE,MAAM,CAyCR;AAGD,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAA;CAAE,CA2C7E;AAED,wBAAgB,aAAa,CAC3B,CAAC,EAAE,OAAO,EACV,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,GACjC,OAAO,CAYT;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAKtE;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAK3E;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,SAAI,GAAG,OAAO,CA8B3E;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAOxF"}
package/core/poset.js CHANGED
@@ -3,9 +3,14 @@
3
3
  // Small escapes so Mermaid/DOT don't choke on special characters
4
4
  const escMermaid = (s) => s.replace(/"/g, '\\"').replace(/\n/g, "\\n");
5
5
  const escDot = (s) => s.replace(/"/g, '\\"').replace(/\n/g, "\\n");
6
- /** Safe ID for Mermaid/DOT node identifiers */
6
+ /**
7
+ * Safe, INJECTIVE node identifier for Mermaid/DOT. Each non-alphanumeric byte
8
+ * (including `_`) is encoded as `_<charCode>_`, so distinct token ids can never
9
+ * collapse to the same node id (e.g. `a.b` -> `a_46_b`, `a_b` -> `a_95_b`).
10
+ * A naive `[^a-zA-Z0-9_] -> _` replacement merged those, corrupting graphs.
11
+ */
7
12
  export function sanitizeId(id) {
8
- return id.replace(/[^a-zA-Z0-9_]/g, "_");
13
+ return id.replace(/[^a-zA-Z0-9]/g, (c) => `_${c.charCodeAt(0)}_`);
9
14
  }
10
15
  export function buildPoset(orders) {
11
16
  const g = new Map();
package/core/poset.ts CHANGED
@@ -14,9 +14,14 @@ export type EdgeLabels = Map<string, string>; // key = "a|b" (raw ids)
14
14
  const escMermaid = (s: string) => s.replace(/"/g, '\\"').replace(/\n/g, "\\n");
15
15
  const escDot = (s: string) => s.replace(/"/g, '\\"').replace(/\n/g, "\\n");
16
16
 
17
- /** Safe ID for Mermaid/DOT node identifiers */
17
+ /**
18
+ * Safe, INJECTIVE node identifier for Mermaid/DOT. Each non-alphanumeric byte
19
+ * (including `_`) is encoded as `_<charCode>_`, so distinct token ids can never
20
+ * collapse to the same node id (e.g. `a.b` -> `a_46_b`, `a_b` -> `a_95_b`).
21
+ * A naive `[^a-zA-Z0-9_] -> _` replacement merged those, corrupting graphs.
22
+ */
18
23
  export function sanitizeId(id: string): string {
19
- return id.replace(/[^a-zA-Z0-9_]/g, "_");
24
+ return id.replace(/[^a-zA-Z0-9]/g, (c) => `_${c.charCodeAt(0)}_`);
20
25
  }
21
26
 
22
27
  export type Highlight = {