@usefragments/core 1.10.2 → 2.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 (130) hide show
  1. package/dist/{chunk-MZ4SW3TP.js → chunk-3IOWHECM.js} +8 -1
  2. package/dist/{chunk-MZ4SW3TP.js.map → chunk-3IOWHECM.js.map} +1 -1
  3. package/dist/chunk-7ULGH74M.js +66 -0
  4. package/dist/chunk-7ULGH74M.js.map +1 -0
  5. package/dist/{chunk-XN3LSDPY.js → chunk-BMPYIUZE.js} +612 -344
  6. package/dist/chunk-BMPYIUZE.js.map +1 -0
  7. package/dist/{chunk-YF65VYRY.js → chunk-ML5S6QNU.js} +191 -169
  8. package/dist/chunk-ML5S6QNU.js.map +1 -0
  9. package/dist/{chunk-3LLRNCPX.js → chunk-MZ2FS7U4.js} +1 -1
  10. package/dist/chunk-MZ2FS7U4.js.map +1 -0
  11. package/dist/chunk-PWIJMOI4.js +202 -0
  12. package/dist/chunk-PWIJMOI4.js.map +1 -0
  13. package/dist/chunk-RYFULE43.js +578 -0
  14. package/dist/chunk-RYFULE43.js.map +1 -0
  15. package/dist/codes/index.d.ts +2 -2
  16. package/dist/codes/index.js +3 -2
  17. package/dist/compiled-types/index.d.ts +327 -2
  18. package/dist/compiled-types/index.js +1 -1
  19. package/dist/generate/index.d.ts +3 -2
  20. package/dist/{governance-CLk_wkP9.d.ts → governance-hOPXGbbs.d.ts} +474 -515
  21. package/dist/governance-telemetry.d.ts +6 -0
  22. package/dist/governance-telemetry.js +1 -1
  23. package/dist/{index-_sxhUNqx.d.ts → index-C8bcXVav.d.ts} +448 -448
  24. package/dist/index.d.ts +3280 -1305
  25. package/dist/index.js +2414 -350
  26. package/dist/index.js.map +1 -1
  27. package/dist/manifest.d.ts +228 -0
  28. package/dist/manifest.js +24 -0
  29. package/dist/manifest.js.map +1 -0
  30. package/dist/preview/index.js +45 -1
  31. package/dist/preview/index.js.map +1 -1
  32. package/dist/preview-runtime.d.ts +1 -2
  33. package/dist/preview-runtime.js +150 -14
  34. package/dist/preview-runtime.js.map +1 -1
  35. package/dist/react-types.d.ts +1 -2
  36. package/dist/registry.d.ts +1412 -203
  37. package/dist/registry.js +30 -3
  38. package/dist/schemas/index.d.ts +1 -1
  39. package/dist/schemas/index.js +3 -2
  40. package/dist/storyAdapter.d.ts +1 -2
  41. package/dist/storyAdapter.js +11 -49
  42. package/dist/storyAdapter.js.map +1 -1
  43. package/dist/test-utils.d.ts +3 -2
  44. package/dist/topology/index.d.ts +1 -1
  45. package/dist/topology/index.js +1 -1
  46. package/package.json +8 -2
  47. package/src/__tests__/contract-parser.test.ts +318 -277
  48. package/src/__tests__/preview-runtime-hook.test.tsx +315 -0
  49. package/src/__tests__/preview-runtime.test.tsx +30 -8
  50. package/src/__tests__/schema.test.ts +191 -14
  51. package/src/analysis-plan/analysis-plan-v1.test.ts +320 -0
  52. package/src/analysis-plan/coverage.ts +181 -0
  53. package/src/analysis-plan/digest.ts +141 -0
  54. package/src/analysis-plan/index.ts +34 -0
  55. package/src/analysis-plan/types.ts +207 -0
  56. package/src/approved-contract-tokens.test.ts +39 -0
  57. package/src/approved-contract-tokens.ts +18 -0
  58. package/src/codes/__tests__/codes.test.ts +13 -0
  59. package/src/codes/codes.ts +40 -0
  60. package/src/compiled-types/index.ts +640 -39
  61. package/src/compiled-types/parse.test.ts +145 -4
  62. package/src/component-contract.ts +95 -53
  63. package/src/composition.ts +7 -13
  64. package/src/constants.ts +3 -6
  65. package/src/contract/hash.test.ts +20 -0
  66. package/src/contract/hash.ts +66 -9
  67. package/src/contract/index.ts +24 -1
  68. package/src/contract/manifest.test.ts +94 -0
  69. package/src/contract/manifest.ts +68 -0
  70. package/src/contract/preimage.test.ts +219 -1
  71. package/src/contract/preimage.ts +326 -6
  72. package/src/contract/stamp.test.ts +3 -0
  73. package/src/contract/stamp.ts +1 -1
  74. package/src/contract-parser.ts +54 -30
  75. package/src/defineFragment.test.ts +476 -91
  76. package/src/defineFragment.ts +204 -114
  77. package/src/domain-ids.test.ts +35 -0
  78. package/src/domain-ids.ts +61 -0
  79. package/src/evaluation/evaluate.test.ts +522 -0
  80. package/src/evaluation/evaluate.ts +690 -0
  81. package/src/evaluation/evaluation-v2-receipt-v1.test.ts +772 -0
  82. package/src/evaluation/index.ts +58 -0
  83. package/src/evaluation/receipt.ts +753 -0
  84. package/src/evaluation/types.ts +406 -0
  85. package/src/facts/builders.ts +2 -0
  86. package/src/facts/compile.ts +29 -6
  87. package/src/facts/fact-index.ts +13 -3
  88. package/src/facts/fact-integrity-v1.test.ts +172 -0
  89. package/src/facts/facts.test.ts +15 -0
  90. package/src/facts/ids.ts +46 -3
  91. package/src/facts/index.ts +14 -1
  92. package/src/facts/integrity.ts +134 -0
  93. package/src/facts/types.ts +36 -0
  94. package/src/governance-integrity.test.ts +1 -0
  95. package/src/governance-integrity.ts +5 -3
  96. package/src/governance-telemetry.ts +8 -0
  97. package/src/governance.ts +70 -8
  98. package/src/index.ts +230 -37
  99. package/src/preview/validation.test.ts +62 -0
  100. package/src/preview/validation.ts +48 -2
  101. package/src/preview-runtime.tsx +227 -20
  102. package/src/registry-install-plan.ts +200 -109
  103. package/src/registry-shards.test.ts +263 -0
  104. package/src/registry.ts +237 -0
  105. package/src/repository-binding.test.ts +50 -0
  106. package/src/repository-binding.ts +96 -0
  107. package/src/rules/families.test.ts +36 -0
  108. package/src/rules/finding.ts +7 -2
  109. package/src/rules/index.ts +17 -1
  110. package/src/rules/rule-config.test.ts +66 -0
  111. package/src/rules/rule-config.ts +73 -0
  112. package/src/rules/rules.test.ts +26 -0
  113. package/src/rules/tokens-css-vars-must-be-defined.test.ts +51 -2
  114. package/src/rules/tokens-css-vars-must-be-defined.ts +34 -1
  115. package/src/schema.ts +293 -113
  116. package/src/schemas/index.ts +1 -1
  117. package/src/storyAdapter.test.ts +68 -12
  118. package/src/storyAdapter.ts +44 -75
  119. package/src/topology/resolve-area.ts +1 -1
  120. package/src/types.ts +258 -40
  121. package/dist/chunk-3LLRNCPX.js.map +0 -1
  122. package/dist/chunk-RANPUC6C.js +0 -72
  123. package/dist/chunk-RANPUC6C.js.map +0 -1
  124. package/dist/chunk-XN3LSDPY.js.map +0 -1
  125. package/dist/chunk-YF65VYRY.js.map +0 -1
  126. package/src/fragment-types.ts +0 -214
  127. package/src/react-create-element.test.ts +0 -22
  128. package/src/react-create-element.ts +0 -12
  129. package/src/storyFilters.test.ts +0 -350
  130. package/src/storyFilters.ts +0 -253
@@ -1,9 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import {
4
- CompiledFragmentsFileValidationError,
5
- parseCompiledFragmentsFile,
6
- } from "./index.js";
3
+ import { CompiledFragmentsFileValidationError, parseCompiledFragmentsFile } from "./index.js";
7
4
 
8
5
  const validCatalog = {
9
6
  version: "1.0.0",
@@ -24,6 +21,64 @@ describe("parseCompiledFragmentsFile", () => {
24
21
  expect(parseCompiledFragmentsFile(JSON.stringify(validCatalog))).toMatchObject(validCatalog);
25
22
  });
26
23
 
24
+ it("accepts legacy optional omissions and preserves additive future fields without cloning", () => {
25
+ const legacyWithFutureData = {
26
+ ...validCatalog,
27
+ futureRoot: { schemaVersion: 2 },
28
+ fragments: {
29
+ Button: {
30
+ ...validCatalog.fragments.Button,
31
+ futureFragment: { interactionModel: "press" },
32
+ },
33
+ },
34
+ };
35
+
36
+ const parsed = parseCompiledFragmentsFile(legacyWithFutureData);
37
+
38
+ expect(parsed).toBe(legacyWithFutureData);
39
+ expect(parsed).toHaveProperty("futureRoot.schemaVersion", 2);
40
+ expect(parsed).toHaveProperty("fragments.Button.futureFragment.interactionModel", "press");
41
+ });
42
+
43
+ it("round-trips a valid current artifact without reinterpretation", () => {
44
+ const current = {
45
+ ...validCatalog,
46
+ generatorVersion: "sha256:generator",
47
+ buildInputs: ["src/Button.fragment.ts"],
48
+ packageName: "@acme/ui",
49
+ fragments: {
50
+ Button: {
51
+ ...validCatalog.fragments.Button,
52
+ meta: {
53
+ ...validCatalog.fragments.Button.meta,
54
+ aliases: ["Action"],
55
+ },
56
+ guidance: validCatalog.fragments.Button.usage,
57
+ preview: { theme: "dark", dynamicRegions: ["[data-clock]"] },
58
+ matrix: { axes: { tone: ["neutral", "danger"], density: "auto" } },
59
+ design: { figmaNode: "node:1" },
60
+ tokens: ["--color-action"],
61
+ },
62
+ },
63
+ graph: {
64
+ nodes: [{ name: "Button", category: "Actions", status: "stable" }],
65
+ edges: [],
66
+ health: {
67
+ orphans: ["Button"],
68
+ hubs: [],
69
+ compositionCoverage: 0,
70
+ connectedComponents: [["Button"]],
71
+ averageDegree: 0,
72
+ nodeCount: 1,
73
+ edgeCount: 0,
74
+ },
75
+ },
76
+ };
77
+ const serialized = JSON.stringify(current);
78
+
79
+ expect(JSON.stringify(parseCompiledFragmentsFile(serialized))).toBe(serialized);
80
+ });
81
+
27
82
  it("reports invalid fragment paths instead of allowing command-specific crashes", () => {
28
83
  expect(() =>
29
84
  parseCompiledFragmentsFile(
@@ -44,4 +99,90 @@ describe("parseCompiledFragmentsFile", () => {
44
99
  )
45
100
  ).toThrow("fragments.Button.meta.name");
46
101
  });
102
+
103
+ it("rejects malformed nested and compact graph shapes with sorted deterministic issues", () => {
104
+ let thrown: unknown;
105
+ try {
106
+ parseCompiledFragmentsFile(
107
+ {
108
+ ...validCatalog,
109
+ fragments: {
110
+ Button: {
111
+ ...validCatalog.fragments.Button,
112
+ meta: { name: "Button", description: false, category: "Actions" },
113
+ props: {
114
+ tone: { type: 1, description: "Tone", values: ["neutral", 2] },
115
+ },
116
+ variants: [{ name: "Default", description: 2 }],
117
+ },
118
+ },
119
+ graph: {
120
+ nodes: [{ name: "Button", category: "Actions", status: "stable" }],
121
+ edges: [{ s: "Button", t: "Card", ty: "renders", w: "heavy", p: "usage" }],
122
+ health: {
123
+ orphans: [],
124
+ hubs: [],
125
+ compositionCoverage: 0,
126
+ connectedComponents: [["Button"]],
127
+ averageDegree: 0,
128
+ nodeCount: 1,
129
+ edgeCount: 1,
130
+ },
131
+ },
132
+ },
133
+ "/repo/fragments.json"
134
+ );
135
+ } catch (error) {
136
+ thrown = error;
137
+ }
138
+
139
+ expect(thrown).toBeInstanceOf(CompiledFragmentsFileValidationError);
140
+ expect((thrown as CompiledFragmentsFileValidationError).issues).toEqual([
141
+ "fragments.Button.meta.description must be a string",
142
+ "fragments.Button.props.tone.type must be a string",
143
+ "fragments.Button.props.tone.values must be an array of strings",
144
+ "fragments.Button.variants.0.description must be a string",
145
+ "graph.edges.0.w must be a finite number",
146
+ ]);
147
+ });
148
+
149
+ it("rejects duplicate semantic fragment, variant, and graph-node identities", () => {
150
+ expect(() =>
151
+ parseCompiledFragmentsFile({
152
+ ...validCatalog,
153
+ fragments: {
154
+ PrimaryButton: {
155
+ ...validCatalog.fragments.Button,
156
+ variants: [
157
+ { name: "Default", description: "First" },
158
+ { name: "Default", description: "Second" },
159
+ ],
160
+ },
161
+ SecondaryButton: validCatalog.fragments.Button,
162
+ },
163
+ graph: {
164
+ nodes: [
165
+ { name: "Button", category: "Actions", status: "stable" },
166
+ { name: "Button", category: "Actions", status: "stable" },
167
+ ],
168
+ edges: [],
169
+ health: {
170
+ orphans: [],
171
+ hubs: [],
172
+ compositionCoverage: 0,
173
+ connectedComponents: [],
174
+ averageDegree: 0,
175
+ nodeCount: 2,
176
+ edgeCount: 0,
177
+ },
178
+ },
179
+ })
180
+ ).toThrow(/duplicates/);
181
+ });
182
+
183
+ it("uses a runtime-independent invalid JSON diagnostic", () => {
184
+ expect(() => parseCompiledFragmentsFile('{"fragments":', "memory.json")).toThrow(
185
+ "Invalid compiled Fragments catalog at memory.json: invalid JSON"
186
+ );
187
+ });
47
188
  });
@@ -5,7 +5,7 @@
5
5
  * files instead of `.fragment.tsx`. Maps trivially to CompiledFragment.
6
6
  */
7
7
 
8
- import { z } from 'zod';
8
+ import { z } from "zod";
9
9
 
10
10
  // ---------------------------------------------------------------------------
11
11
  // Component Contract interface
@@ -20,10 +20,10 @@ export interface ComponentContract {
20
20
  description: string;
21
21
  category: string;
22
22
  tags?: string[];
23
- status?: 'stable' | 'beta' | 'deprecated' | 'experimental';
23
+ status?: "stable" | "beta" | "deprecated" | "experimental";
24
24
 
25
25
  /** Framework hint (for extraction and documentation) */
26
- framework?: 'react' | 'vue' | 'svelte' | 'web-components' | 'angular';
26
+ framework?: "react" | "vue" | "svelte" | "web-components" | "angular";
27
27
 
28
28
  /** External npm packages required by this component (displayed in docs Setup section) */
29
29
  dependencies?: Array<{
@@ -42,14 +42,17 @@ export interface ComponentContract {
42
42
  propsSummary: string[];
43
43
 
44
44
  /** Component props schema */
45
- props: Record<string, {
46
- type: string;
47
- values?: string[];
48
- default?: unknown;
49
- description: string;
50
- required?: boolean;
51
- constraints?: string[];
52
- }>;
45
+ props: Record<
46
+ string,
47
+ {
48
+ type: string;
49
+ values?: string[];
50
+ default?: unknown;
51
+ description: string;
52
+ required?: boolean;
53
+ constraints?: string[];
54
+ }
55
+ >;
53
56
 
54
57
  /** Usage guidelines */
55
58
  usage: {
@@ -70,7 +73,14 @@ export interface ComponentContract {
70
73
  /** Relations to other components */
71
74
  relations?: Array<{
72
75
  component: string;
73
- relationship: 'alternative' | 'parent' | 'child' | 'sibling' | 'composition' | 'complementary' | 'used-by';
76
+ relationship:
77
+ | "alternative"
78
+ | "parent"
79
+ | "child"
80
+ | "sibling"
81
+ | "composition"
82
+ | "complementary"
83
+ | "used-by";
74
84
  note: string;
75
85
  }>;
76
86
 
@@ -81,11 +91,14 @@ export interface ComponentContract {
81
91
  a11yRules?: string[];
82
92
  bans?: Array<{ pattern: string; message: string }>;
83
93
  /** Sub-component slot metadata for compound components */
84
- compoundChildren?: Record<string, {
85
- required?: boolean;
86
- accepts?: string[];
87
- description?: string;
88
- }>;
94
+ compoundChildren?: Record<
95
+ string,
96
+ {
97
+ required?: boolean;
98
+ accepts?: string[];
99
+ description?: string;
100
+ }
101
+ >;
89
102
  /** Canonical JSX usage examples */
90
103
  canonicalUsage?: string[];
91
104
  /** Per-component performance budget override in bytes (gzipped) */
@@ -94,7 +107,7 @@ export interface ComponentContract {
94
107
 
95
108
  /** AI metadata for compound component generation */
96
109
  ai?: {
97
- compositionPattern?: 'compound' | 'simple' | 'controlled' | 'wrapper';
110
+ compositionPattern?: "compound" | "simple" | "controlled" | "wrapper";
98
111
  subComponents?: string[];
99
112
  requiredChildren?: string[];
100
113
  commonPatterns?: string[];
@@ -106,14 +119,14 @@ export interface ComponentContract {
106
119
  wrapperModule?: string;
107
120
  wrapperExport?: string;
108
121
  css?: string[];
109
- theme?: 'light' | 'dark';
122
+ theme?: "light" | "dark";
110
123
  };
111
124
 
112
125
  /** Provenance tracking — required for verification */
113
126
  provenance: {
114
- source: 'manual' | 'extracted' | 'merged' | 'migrated';
127
+ source: "manual" | "extracted" | "merged" | "migrated";
115
128
  verified: boolean;
116
- frameworkSupport?: 'native' | 'manual-only';
129
+ frameworkSupport?: "native" | "manual-only";
117
130
  sourceHash?: string;
118
131
  extractedAt?: string;
119
132
  };
@@ -124,11 +137,14 @@ export interface ComponentContract {
124
137
  /** Figma mappings (same DSL as .fragment.tsx but in JSON form) */
125
138
  figma?: {
126
139
  nodeUrl?: string;
127
- propMappings?: Record<string, {
128
- type: 'string' | 'boolean' | 'enum' | 'instance' | 'children' | 'textContent';
129
- figmaProperty: string;
130
- values?: Record<string, string>;
131
- }>;
140
+ propMappings?: Record<
141
+ string,
142
+ {
143
+ type: "string" | "boolean" | "enum" | "instance" | "children" | "textContent";
144
+ figmaProperty: string;
145
+ values?: Record<string, string>;
146
+ }
147
+ >;
132
148
  };
133
149
  }
134
150
 
@@ -161,7 +177,15 @@ const contractExampleSchema = z.object({
161
177
 
162
178
  const contractRelationSchema = z.object({
163
179
  component: z.string(),
164
- relationship: z.enum(['alternative', 'parent', 'child', 'sibling', 'composition', 'complementary', 'used-by']),
180
+ relationship: z.enum([
181
+ "alternative",
182
+ "parent",
183
+ "child",
184
+ "sibling",
185
+ "composition",
186
+ "complementary",
187
+ "used-by",
188
+ ]),
165
189
  note: z.string(),
166
190
  });
167
191
 
@@ -169,21 +193,30 @@ const contractContractSchema = z.object({
169
193
  propsSummary: z.array(z.string()).optional(),
170
194
  scenarioTags: z.array(z.string()).optional(),
171
195
  a11yRules: z.array(z.string()).optional(),
172
- bans: z.array(z.object({
173
- pattern: z.string(),
174
- message: z.string(),
175
- })).optional(),
176
- compoundChildren: z.record(z.string(), z.object({
177
- required: z.boolean().optional(),
178
- accepts: z.array(z.string()).optional(),
179
- description: z.string().optional(),
180
- })).optional(),
196
+ bans: z
197
+ .array(
198
+ z.object({
199
+ pattern: z.string(),
200
+ message: z.string(),
201
+ })
202
+ )
203
+ .optional(),
204
+ compoundChildren: z
205
+ .record(
206
+ z.string(),
207
+ z.object({
208
+ required: z.boolean().optional(),
209
+ accepts: z.array(z.string()).optional(),
210
+ description: z.string().optional(),
211
+ })
212
+ )
213
+ .optional(),
181
214
  canonicalUsage: z.array(z.string()).optional(),
182
- performanceBudget: z.number().optional(),
215
+ performanceBudget: z.number().positive().finite().optional(),
183
216
  });
184
217
 
185
218
  const contractAiSchema = z.object({
186
- compositionPattern: z.enum(['compound', 'simple', 'controlled', 'wrapper']).optional(),
219
+ compositionPattern: z.enum(["compound", "simple", "controlled", "wrapper"]).optional(),
187
220
  subComponents: z.array(z.string()).optional(),
188
221
  requiredChildren: z.array(z.string()).optional(),
189
222
  commonPatterns: z.array(z.string()).optional(),
@@ -194,24 +227,29 @@ const contractPreviewSchema = z.object({
194
227
  wrapperModule: z.string().optional(),
195
228
  wrapperExport: z.string().optional(),
196
229
  css: z.array(z.string()).optional(),
197
- theme: z.enum(['light', 'dark']).optional(),
230
+ theme: z.enum(["light", "dark"]).optional(),
198
231
  });
199
232
 
200
233
  const contractProvenanceSchema = z.object({
201
- source: z.enum(['manual', 'extracted', 'merged', 'migrated']),
234
+ source: z.enum(["manual", "extracted", "merged", "migrated"]),
202
235
  verified: z.boolean(),
203
- frameworkSupport: z.enum(['native', 'manual-only']).optional(),
236
+ frameworkSupport: z.enum(["native", "manual-only"]).optional(),
204
237
  sourceHash: z.string().optional(),
205
238
  extractedAt: z.string().optional(),
206
239
  });
207
240
 
208
241
  const contractFigmaSchema = z.object({
209
242
  nodeUrl: z.string().optional(),
210
- propMappings: z.record(z.string(), z.object({
211
- type: z.enum(['string', 'boolean', 'enum', 'instance', 'children', 'textContent']),
212
- figmaProperty: z.string(),
213
- values: z.record(z.string(), z.string()).optional(),
214
- })).optional(),
243
+ propMappings: z
244
+ .record(
245
+ z.string(),
246
+ z.object({
247
+ type: z.enum(["string", "boolean", "enum", "instance", "children", "textContent"]),
248
+ figmaProperty: z.string(),
249
+ values: z.record(z.string(), z.string()).optional(),
250
+ })
251
+ )
252
+ .optional(),
215
253
  });
216
254
 
217
255
  export const componentContractSchema = z.object({
@@ -220,13 +258,17 @@ export const componentContractSchema = z.object({
220
258
  description: z.string(),
221
259
  category: z.string(),
222
260
  tags: z.array(z.string()).optional(),
223
- status: z.enum(['stable', 'beta', 'deprecated', 'experimental']).optional(),
224
- framework: z.enum(['react', 'vue', 'svelte', 'web-components', 'angular']).optional(),
225
- dependencies: z.array(z.object({
226
- name: z.string(),
227
- version: z.string(),
228
- reason: z.string().optional(),
229
- })).optional(),
261
+ status: z.enum(["stable", "beta", "deprecated", "experimental"]).optional(),
262
+ framework: z.enum(["react", "vue", "svelte", "web-components", "angular"]).optional(),
263
+ dependencies: z
264
+ .array(
265
+ z.object({
266
+ name: z.string(),
267
+ version: z.string(),
268
+ reason: z.string().optional(),
269
+ })
270
+ )
271
+ .optional(),
230
272
  sourcePath: z.string(),
231
273
  exportName: z.string(),
232
274
  propsSummary: z.array(z.string()),
@@ -1,4 +1,5 @@
1
- import type { CompiledFragment, RelationshipType } from "./types.js";
1
+ import type { CompiledFragment } from "./compiled-types/index.js";
2
+ import type { RelationshipType } from "./types.js";
2
3
  import type { ComponentGraph } from "./graph/index.js";
3
4
  import { ComponentGraphEngine } from "./graph/index.js";
4
5
 
@@ -50,6 +51,7 @@ export interface CompositionAnalysis {
50
51
 
51
52
  const CATEGORY_AFFINITIES: Record<string, string[]> = {
52
53
  forms: ["feedback"],
54
+ inputs: ["feedback"],
53
55
  actions: ["feedback"],
54
56
  };
55
57
 
@@ -69,7 +71,7 @@ export function analyzeComposition(
69
71
  fragments: Record<string, CompiledFragment>,
70
72
  componentNames: string[],
71
73
  _context?: string,
72
- options?: { graph?: ComponentGraph },
74
+ options?: { graph?: ComponentGraph }
73
75
  ): CompositionAnalysis {
74
76
  const allNames = new Set(Object.keys(fragments));
75
77
 
@@ -193,9 +195,7 @@ export function analyzeComposition(
193
195
  }
194
196
 
195
197
  // 5. Category gap analysis
196
- const selectedCategories = new Set(
197
- components.map((name) => fragments[name].meta.category)
198
- );
198
+ const selectedCategories = new Set(components.map((name) => fragments[name].meta.category));
199
199
 
200
200
  for (const [category, affinities] of Object.entries(CATEGORY_AFFINITIES)) {
201
201
  if (!selectedCategories.has(category)) continue;
@@ -215,9 +215,7 @@ export function analyzeComposition(
215
215
  component: candidate,
216
216
  reason: `Compositions using "${category}" components often benefit from a "${neededCategory}" component`,
217
217
  relationship: "category_gap",
218
- sourceComponent: components.find(
219
- (n) => fragments[n].meta.category === category
220
- )!,
218
+ sourceComponent: components.find((n) => fragments[n].meta.category === category)!,
221
219
  });
222
220
  suggestedSet.add(candidate);
223
221
  }
@@ -263,11 +261,7 @@ export function analyzeComposition(
263
261
  .map((e) => (e.source === name ? e.target : e.source));
264
262
 
265
263
  for (const comp of blockComps) {
266
- if (
267
- !selectedSet.has(comp) &&
268
- !suggestedSet.has(comp) &&
269
- allNames.has(comp)
270
- ) {
264
+ if (!selectedSet.has(comp) && !suggestedSet.has(comp) && allNames.has(comp)) {
271
265
  suggestions.push({
272
266
  component: comp,
273
267
  reason: `"${name}" and "${comp}" are used together in the "${blockName}" block`,
package/src/constants.ts CHANGED
@@ -15,9 +15,6 @@ export const BRAND = {
15
15
  /** Legacy file extension for segments (still supported for migration) */
16
16
  legacyFileExtension: ".segment.tsx",
17
17
 
18
- /** JSON file extension for compiled output */
19
- jsonExtension: ".fragment.json",
20
-
21
18
  /** Default output file name (e.g., "fragments.json") */
22
19
  outFile: "fragments.json",
23
20
 
@@ -72,13 +69,13 @@ export const BRAND = {
72
69
  /** File extension for block definition files */
73
70
  blockFileExtension: ".block.ts",
74
71
 
75
- /** @deprecated Use blockFileExtension instead */
76
- recipeFileExtension: ".recipe.ts",
77
-
78
72
  /** Vite plugin namespace */
79
73
  vitePluginNamespace: "fragments-core-shim",
80
74
  } as const;
81
75
 
76
+ /** Public schema URL for `.fragment` v3 definition bodies. */
77
+ export const FRAGMENT_V3_SCHEMA_URL = "https://usefragments.com/schemas/fragment.v3.json" as const;
78
+
82
79
  export type Brand = typeof BRAND;
83
80
 
84
81
  /**
@@ -61,6 +61,26 @@ describe("canonicalPreimage", () => {
61
61
  '{"xs":[1,{"y":"q","z":true}]}'
62
62
  );
63
63
  });
64
+
65
+ it("rejects circular and runtime-only values with deterministic paths", () => {
66
+ const circular: Record<string, unknown> = {};
67
+ circular.self = circular;
68
+
69
+ expect(() => canonicalPreimage(circular)).toThrow("Circular contract value at (root).self");
70
+ expect(() => canonicalPreimage({ nested: () => null })).toThrow(
71
+ "Unsupported contract value at (root).nested: function"
72
+ );
73
+ expect(() => canonicalPreimage({ createdAt: new Date(0) })).toThrow(
74
+ "Unsupported contract object at (root).createdAt: Date"
75
+ );
76
+ });
77
+
78
+ it("permits a shared acyclic object without treating it as a cycle", () => {
79
+ const shared = { value: 1 };
80
+ expect(canonicalPreimage({ left: shared, right: shared })).toBe(
81
+ '{"left":{"value":1},"right":{"value":1}}'
82
+ );
83
+ });
64
84
  });
65
85
 
66
86
  describe("contractHash", () => {
@@ -30,8 +30,8 @@
30
30
  * numbers, but renders `NaN`/`Infinity` as `null` (ambiguous) and does not
31
31
  * collapse `-0`. Token values flow into the contract body, so a serializer that
32
32
  * silently maps `NaN` to `null` would hash two different inputs to the same
33
- * preimage. Non-finite numbers get unambiguous sentinels instead; `-0` collapses
34
- * to `0`. This is total (never throws) because it runs on the ingest hot path.
33
+ * preimage. Non-finite numbers get legacy v1 sentinels instead; `-0` collapses
34
+ * to `0`.
35
35
  */
36
36
  function normalizeNumber(value: number): string {
37
37
  if (Number.isNaN(value)) return '"@num:nan"';
@@ -48,29 +48,72 @@ function normalizeNumber(value: number): string {
48
48
  * - `undefined` properties dropped (so optional fields don't perturb the hash)
49
49
  * - numbers normalized via {@link normalizeNumber}
50
50
  * - `bigint` encoded as its decimal string (JSON cannot represent it natively)
51
+ * - circular, executable, and non-plain runtime values rejected with a stable path
51
52
  *
52
53
  * This is intentionally a separate serializer from `facts/ids.ts#canonicalJson`:
53
54
  * that one targets in-process fact IDs and does not normalize numbers, and we
54
55
  * must not change its output (it would silently re-key existing fact IDs).
55
56
  */
56
57
  export function canonicalPreimage(value: unknown): string {
58
+ return canonicalPreimageValue(value, new Set<object>(), "(root)");
59
+ }
60
+
61
+ /**
62
+ * Locale-independent lexical order for authority-bearing canonical records.
63
+ *
64
+ * `String#localeCompare` follows the runtime's default locale, so it can order
65
+ * the same Unicode identifiers differently across a developer machine, CI,
66
+ * and a browser. Relational string comparison is defined by ECMAScript over
67
+ * UTF-16 code units and therefore gives every runtime the same ordering.
68
+ */
69
+ export function compareCanonicalStrings(left: string, right: string): number {
70
+ return left < right ? -1 : left > right ? 1 : 0;
71
+ }
72
+
73
+ function canonicalPreimageValue(value: unknown, ancestors: Set<object>, path: string): string {
57
74
  if (value === undefined || value === null) return "null";
58
75
  const type = typeof value;
59
76
  if (type === "number") return normalizeNumber(value as number);
60
77
  if (type === "string" || type === "boolean") return JSON.stringify(value);
61
78
  if (type === "bigint") return JSON.stringify((value as bigint).toString());
79
+ if (type === "function" || type === "symbol") {
80
+ throw new TypeError(`Unsupported contract value at ${path}: ${type}`);
81
+ }
62
82
  if (Array.isArray(value)) {
63
- return `[${value.map(canonicalPreimage).join(",")}]`;
83
+ if (ancestors.has(value)) throw new TypeError(`Circular contract value at ${path}`);
84
+ ancestors.add(value);
85
+ try {
86
+ return `[${value
87
+ .map((entry, index) => canonicalPreimageValue(entry, ancestors, `${path}[${index}]`))
88
+ .join(",")}]`;
89
+ } finally {
90
+ ancestors.delete(value);
91
+ }
64
92
  }
65
93
  if (type !== "object") {
66
- // functions, symbols not valid contract content; encode as null so the
67
- // serializer stays total rather than throwing on the ingest path.
68
- return "null";
94
+ throw new TypeError(`Unsupported contract value at ${path}: ${type}`);
69
95
  }
70
- const entries = Object.entries(value as Record<string, unknown>)
96
+ const object = value as object;
97
+ const prototype = Object.getPrototypeOf(object);
98
+ if (prototype !== Object.prototype && prototype !== null) {
99
+ const objectType = prototype?.constructor?.name ?? "unknown";
100
+ throw new TypeError(`Unsupported contract object at ${path}: ${objectType}`);
101
+ }
102
+ if (ancestors.has(object)) throw new TypeError(`Circular contract value at ${path}`);
103
+ ancestors.add(object);
104
+ const entries = Object.entries(object as Record<string, unknown>)
71
105
  .filter(([, v]) => v !== undefined)
72
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
73
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalPreimage(v)}`).join(",")}}`;
106
+ .sort(([a], [b]) => compareCanonicalStrings(a, b));
107
+ try {
108
+ return `{${entries
109
+ .map(
110
+ ([key, entry]) =>
111
+ `${JSON.stringify(key)}:${canonicalPreimageValue(entry, ancestors, `${path}.${key}`)}`
112
+ )
113
+ .join(",")}}`;
114
+ } finally {
115
+ ancestors.delete(object);
116
+ }
74
117
  }
75
118
 
76
119
  // ---------------------------------------------------------------------------
@@ -189,7 +232,21 @@ export function sha256Hex(message: string): string {
189
232
  * chars. Identical inputs produce an identical FCID in the CLI, the Convex
190
233
  * isolate, and the browser — which is what lets the gen-time agent view and the
191
234
  * CI enforcement reference the same contract by hash.
235
+ *
236
+ * Never feed preview modules, story states, guidance, or runtime config into
237
+ * this function and call the result an FCID — those belong to
238
+ * {@link artifactContentHash}.
192
239
  */
193
240
  export function contractHash(body: unknown): string {
194
241
  return sha256Hex(canonicalPreimage(body));
195
242
  }
243
+
244
+ /**
245
+ * Content-address for preview artifacts (`artifactId`): the same deterministic
246
+ * SHA-256 canonicalization as {@link contractHash}, but a separate named seam so
247
+ * callers never assign `artifactId` from an FCID (or vice versa). Hash preview
248
+ * modules, states, guidance/render content, and runtime config here.
249
+ */
250
+ export function artifactContentHash(body: unknown): string {
251
+ return sha256Hex(canonicalPreimage(body));
252
+ }