@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,494 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ asComponentId,
4
+ componentId as makeComponentId,
5
+ defineConfig,
6
+ defineFragment,
7
+ factId,
8
+ FactIndex,
9
+ compileComponentFacts,
10
+ compileGlobalGovernanceFacts,
11
+ g,
12
+ makeUsageNodeFact,
13
+ } from "../index.js";
14
+ import type { ComponentId, FactId, PropValueForbiddenFact } from "../index.js";
15
+
16
+ type ButtonProps = {
17
+ variant?: "primary" | "secondary" | "ghost" | "link";
18
+ size?: "sm" | "md" | "lg";
19
+ disabled?: boolean;
20
+ };
21
+
22
+ function Button(_props: ButtonProps) {
23
+ return null;
24
+ }
25
+
26
+ function buildSampleFragment() {
27
+ return defineFragment({
28
+ component: Button,
29
+ meta: {
30
+ name: "Button",
31
+ description: "Interactive element for user-triggered actions.",
32
+ category: "forms",
33
+ },
34
+ guidance: {
35
+ when: ["Submitting forms"],
36
+ whenNot: ["Simple navigation without side effects"],
37
+ },
38
+ props: {
39
+ variant: {
40
+ type: "enum",
41
+ values: ["primary", "secondary", "ghost", "link"],
42
+ description: "Visual style",
43
+ required: false,
44
+ },
45
+ size: {
46
+ type: "enum",
47
+ values: ["sm", "md", "lg"],
48
+ description: "Size of the button",
49
+ required: false,
50
+ },
51
+ disabled: {
52
+ type: "boolean",
53
+ description: "Disables the button",
54
+ required: false,
55
+ },
56
+ },
57
+ govern: (govern) => [
58
+ govern.capability("dom.button"),
59
+ govern.prop("variant").avoid("link", {
60
+ because: "Use Link for ordinary navigation.",
61
+ severity: "warn",
62
+ }),
63
+ govern.prop("variant").forbid("secondary", {
64
+ when: { path: "apps/checkout/**" },
65
+ because: "Checkout CTAs should stay visually dominant.",
66
+ fix: { replaceWith: "primary" },
67
+ severity: "error",
68
+ }),
69
+ govern.accessibility().requireName({
70
+ because: "Buttons must announce their action.",
71
+ severity: "error",
72
+ }),
73
+ ],
74
+ });
75
+ }
76
+
77
+ const buttonId = makeComponentId("@usefragments/ui", "Button");
78
+
79
+ describe("fact IR — content-addressed IDs", () => {
80
+ it("authors owned component package ids with the active package epoch", () => {
81
+ expect(makeComponentId("@usefragments/ui", "Button")).toBe("@usefragments/ui#Button");
82
+ expect(makeComponentId("@acme/ui", "Button")).toBe("@acme/ui#Button");
83
+ });
84
+
85
+ it("emits the same fact IDs across runs for the same input", () => {
86
+ const a = compileComponentFacts(buttonId, buildSampleFragment());
87
+ const b = compileComponentFacts(buttonId, buildSampleFragment());
88
+
89
+ expect(a.map((f) => f.id)).toEqual(b.map((f) => f.id));
90
+ });
91
+
92
+ it("does not change component or policy fact IDs when source location moves", () => {
93
+ const baseline = compileComponentFacts(buttonId, buildSampleFragment());
94
+
95
+ // Different filePath simulates a moved component file. Identity inputs for
96
+ // component/policy facts are componentId/prop/value/path-pattern only —
97
+ // never the source location of the .fragment.ts file.
98
+ const movedFragment = buildSampleFragment();
99
+ movedFragment._provenance = {
100
+ source: "manual",
101
+ verified: true,
102
+ sourceFile: "src/components/Button.fragment.ts",
103
+ } as never;
104
+ const moved = compileComponentFacts(buttonId, movedFragment);
105
+
106
+ expect(moved.map((f) => f.id)).toEqual(baseline.map((f) => f.id));
107
+ });
108
+
109
+ it("usage_node fact IDs include stable node identity inputs, not raw line/col", () => {
110
+ const a = makeUsageNodeFact({
111
+ file: "apps/checkout/page.tsx",
112
+ nodePath: "0/2/1",
113
+ element: "Button",
114
+ location: { file: "apps/checkout/page.tsx", line: 42, column: 8 },
115
+ });
116
+ const b = makeUsageNodeFact({
117
+ file: "apps/checkout/page.tsx",
118
+ nodePath: "0/2/1",
119
+ element: "Button",
120
+ // Same identity inputs, different location — moved several lines down.
121
+ location: { file: "apps/checkout/page.tsx", line: 87, column: 12 },
122
+ });
123
+
124
+ expect(a.id).toBe(b.id);
125
+
126
+ // Identity changes when any of file / nodePath / element changes.
127
+ const moved = makeUsageNodeFact({
128
+ file: "apps/checkout/page.tsx",
129
+ nodePath: "0/2/2",
130
+ element: "Button",
131
+ location: { file: "apps/checkout/page.tsx", line: 42, column: 8 },
132
+ });
133
+ expect(moved.id).not.toBe(a.id);
134
+ });
135
+
136
+ it("factId() canonicalizes attribute order", () => {
137
+ const a = factId("prop_value_forbidden", {
138
+ componentId: buttonId,
139
+ prop: "variant",
140
+ value: "secondary",
141
+ pathPattern: "apps/checkout/**",
142
+ });
143
+ const b = factId("prop_value_forbidden", {
144
+ pathPattern: "apps/checkout/**",
145
+ value: "secondary",
146
+ prop: "variant",
147
+ componentId: buttonId,
148
+ });
149
+
150
+ expect(a).toBe(b);
151
+ });
152
+
153
+ it("keeps v1 IDs stable across owned package spellings only in typed identity fields", () => {
154
+ const legacyComponentId = asComponentId("@fragments-sdk/ui#Button");
155
+ const currentComponentId = asComponentId("@usefragments/ui#Button");
156
+
157
+ expect(factId("component", { componentId: legacyComponentId })).toBe(
158
+ factId("component", { componentId: currentComponentId })
159
+ );
160
+ expect(
161
+ factId("jsx_import_path_preferred", {
162
+ from: "@fragments-sdk/ui/legacy",
163
+ to: "@fragments-sdk/ui",
164
+ })
165
+ ).toBe(
166
+ factId("jsx_import_path_preferred", {
167
+ from: "@usefragments/ui/legacy",
168
+ to: "@usefragments/ui",
169
+ })
170
+ );
171
+ expect(
172
+ factId("jsx_component_preferred", {
173
+ from: legacyComponentId,
174
+ to: asComponentId("@fragments-sdk/ui#Link"),
175
+ })
176
+ ).toBe(
177
+ factId("jsx_component_preferred", {
178
+ from: currentComponentId,
179
+ to: asComponentId("@usefragments/ui#Link"),
180
+ })
181
+ );
182
+ expect(
183
+ factId("usage_import", {
184
+ file: "src/app.tsx",
185
+ local: "Button",
186
+ imported: "Button",
187
+ source: "@fragments-sdk/ui",
188
+ })
189
+ ).toBe(
190
+ factId("usage_import", {
191
+ file: "src/app.tsx",
192
+ local: "Button",
193
+ imported: "Button",
194
+ source: "@usefragments/ui",
195
+ })
196
+ );
197
+
198
+ expect(factId("component", { componentId: "@acme/ui#Button" })).not.toBe(
199
+ factId("component", { componentId: "@other/ui#Button" })
200
+ );
201
+ expect(factId("marker", { generated_by: "@fragments-sdk/ui" })).not.toBe(
202
+ factId("marker", { generated_by: "@usefragments/ui" })
203
+ );
204
+ });
205
+ });
206
+
207
+ describe("FactIndex — query layer", () => {
208
+ function buildIndex(): FactIndex {
209
+ const ix = new FactIndex();
210
+ ix.addMany(compileComponentFacts(buttonId, buildSampleFragment()));
211
+ ix.addMany(
212
+ compileGlobalGovernanceFacts({
213
+ scales: {
214
+ space: g.scale.px([0, 4, 8, 12, 16]),
215
+ },
216
+ styles: [
217
+ g.styles.rawColors().forbid({
218
+ except: ["transparent"],
219
+ prefer: "token",
220
+ severity: "error",
221
+ }),
222
+ g.styles.rawSpacing().mustMatchScale("space", {
223
+ appliesTo: ["padding", "gap"],
224
+ severity: "error",
225
+ }),
226
+ ],
227
+ jsx: [g.jsx.unknownProps().forbid({ severity: "error" })],
228
+ })
229
+ );
230
+ return ix;
231
+ }
232
+
233
+ it("resolves components.byId() to the component metadata fact", () => {
234
+ const ix = buildIndex();
235
+ const component = ix.components.byId(buttonId);
236
+
237
+ expect(component?.kind).toBe("component");
238
+ expect(component?.componentId).toBe(buttonId);
239
+ expect(component?.name).toBe("Button");
240
+ expect(component?.category).toBe("forms");
241
+ });
242
+
243
+ it("queries legacy/current component ids as one logical identity without duplicate facts", () => {
244
+ const legacyId = asComponentId("@usefragments/ui#Button");
245
+ const currentId = asComponentId("@usefragments/ui#Button");
246
+ const ix = new FactIndex();
247
+ ix.addMany(compileComponentFacts(legacyId, buildSampleFragment()));
248
+ ix.addMany(compileComponentFacts(currentId, buildSampleFragment()));
249
+
250
+ expect(ix.components.byId(currentId)?.componentId).toBe(legacyId);
251
+ expect(ix.components.list()).toHaveLength(1);
252
+ expect(
253
+ ix.components
254
+ .propsOf(currentId)
255
+ .map((fact) => fact.prop)
256
+ .sort()
257
+ ).toEqual(["disabled", "size", "variant"]);
258
+ expect(ix.policy.forbiddenPropValues(currentId)).toHaveLength(1);
259
+ });
260
+
261
+ it("resolves prop metadata for a component", () => {
262
+ const ix = buildIndex();
263
+
264
+ const props = ix.components.propsOf(buttonId);
265
+ expect(props.map((p) => p.prop).sort()).toEqual(["disabled", "size", "variant"]);
266
+
267
+ const variant = ix.components.propOf(buttonId, "variant");
268
+ expect(variant?.type).toBe("enum");
269
+ expect(variant?.values).toEqual(["primary", "secondary", "ghost", "link"]);
270
+ expect(variant?.required).toBe(false);
271
+
272
+ const disabled = ix.components.propOf(buttonId, "disabled");
273
+ expect(disabled?.type).toBe("boolean");
274
+ });
275
+
276
+ it("resolves component capabilities", () => {
277
+ const ix = buildIndex();
278
+
279
+ const capabilities = ix.components.capabilitiesOf(buttonId);
280
+ expect(capabilities.map((c) => c.capability)).toEqual(["dom.button"]);
281
+ });
282
+
283
+ it("resolves policy records by component, property, and path", () => {
284
+ const ix = buildIndex();
285
+
286
+ const allForbidden = ix.policy.forbiddenPropValues(buttonId);
287
+ expect(allForbidden).toHaveLength(1);
288
+
289
+ const forVariant = ix.policy.forbiddenPropValues(buttonId, { prop: "variant" });
290
+ expect(forVariant).toHaveLength(1);
291
+ expect(forVariant[0].value).toBe("secondary");
292
+ expect(forVariant[0].pathPattern).toBe("apps/checkout/**");
293
+
294
+ const inCheckout = ix.policy.forbiddenPropValues(buttonId, {
295
+ prop: "variant",
296
+ path: "apps/checkout/cart/page.tsx",
297
+ });
298
+ expect(inCheckout).toHaveLength(1);
299
+
300
+ const inMarketing = ix.policy.forbiddenPropValues(buttonId, {
301
+ prop: "variant",
302
+ path: "apps/marketing/home.tsx",
303
+ });
304
+ expect(inMarketing).toHaveLength(0);
305
+
306
+ const avoided = ix.policy.avoidedPropValues(buttonId, { prop: "variant" });
307
+ expect(avoided).toHaveLength(1);
308
+ expect(avoided[0].value).toBe("link");
309
+
310
+ const a11y = ix.policy.a11yNameRequired(buttonId);
311
+ expect(a11y?.severity).toBe("error");
312
+ });
313
+
314
+ it("resolves global policy facts", () => {
315
+ const ix = buildIndex();
316
+
317
+ expect(
318
+ ix.policy
319
+ .scales()
320
+ .map((s) => s.name)
321
+ .sort()
322
+ ).toEqual(["space"]);
323
+ expect(
324
+ ix.policy
325
+ .scaleValues("space")
326
+ .map((v) => v.value)
327
+ .sort((a, b) => a - b)
328
+ ).toEqual([0, 4, 8, 12, 16]);
329
+
330
+ const rawColors = ix.policy.rawColorPolicy();
331
+ expect(rawColors?.except).toEqual(["transparent"]);
332
+
333
+ const padding = ix.policy.propertyScale("padding");
334
+ expect(padding?.scale).toBe("space");
335
+ const gap = ix.policy.propertyScale("gap");
336
+ expect(gap?.scale).toBe("space");
337
+
338
+ const unknownProps = ix.policy.jsxUnknownPropsForbidden();
339
+ expect(unknownProps?.severity).toBe("error");
340
+ });
341
+
342
+ it("activates prefer-library from top-level canonical sources", () => {
343
+ const ix = new FactIndex();
344
+ ix.addMany(
345
+ compileGlobalGovernanceFacts({
346
+ canonicalSources: [
347
+ {
348
+ kind: "npm",
349
+ specifier: "@usefragments/ui",
350
+ include: ["Button"],
351
+ },
352
+ ],
353
+ })
354
+ );
355
+
356
+ expect(ix.policy.ruleConfig("components/prefer-library")).toMatchObject({
357
+ enabled: true,
358
+ severity: "warn",
359
+ options: {
360
+ canonicalSources: [
361
+ {
362
+ kind: "npm",
363
+ specifier: "@usefragments/ui",
364
+ include: ["Button"],
365
+ },
366
+ ],
367
+ },
368
+ });
369
+ });
370
+
371
+ it("resolves preferred import and component policy facts", () => {
372
+ const ix = new FactIndex();
373
+ ix.addMany(
374
+ compileGlobalGovernanceFacts({
375
+ jsx: [
376
+ g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui", {
377
+ imported: "Button",
378
+ severity: "warn",
379
+ }),
380
+ g.jsx.component().prefer("@legacy/ui#LegacyButton", "@usefragments/ui#Button", {
381
+ severity: "error",
382
+ }),
383
+ ],
384
+ })
385
+ );
386
+
387
+ expect(ix.policy.jsxImportPathPreferred({ from: "@legacy/ui" })).toMatchObject([
388
+ {
389
+ kind: "jsx_import_path_preferred",
390
+ from: "@legacy/ui",
391
+ to: "@usefragments/ui",
392
+ imported: "Button",
393
+ severity: "warn",
394
+ },
395
+ ]);
396
+ expect(ix.policy.jsxComponentPreferred()).toMatchObject([
397
+ {
398
+ kind: "jsx_component_preferred",
399
+ from: "@legacy/ui#LegacyButton",
400
+ to: "@usefragments/ui#Button",
401
+ severity: "error",
402
+ },
403
+ ]);
404
+ });
405
+
406
+ it("evidence([]) rejects missing fact IDs", () => {
407
+ const ix = buildIndex();
408
+ const componentFact = ix.components.byId(buttonId)!;
409
+ const propFact = ix.components.propOf(buttonId, "variant")!;
410
+ const ghost = "prop_value_forbidden:ghosts0123456789" as FactId;
411
+
412
+ const ok = ix.evidence([componentFact.id, propFact.id]);
413
+ expect(ok.map((e) => e.factId)).toEqual([componentFact.id, propFact.id]);
414
+
415
+ expect(() => ix.evidence([componentFact.id, ghost])).toThrow(/missing/i);
416
+ });
417
+
418
+ it("warns and keeps the first fact when a conflicting fact reuses an id", () => {
419
+ const ix = new FactIndex();
420
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
421
+ const id = factId("prop_value_forbidden", {
422
+ componentId: buttonId,
423
+ prop: "variant",
424
+ value: "secondary",
425
+ });
426
+ const a: PropValueForbiddenFact = {
427
+ id,
428
+ kind: "prop_value_forbidden",
429
+ componentId: buttonId,
430
+ prop: "variant",
431
+ value: "secondary",
432
+ because: "first",
433
+ severity: "error",
434
+ };
435
+ const b: PropValueForbiddenFact = {
436
+ ...a,
437
+ because: "second",
438
+ };
439
+ ix.add(a);
440
+ expect(() => ix.add(b)).not.toThrow();
441
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"));
442
+ expect(ix.get(id)).toEqual(a);
443
+ warn.mockRestore();
444
+ });
445
+
446
+ it("treats old and current owned component spellings as the same indexed facts", () => {
447
+ const legacyId = asComponentId("@fragments-sdk/ui#Button");
448
+ const currentId = asComponentId("@usefragments/ui#Button");
449
+ const legacy = compileComponentFacts(legacyId, buildSampleFragment());
450
+ const current = compileComponentFacts(currentId, buildSampleFragment());
451
+ const ix = new FactIndex();
452
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
453
+
454
+ ix.addMany(legacy);
455
+ ix.addMany(current);
456
+
457
+ expect(warn).not.toHaveBeenCalled();
458
+ expect(ix.size()).toBe(legacy.length);
459
+ expect(ix.components.byId(legacyId)?.name).toBe("Button");
460
+ expect(ix.components.byId(currentId)?.name).toBe("Button");
461
+ warn.mockRestore();
462
+ });
463
+ });
464
+
465
+ describe("fact IR — deterministic snapshot", () => {
466
+ it("compiles the sample fragment to a stable, sorted snapshot", () => {
467
+ const config = defineConfig({
468
+ include: ["src/**/*.fragment.ts"],
469
+ govern: {
470
+ scales: {
471
+ space: g.scale.px([0, 4, 8]),
472
+ },
473
+ styles: [
474
+ g.styles.rawColors().forbid({
475
+ except: ["transparent"],
476
+ prefer: "token",
477
+ severity: "error",
478
+ }),
479
+ ],
480
+ },
481
+ });
482
+
483
+ const facts = [
484
+ ...compileGlobalGovernanceFacts(config.govern),
485
+ ...compileComponentFacts(buttonId, buildSampleFragment()),
486
+ ].sort((a, b) => a.id.localeCompare(b.id));
487
+
488
+ expect(facts).toMatchSnapshot();
489
+ });
490
+ });
491
+
492
+ // Convenience type assertion to keep TS happy if FactId is unused above.
493
+ const _typeWitness: ComponentId = buttonId;
494
+ void _typeWitness;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Content-addressed fact IDs.
3
+ *
4
+ * A fact ID is a deterministic function of (kind, identity). Two runs over the
5
+ * same inputs produce identical IDs; identity attribute order is irrelevant
6
+ * because the payload is canonicalized before hashing.
7
+ *
8
+ * The hash is a 64-bit FNV-1a (computed as two 32-bit halves with different
9
+ * seeds). It is not a cryptographic hash — fact IDs are integrity references
10
+ * inside one build, not security tokens.
11
+ */
12
+
13
+ import {
14
+ authorOwnedImport,
15
+ projectV1OwnedComponentId,
16
+ projectV1OwnedImportIdentity,
17
+ } from "../package-identity.js";
18
+ import type { ComponentId, FactId } from "./types.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Canonical JSON
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export function canonicalJson(value: unknown): string {
25
+ if (value === undefined) return "null";
26
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
27
+ if (Array.isArray(value)) {
28
+ return `[${value.map(canonicalJson).join(",")}]`;
29
+ }
30
+ const entries = Object.entries(value as Record<string, unknown>)
31
+ .filter(([, v]) => v !== undefined)
32
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
33
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // 64-bit FNV-1a (two 32-bit halves with different seeds)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const FNV_PRIME = 0x01000193;
41
+ const FNV_SEED_A = 0x811c9dc5;
42
+ const FNV_SEED_B = 0x9e3779b1;
43
+
44
+ function fnv1a32(input: string, seed: number): number {
45
+ let hash = seed >>> 0;
46
+ for (let i = 0; i < input.length; i++) {
47
+ const code = input.charCodeAt(i);
48
+ hash ^= code & 0xff;
49
+ hash = Math.imul(hash, FNV_PRIME) >>> 0;
50
+ if (code > 0xff) {
51
+ hash ^= (code >>> 8) & 0xff;
52
+ hash = Math.imul(hash, FNV_PRIME) >>> 0;
53
+ }
54
+ }
55
+ return hash >>> 0;
56
+ }
57
+
58
+ export function hash64Hex(input: string): string {
59
+ const a = fnv1a32(input, FNV_SEED_A);
60
+ const b = fnv1a32(input, FNV_SEED_B);
61
+ return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // IDs
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export function componentId(packageName: string, name: string): ComponentId {
69
+ if (!packageName.length || !name.length) {
70
+ throw new Error("componentId requires a non-empty packageName and name");
71
+ }
72
+ return `${authorOwnedImport(packageName)}#${name}` as ComponentId;
73
+ }
74
+
75
+ export function asComponentId(value: string): ComponentId {
76
+ if (!value.includes("#")) {
77
+ throw new Error(`componentId must look like "package#Name", got "${value}"`);
78
+ }
79
+ return value as ComponentId;
80
+ }
81
+
82
+ /**
83
+ * Compute a content-addressed fact ID. Identity is canonicalized so attribute
84
+ * order is irrelevant.
85
+ */
86
+ export function factId(kind: string, identity: Record<string, unknown>): FactId {
87
+ const payload = canonicalJson({ k: kind, i: projectFactIdentityV1(kind, identity) });
88
+ return `${kind}:${hash64Hex(payload)}` as FactId;
89
+ }
90
+
91
+ /**
92
+ * Preserve the immutable v1 fact-id domain across the public package rename.
93
+ * Projection is deliberately kind/key aware: arbitrary strings must not be
94
+ * rewritten merely because they resemble one of our package names.
95
+ */
96
+ function projectFactIdentityV1(
97
+ kind: string,
98
+ identity: Record<string, unknown>
99
+ ): Record<string, unknown> {
100
+ return Object.fromEntries(
101
+ Object.entries(identity).map(([key, value]) => {
102
+ if (key === "componentId" && typeof value === "string") {
103
+ return [key, projectV1OwnedComponentId(value)];
104
+ }
105
+ if (
106
+ kind === "jsx_component_preferred" &&
107
+ (key === "from" || key === "to") &&
108
+ typeof value === "string"
109
+ ) {
110
+ return [key, projectV1OwnedComponentId(value)];
111
+ }
112
+ if (
113
+ kind === "jsx_import_path_preferred" &&
114
+ (key === "from" || key === "to") &&
115
+ typeof value === "string"
116
+ ) {
117
+ return [key, projectV1OwnedImportIdentity(value)];
118
+ }
119
+ if (kind === "usage_import" && key === "source" && typeof value === "string") {
120
+ return [key, projectV1OwnedImportIdentity(value)];
121
+ }
122
+ return [key, value];
123
+ })
124
+ );
125
+ }