@usefragments/core 1.0.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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-243QYRUF.js +2145 -0
  3. package/dist/chunk-243QYRUF.js.map +1 -0
  4. package/dist/chunk-3LLRNCPX.js +111 -0
  5. package/dist/chunk-3LLRNCPX.js.map +1 -0
  6. package/dist/chunk-EIYNNS77.js +48 -0
  7. package/dist/chunk-EIYNNS77.js.map +1 -0
  8. package/dist/chunk-JNBFJ34I.js +326 -0
  9. package/dist/chunk-JNBFJ34I.js.map +1 -0
  10. package/dist/chunk-R3X7UBHU.js +228 -0
  11. package/dist/chunk-R3X7UBHU.js.map +1 -0
  12. package/dist/chunk-SH4KPIYH.js +393 -0
  13. package/dist/chunk-SH4KPIYH.js.map +1 -0
  14. package/dist/chunk-V4VQB57N.js +56 -0
  15. package/dist/chunk-V4VQB57N.js.map +1 -0
  16. package/dist/chunk-X34IA4LR.js +319 -0
  17. package/dist/chunk-X34IA4LR.js.map +1 -0
  18. package/dist/chunk-YF65VYRY.js +493 -0
  19. package/dist/chunk-YF65VYRY.js.map +1 -0
  20. package/dist/codes/index.d.ts +40 -0
  21. package/dist/codes/index.js +23 -0
  22. package/dist/codes/index.js.map +1 -0
  23. package/dist/compiled-types/index.d.ts +5 -0
  24. package/dist/compiled-types/index.js +1 -0
  25. package/dist/compiled-types/index.js.map +1 -0
  26. package/dist/generate/index.d.ts +47 -0
  27. package/dist/generate/index.js +11 -0
  28. package/dist/generate/index.js.map +1 -0
  29. package/dist/governance-BOa3KyiJ.d.ts +3279 -0
  30. package/dist/graph/index.d.ts +66 -0
  31. package/dist/graph/index.js +17 -0
  32. package/dist/graph/index.js.map +1 -0
  33. package/dist/index.d.ts +11388 -0
  34. package/dist/index.js +8711 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/package-identity.d.ts +42 -0
  37. package/dist/package-identity.js +23 -0
  38. package/dist/package-identity.js.map +1 -0
  39. package/dist/preview/index.d.ts +484 -0
  40. package/dist/preview/index.js +1129 -0
  41. package/dist/preview/index.js.map +1 -0
  42. package/dist/react-types.d.ts +22 -0
  43. package/dist/react-types.js +1 -0
  44. package/dist/react-types.js.map +1 -0
  45. package/dist/registry.d.ts +2367 -0
  46. package/dist/registry.js +62 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/schemas/index.d.ts +2584 -0
  49. package/dist/schemas/index.js +55 -0
  50. package/dist/schemas/index.js.map +1 -0
  51. package/dist/severity.d.ts +18 -0
  52. package/dist/severity.js +27 -0
  53. package/dist/severity.js.map +1 -0
  54. package/dist/test-utils.d.ts +14 -0
  55. package/dist/test-utils.js +63 -0
  56. package/dist/test-utils.js.map +1 -0
  57. package/dist/topology/index.d.ts +48 -0
  58. package/dist/topology/index.js +7 -0
  59. package/dist/topology/index.js.map +1 -0
  60. package/dist/types-xJ2xyp_G.d.ts +119 -0
  61. package/package.json +120 -0
  62. package/src/__tests__/contract-parser.test.ts +614 -0
  63. package/src/__tests__/dtcg-generators.test.ts +439 -0
  64. package/src/__tests__/dtcg-parser.test.ts +884 -0
  65. package/src/__tests__/preview-runtime.test.tsx +111 -0
  66. package/src/__tests__/schema.test.ts +31 -0
  67. package/src/__tests__/severity.test.ts +34 -0
  68. package/src/__tests__/style-comparison.test.ts +662 -0
  69. package/src/__tests__/tokens-compat.test.ts +51 -0
  70. package/src/agent-format.test.ts +105 -0
  71. package/src/agent-format.ts +141 -0
  72. package/src/bundle.ts +149 -0
  73. package/src/codes/__tests__/codes.test.ts +73 -0
  74. package/src/codes/codes.ts +463 -0
  75. package/src/codes/index.ts +7 -0
  76. package/src/codes/source-finding-bridge.ts +113 -0
  77. package/src/compiled-types/index.ts +293 -0
  78. package/src/component-contract.ts +231 -0
  79. package/src/component-discovery.ts +80 -0
  80. package/src/composition.test.ts +262 -0
  81. package/src/composition.ts +318 -0
  82. package/src/config-paths.test.ts +78 -0
  83. package/src/config-paths.ts +47 -0
  84. package/src/config.ts +18 -0
  85. package/src/conform-prove.test.ts +274 -0
  86. package/src/conform-prove.ts +326 -0
  87. package/src/conform.ts +160 -0
  88. package/src/constants.ts +114 -0
  89. package/src/context.ts +2 -0
  90. package/src/contract/fixture.ts +110 -0
  91. package/src/contract/hash.test.ts +86 -0
  92. package/src/contract/hash.ts +195 -0
  93. package/src/contract/index.ts +43 -0
  94. package/src/contract/preimage.test.ts +254 -0
  95. package/src/contract/preimage.ts +365 -0
  96. package/src/contract/stamp.test.ts +100 -0
  97. package/src/contract/stamp.ts +105 -0
  98. package/src/contract-parser.ts +141 -0
  99. package/src/defineFragment.test.ts +257 -0
  100. package/src/defineFragment.ts +293 -0
  101. package/src/dtcg-generators.ts +324 -0
  102. package/src/dtcg-parser.ts +1 -0
  103. package/src/dtcg.ts +237 -0
  104. package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
  105. package/src/facts/builders.ts +835 -0
  106. package/src/facts/compile.ts +422 -0
  107. package/src/facts/fact-index.ts +394 -0
  108. package/src/facts/facts.test.ts +494 -0
  109. package/src/facts/ids.ts +125 -0
  110. package/src/facts/index.ts +119 -0
  111. package/src/facts/types.ts +614 -0
  112. package/src/figma.ts +263 -0
  113. package/src/fragment-types.ts +214 -0
  114. package/src/generate/generate.test.ts +594 -0
  115. package/src/generate/index.ts +438 -0
  116. package/src/governance-integrity.test.ts +186 -0
  117. package/src/governance-integrity.ts +282 -0
  118. package/src/governance.test.ts +300 -0
  119. package/src/governance.ts +605 -0
  120. package/src/governance.type-test.ts +75 -0
  121. package/src/graph/engine.ts +446 -0
  122. package/src/graph/graph.test.ts +746 -0
  123. package/src/graph/index.ts +32 -0
  124. package/src/graph/serialization.ts +55 -0
  125. package/src/graph/types.ts +178 -0
  126. package/src/index.ts +891 -0
  127. package/src/local-canonical.ts +102 -0
  128. package/src/mcp-snapshot.ts +222 -0
  129. package/src/package-identity-match.ts +33 -0
  130. package/src/package-identity.generated.ts +233 -0
  131. package/src/package-identity.test.ts +99 -0
  132. package/src/package-identity.ts +157 -0
  133. package/src/performance-presets.ts +142 -0
  134. package/src/preview/comparison-artifact.test.ts +228 -0
  135. package/src/preview/comparison-artifact.ts +181 -0
  136. package/src/preview/comparison-context.ts +93 -0
  137. package/src/preview/comparison-evidence.ts +112 -0
  138. package/src/preview/comparison-format.ts +21 -0
  139. package/src/preview/comparison-presentation.ts +187 -0
  140. package/src/preview/comparison-references.test.ts +430 -0
  141. package/src/preview/comparison-references.ts +72 -0
  142. package/src/preview/comparison-score.ts +59 -0
  143. package/src/preview/comparison-styles.ts +197 -0
  144. package/src/preview/comparison.test.ts +559 -0
  145. package/src/preview/comparison.ts +488 -0
  146. package/src/preview/index.ts +10 -0
  147. package/src/preview/types.ts +309 -0
  148. package/src/preview/validation.test.ts +290 -0
  149. package/src/preview/validation.ts +356 -0
  150. package/src/preview-runtime.tsx +144 -0
  151. package/src/raw-html-canonical.test.ts +41 -0
  152. package/src/raw-html-canonical.ts +179 -0
  153. package/src/react-types.ts +21 -0
  154. package/src/registry-install-plan.ts +344 -0
  155. package/src/registry.test.ts +245 -0
  156. package/src/registry.ts +369 -0
  157. package/src/rules/a11y-required-accessible-name.ts +93 -0
  158. package/src/rules/color-math.test.ts +72 -0
  159. package/src/rules/color-math.ts +192 -0
  160. package/src/rules/components-forbidden-prop-value.ts +108 -0
  161. package/src/rules/components-prefer-library.ts +943 -0
  162. package/src/rules/components-unknown-prop.ts +94 -0
  163. package/src/rules/composition-pattern-schema.ts +125 -0
  164. package/src/rules/composition-pattern.test.ts +320 -0
  165. package/src/rules/composition-pattern.ts +271 -0
  166. package/src/rules/emit-gate.test.ts +42 -0
  167. package/src/rules/emit-gate.ts +60 -0
  168. package/src/rules/finding.ts +54 -0
  169. package/src/rules/index.ts +235 -0
  170. package/src/rules/jsx-preferred-component.ts +68 -0
  171. package/src/rules/jsx-preferred-import-path.ts +67 -0
  172. package/src/rules/presets.ts +57 -0
  173. package/src/rules/props-invalid-value.ts +115 -0
  174. package/src/rules/rules.test.ts +3636 -0
  175. package/src/rules/spacing-resolution.ts +184 -0
  176. package/src/rules/styles-no-raw-color.ts +329 -0
  177. package/src/rules/styles-no-raw-dimensions.ts +357 -0
  178. package/src/rules/styles-no-raw-spacing.ts +236 -0
  179. package/src/rules/styles-no-raw-typography.ts +237 -0
  180. package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
  181. package/src/rules/tailwind-arbitrary-color.ts +93 -0
  182. package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
  183. package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
  184. package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
  185. package/src/rules/tailwind-forbidden-palette.ts +107 -0
  186. package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
  187. package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
  188. package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
  189. package/src/rules/tailwind-raw-color-via-token.ts +82 -0
  190. package/src/rules/tailwind-rule-utils.ts +106 -0
  191. package/src/rules/tailwind-unknown-class.test.ts +157 -0
  192. package/src/rules/tailwind-unknown-class.ts +121 -0
  193. package/src/rules/taxonomy.test.ts +238 -0
  194. package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
  195. package/src/rules/tiers.ts +111 -0
  196. package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
  197. package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
  198. package/src/rules/tokens-require-dual-fallback.ts +78 -0
  199. package/src/rules/types.ts +27 -0
  200. package/src/rules/utils.test.ts +110 -0
  201. package/src/rules/utils.ts +599 -0
  202. package/src/schema.ts +427 -0
  203. package/src/schemas/index.ts +275 -0
  204. package/src/schemas/schemas.test.ts +124 -0
  205. package/src/severity.ts +54 -0
  206. package/src/storyAdapter.test.ts +571 -0
  207. package/src/storyAdapter.ts +761 -0
  208. package/src/storyFilters.test.ts +350 -0
  209. package/src/storyFilters.ts +253 -0
  210. package/src/storybook-csf.ts +11 -0
  211. package/src/style-comparison.ts +423 -0
  212. package/src/test-utils.ts +68 -0
  213. package/src/token-parser.ts +2 -0
  214. package/src/token-types.ts +287 -0
  215. package/src/tokens/categories.ts +131 -0
  216. package/src/tokens/color.ts +193 -0
  217. package/src/tokens/design-token-parser.test.ts +159 -0
  218. package/src/tokens/design-token-parser.ts +402 -0
  219. package/src/tokens/dtcg.ts +428 -0
  220. package/src/tokens/index.ts +130 -0
  221. package/src/tokens/lookup.ts +19 -0
  222. package/src/tokens/scss.ts +171 -0
  223. package/src/tokens/tailwind-v4.ts +187 -0
  224. package/src/tokens/types.ts +43 -0
  225. package/src/topology/index.ts +9 -0
  226. package/src/topology/resolve-area.test.ts +174 -0
  227. package/src/topology/resolve-area.ts +215 -0
  228. package/src/types.ts +1057 -0
@@ -0,0 +1,68 @@
1
+ import type { FactIndex } from "../facts/index.js";
2
+ import { ownedComponentIdsEqual } from "../package-identity-match.js";
3
+
4
+ import { makeFinding } from "./finding.js";
5
+ import type { Finding } from "./types.js";
6
+ import { indexComponentByNodeId, readUsageNode } from "./utils.js";
7
+
8
+ export const RULE_ID = "components/preferred-component";
9
+ export const RULE_VERSION = "1";
10
+
11
+ export function ruleJsxPreferredComponent(ix: FactIndex): Finding[] {
12
+ const policies = ix.policy.jsxComponentPreferred();
13
+ if (policies.length === 0) return [];
14
+
15
+ const findings: Finding[] = [];
16
+ const componentByNode = indexComponentByNodeId(ix);
17
+
18
+ for (const [nodeId, usageComponent] of componentByNode) {
19
+ const node = readUsageNode(ix, nodeId);
20
+ if (!node) continue;
21
+ if (node.element.includes(".")) continue;
22
+
23
+ for (const policy of policies) {
24
+ if (!ownedComponentIdsEqual(usageComponent.componentId, policy.from)) continue;
25
+
26
+ const replacement = componentNameFromId(policy.to);
27
+ if (!replacement) continue;
28
+
29
+ findings.push(
30
+ makeFinding({
31
+ ruleId: RULE_ID,
32
+ ruleVersion: RULE_VERSION,
33
+ severity: policy.severity,
34
+ message: `<${node.element}> should use <${replacement}>.`,
35
+ location: node.location,
36
+ evidence: ix.evidence([node.id, usageComponent.id, policy.id]),
37
+ fingerprintIdentity: {
38
+ file: node.file,
39
+ nodePath: node.nodePath,
40
+ from: policy.from,
41
+ to: policy.to,
42
+ },
43
+ fix: {
44
+ kind: "replaceComponent",
45
+ title: policy.because ?? `Replace <${node.element}> with <${replacement}>`,
46
+ from: node.element,
47
+ to: replacement,
48
+ deterministic: true,
49
+ },
50
+ attributes: {
51
+ componentId: usageComponent.componentId,
52
+ rawValue: node.element,
53
+ suggestedComponent: replacement,
54
+ },
55
+ })
56
+ );
57
+ }
58
+ }
59
+
60
+ return findings;
61
+ }
62
+
63
+ function componentNameFromId(componentId: string): string | undefined {
64
+ const hash = componentId.lastIndexOf("#");
65
+ const name = hash >= 0 ? componentId.slice(hash + 1) : componentId;
66
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) return undefined;
67
+ return name;
68
+ }
@@ -0,0 +1,67 @@
1
+ import type { FactIndex } from "../facts/index.js";
2
+ import { ownedImportsEqual } from "../package-identity-match.js";
3
+
4
+ import { makeFinding } from "./finding.js";
5
+ import type { Finding } from "./types.js";
6
+
7
+ export const RULE_ID = "imports/preferred-path";
8
+ export const RULE_VERSION = "1";
9
+
10
+ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
11
+ const policies = ix.policy.jsxImportPathPreferred();
12
+ if (policies.length === 0) return [];
13
+
14
+ const findings: Finding[] = [];
15
+ const seen = new Set<string>();
16
+
17
+ for (const usage of ix.byKind("usage_import")) {
18
+ const matches = policies.filter((policy) => {
19
+ if (!ownedImportsEqual(policy.from, usage.source)) return false;
20
+ if (policy.imported !== undefined && policy.imported !== usage.imported) return false;
21
+ return true;
22
+ });
23
+
24
+ for (const policy of matches) {
25
+ if (ownedImportsEqual(usage.source, policy.to)) continue;
26
+ const importedIdentity = policy.imported ?? "*";
27
+ const key = `${policy.id}\0${usage.file}\0${usage.source}\0${usage.location.line}\0${usage.location.column}\0${importedIdentity}`;
28
+ if (seen.has(key)) continue;
29
+ seen.add(key);
30
+
31
+ findings.push(
32
+ makeFinding({
33
+ ruleId: RULE_ID,
34
+ ruleVersion: RULE_VERSION,
35
+ severity: policy.severity,
36
+ message: `Import from "${usage.source}" should use "${policy.to}".`,
37
+ location: usage.location,
38
+ evidence: ix.evidence([usage.id, policy.id]),
39
+ fingerprintIdentity: {
40
+ file: usage.file,
41
+ from: usage.source,
42
+ to: policy.to,
43
+ imported: policy.imported,
44
+ line: usage.location.line,
45
+ column: usage.location.column,
46
+ },
47
+ fix: {
48
+ kind: "replaceImport",
49
+ title: policy.because ?? `Replace import path with "${policy.to}"`,
50
+ from: usage.source,
51
+ to: policy.to,
52
+ deterministic: true,
53
+ },
54
+ attributes: {
55
+ importPath: usage.source,
56
+ suggestedImport: policy.to,
57
+ imported: usage.imported,
58
+ local: usage.local,
59
+ rawValue: usage.source,
60
+ },
61
+ })
62
+ );
63
+ }
64
+ }
65
+
66
+ return findings;
67
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Rule presets — the customer default vs the full `fragments` dogfood preset.
3
+ *
4
+ * Both are derived from the tier SSOT (`./tiers.ts`); neither re-lists rules.
5
+ *
6
+ * - **customer default** — Tier A on (inert until a contract is authored),
7
+ * Tier B off, Fragments-internal rules excluded entirely. This is the
8
+ * vocabulary-first, no-flood default a customer repo gets.
9
+ * - **`fragments`** — every rule on, for Fragments' own internal scans.
10
+ *
11
+ * These produce the rule-config half of a `GovernanceConfig`. The hygiene
12
+ * *policy facts* (e.g. `style.rawColors.forbid`, spacing scales) are a separate
13
+ * config axis the customer default simply omits — so a Tier-B rule that gates on
14
+ * a policy fact (raw-color/spacing/typography) stays inert regardless of its
15
+ * config flag, and the customer default produces zero findings without a
16
+ * contract.
17
+ */
18
+
19
+ import {
20
+ FRAGMENTS_INTERNAL_RULE_IDS,
21
+ RULE_TIER,
22
+ isContractTierRule,
23
+ } from "./tiers.js";
24
+
25
+ export interface PresetRuleState {
26
+ enabled: boolean;
27
+ severity?: "error" | "warn" | "info";
28
+ }
29
+
30
+ /**
31
+ * Customer default rule states: Tier A enabled (self-gating on contract facts),
32
+ * Tier B disabled, Fragments-internal rules omitted entirely. Feed directly to
33
+ * `compileGlobalGovernanceFacts({ rules })` (with no hygiene `styles` config).
34
+ */
35
+ export function customerDefaultRuleStates(): Record<string, PresetRuleState> {
36
+ const states: Record<string, PresetRuleState> = {};
37
+ for (const ruleId of Object.keys(RULE_TIER)) {
38
+ if (FRAGMENTS_INTERNAL_RULE_IDS.has(ruleId)) continue;
39
+ states[ruleId] = isContractTierRule(ruleId)
40
+ ? { enabled: true, severity: "warn" }
41
+ : { enabled: false };
42
+ }
43
+ return states;
44
+ }
45
+
46
+ /**
47
+ * Full `fragments` preset rule states: every rule on. Includes the
48
+ * Fragments-internal rules (e.g. `tokens/require-dual-fallback`) the customer
49
+ * default excludes.
50
+ */
51
+ export function fragmentsPresetRuleStates(): Record<string, PresetRuleState> {
52
+ const states: Record<string, PresetRuleState> = {};
53
+ for (const ruleId of Object.keys(RULE_TIER)) {
54
+ states[ruleId] = { enabled: true, severity: "warn" };
55
+ }
56
+ return states;
57
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `props/invalid-value` — fires when a JSX prop is statically assigned a
3
+ * value outside the component's declared enum values.
4
+ *
5
+ * The rule is intentionally conservative: it only runs for exact static
6
+ * prop values and prop metadata that declares an allowed values list.
7
+ * Dynamic expressions, spreads, JSX values, and open-ended prop schemas stay
8
+ * silent so the scanner does not turn weak metadata into false certainty.
9
+ */
10
+
11
+ import type { FactIndex } from "../facts/index.js";
12
+
13
+ import { makeFinding } from "./finding.js";
14
+ import type { Finding } from "./types.js";
15
+ import {
16
+ indexComponentByNodeId,
17
+ indexPropsByNodeId,
18
+ isUniversalJsxProp,
19
+ readUsageNode,
20
+ } from "./utils.js";
21
+
22
+ export const RULE_ID = "props/invalid-value";
23
+ export const RULE_VERSION = "1";
24
+
25
+ export function rulePropsInvalidValue(ix: FactIndex): Finding[] {
26
+ const policy = ix.policy.ruleConfig(RULE_ID);
27
+ if (!policy?.enabled) return [];
28
+
29
+ const findings: Finding[] = [];
30
+ const componentByNode = indexComponentByNodeId(ix);
31
+ const propsByNode = indexPropsByNodeId(ix);
32
+
33
+ for (const [nodeId, propUsages] of propsByNode) {
34
+ const usageComponent = componentByNode.get(nodeId);
35
+ if (!usageComponent) continue;
36
+ const node = readUsageNode(ix, nodeId);
37
+ if (!node) continue;
38
+
39
+ for (const propUsage of propUsages) {
40
+ if (propUsage.resolution !== "static") continue;
41
+ if (propUsage.value === undefined) continue;
42
+ if (isUniversalJsxProp(propUsage.prop)) continue;
43
+
44
+ const metadata = ix.components.propOf(usageComponent.componentId, propUsage.prop);
45
+ if (!metadata?.values?.length) continue;
46
+ const allowedValues = metadata.values;
47
+
48
+ const rawValue = toComparableValue(propUsage.value);
49
+ if (rawValue === null) continue;
50
+ if (allowedValues.includes(rawValue)) continue;
51
+
52
+ const fix =
53
+ allowedValues.length === 1
54
+ ? {
55
+ kind: "replacePropValue" as const,
56
+ title: `Replace ${propUsage.prop} with ${formatValue(allowedValues[0])}`,
57
+ prop: propUsage.prop,
58
+ value: allowedValues[0],
59
+ deterministic: true,
60
+ }
61
+ : undefined;
62
+
63
+ findings.push(
64
+ makeFinding({
65
+ ruleId: RULE_ID,
66
+ ruleVersion: RULE_VERSION,
67
+ severity: policy.severity ?? "warn",
68
+ message: buildMessage(node.element, propUsage.prop, propUsage.value, allowedValues),
69
+ location: propUsage.location ?? node.location,
70
+ evidence: ix.evidence([node.id, usageComponent.id, propUsage.id, metadata.id, policy.id]),
71
+ fingerprintIdentity: {
72
+ file: node.file,
73
+ componentId: usageComponent.componentId,
74
+ element: node.element,
75
+ nodePath: node.nodePath,
76
+ prop: propUsage.prop,
77
+ value: propUsage.value,
78
+ },
79
+ fix,
80
+ attributes: {
81
+ componentId: usageComponent.componentId,
82
+ prop: propUsage.prop,
83
+ rawValue: propUsage.value,
84
+ allowedValues,
85
+ },
86
+ })
87
+ );
88
+ }
89
+ }
90
+
91
+ return findings;
92
+ }
93
+
94
+ function toComparableValue(value: unknown): string | null {
95
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
96
+ return String(value);
97
+ }
98
+ if (value === null) return "null";
99
+ return null;
100
+ }
101
+
102
+ function formatValue(value: unknown): string {
103
+ if (typeof value === "string") return `"${value}"`;
104
+ return String(value);
105
+ }
106
+
107
+ function buildMessage(
108
+ element: string,
109
+ prop: string,
110
+ value: unknown,
111
+ allowedValues: readonly string[]
112
+ ): string {
113
+ const allowed = allowedValues.map(formatValue).join(", ");
114
+ return `<${element}> ${prop}=${formatValue(value)} is not an allowed value. Allowed values: ${allowed}.`;
115
+ }