@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,100 @@
1
+ /**
2
+ * Contract-identity ACCEPTANCE §5 (core half) — the shared stamp record:
3
+ * header form, frontmatter-safe serialization, and the tolerant parser that
4
+ * gates read the stamp back with.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import type { ContractDomain } from "./preimage.js";
9
+ import {
10
+ type ContractStampRecord,
11
+ formatStampHeader,
12
+ parseContractStamp,
13
+ serializeContractStamp,
14
+ } from "./stamp.js";
15
+
16
+ const FCID = "a3f9e21b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b785";
17
+ const DOMAINS: Record<ContractDomain, string> = {
18
+ components: "1".repeat(64),
19
+ tokens: "2".repeat(64),
20
+ canonicalMap: "3".repeat(64),
21
+ policy: "4".repeat(64),
22
+ };
23
+ const RECORD: ContractStampRecord = { contractVersion: 12, fcid: FCID, domains: DOMAINS };
24
+
25
+ describe("formatStampHeader", () => {
26
+ it("prefixes the shared pin formatter's output — never a second pin format", () => {
27
+ expect(formatStampHeader(RECORD)).toBe("fragments contract v12 · a3f9e21");
28
+ });
29
+ });
30
+
31
+ describe("serializeContractStamp", () => {
32
+ it("renders plain header lines with the literal header form on the first line", () => {
33
+ expect(serializeContractStamp(RECORD)).toBe(
34
+ [
35
+ "contract: fragments contract v12 · a3f9e21",
36
+ `contract-fcid: ${FCID}`,
37
+ `contract-domains: components=${DOMAINS.components} tokens=${DOMAINS.tokens} canonicalMap=${DOMAINS.canonicalMap} policy=${DOMAINS.policy}`,
38
+ ].join("\n")
39
+ );
40
+ });
41
+
42
+ it("omits the domains line when sub-hashes are unknown", () => {
43
+ const serialized = serializeContractStamp({ contractVersion: 12, fcid: FCID });
44
+ expect(serialized).not.toContain("contract-domains:");
45
+ expect(serialized.split("\n")).toHaveLength(2);
46
+ });
47
+ });
48
+
49
+ describe("parseContractStamp", () => {
50
+ it("round-trips serialize → parse, with and without domains", () => {
51
+ expect(parseContractStamp(serializeContractStamp(RECORD))).toEqual(RECORD);
52
+ expect(parseContractStamp(serializeContractStamp({ contractVersion: 12, fcid: FCID }))).toEqual(
53
+ { contractVersion: 12, fcid: FCID }
54
+ );
55
+ });
56
+
57
+ it("finds the stamp inside a rendered agent-context-shaped file", () => {
58
+ const rendered = `---
59
+ generated_by: fragments-sdk@0.0.0
60
+ preset: universal
61
+ last_init: 2026-06-09T00:00:00.000Z
62
+ ${serializeContractStamp(RECORD)}
63
+ ---
64
+
65
+ # Agent context for this project
66
+
67
+ Prefer canonical components over raw HTML elements.
68
+ `;
69
+ expect(parseContractStamp(rendered)).toEqual(RECORD);
70
+ });
71
+
72
+ it("returns null when the stamp is absent or malformed — never throws", () => {
73
+ expect(parseContractStamp("")).toBeNull();
74
+ expect(parseContractStamp("# Just markdown\n\ncontract talk, no stamp\n")).toBeNull();
75
+ // Header without the fcid line (and vice versa) is not a stamp.
76
+ expect(parseContractStamp("contract: fragments contract v12 · a3f9e21\n")).toBeNull();
77
+ expect(parseContractStamp(`contract-fcid: ${FCID}\n`)).toBeNull();
78
+ // Short fcid in the header must prefix the full fcid.
79
+ expect(
80
+ parseContractStamp(`contract: fragments contract v12 · 0000000\ncontract-fcid: ${FCID}\n`)
81
+ ).toBeNull();
82
+ // Truncated full fcid.
83
+ expect(
84
+ parseContractStamp(
85
+ `contract: fragments contract v12 · a3f9e21\ncontract-fcid: ${FCID.slice(0, 40)}\n`
86
+ )
87
+ ).toBeNull();
88
+ // Malformed domains line poisons the whole stamp.
89
+ expect(
90
+ parseContractStamp(
91
+ `${serializeContractStamp({ contractVersion: 12, fcid: FCID })}\ncontract-domains: components=zzz\n`
92
+ )
93
+ ).toBeNull();
94
+ expect(
95
+ parseContractStamp(
96
+ `${serializeContractStamp({ contractVersion: 12, fcid: FCID })}\ncontract-domains: components=${DOMAINS.components}\n`
97
+ )
98
+ ).toBeNull();
99
+ });
100
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The shared contract pin formatter. Every surface that shows a human the
3
+ * contract identity — MCP metadata, agent context files, hook output, CI check
4
+ * text, PR comments — interpolates the string this
5
+ * function returns; no surface reformats it. One formatter is what makes
6
+ * "compare the hashes yourself" a falsifiable claim instead of three surfaces
7
+ * rendering the pin three ways.
8
+ *
9
+ * Display convention (spine "Identifiers"): never a bare hash without the
10
+ * version, never a version without the hash.
11
+ */
12
+
13
+ import { CONTRACT_DOMAINS, type ContractDomain } from "./preimage.js";
14
+
15
+ /** The committed agent context file every gate reads for local contract stamps. */
16
+ export const AGENT_CONTEXT_RELATIVE_PATH = ".fragments/agent-context.md";
17
+
18
+ /** Length of the abbreviated fcid in the display form. */
19
+ const SHORT_FCID_LENGTH = 7;
20
+
21
+ export interface ContractStampPin {
22
+ /** Monotonic, human-facing version from the ledger. */
23
+ contractVersion: number;
24
+ /** The 64-hex FCID; abbreviated for display. */
25
+ fcid: string;
26
+ }
27
+
28
+ export interface ContractStampRecord extends ContractStampPin {
29
+ /** Full 64-hex domain sub-hashes, when known. */
30
+ domains?: Record<ContractDomain, string>;
31
+ }
32
+
33
+ /**
34
+ * The bare `v12 · a3f9e21` form, for surfaces where "contract" is already said
35
+ * by the surrounding copy (e.g. the hook staleness line's `local v11 · 9c01b22`).
36
+ * {@link formatStampPin} delegates here so the two forms can never drift.
37
+ */
38
+ export function formatStampVersionHash(pin: ContractStampPin): string {
39
+ return `v${pin.contractVersion} · ${pin.fcid.slice(0, SHORT_FCID_LENGTH)}`;
40
+ }
41
+
42
+ /**
43
+ * Render the canonical pin: `contract v12 · a3f9e21`.
44
+ */
45
+ export function formatStampPin(pin: ContractStampPin): string {
46
+ return `contract ${formatStampVersionHash(pin)}`;
47
+ }
48
+
49
+ /** The context-file header form: `fragments contract v12 · a3f9e21`. */
50
+ export function formatStampHeader(pin: ContractStampPin): string {
51
+ return `fragments ${formatStampPin(pin)}`;
52
+ }
53
+
54
+ /**
55
+ * Serialize pin + full fcid (+ domain hashes when known) as plain header lines —
56
+ * no markdown syntax, so the block is safe inside YAML-ish frontmatter and
57
+ * inside HTML-comment blocks. The first line carries the literal
58
+ * {@link formatStampHeader} output; surfaces grep for that substring, so the
59
+ * header form and the serialized form may never diverge.
60
+ */
61
+ export function serializeContractStamp(record: ContractStampRecord): string {
62
+ const lines = [`contract: ${formatStampHeader(record)}`, `contract-fcid: ${record.fcid}`];
63
+ if (record.domains) {
64
+ const domains = record.domains;
65
+ lines.push(
66
+ `contract-domains: ${CONTRACT_DOMAINS.map((domain) => `${domain}=${domains[domain]}`).join(" ")}`
67
+ );
68
+ }
69
+ return lines.join("\n");
70
+ }
71
+
72
+ // Line-anchored so the stamp is found anywhere in a file — frontmatter, an
73
+ // HTML-comment block, or a marker-merged region — without parsing the host
74
+ // format.
75
+ const STAMP_HEADER_LINE = /^contract:\s*fragments contract v(\d+) · ([0-9a-f]+)\s*$/mu;
76
+ const STAMP_FCID_LINE = /^contract-fcid:\s*([0-9a-f]{64})\s*$/mu;
77
+ const STAMP_DOMAINS_LINE = /^contract-domains:\s*(\S.*?)\s*$/mu;
78
+
79
+ /**
80
+ * Parse a stamp record back out of arbitrary file text. Requires the
81
+ * `contract:` + `contract-fcid:` lines and an abbreviated fcid that prefixes
82
+ * the full one; `contract-domains` is optional. Returns null when absent or
83
+ * malformed — never throws.
84
+ */
85
+ export function parseContractStamp(text: string): ContractStampRecord | null {
86
+ const header = STAMP_HEADER_LINE.exec(text);
87
+ const fcidLine = STAMP_FCID_LINE.exec(text);
88
+ if (!header || !fcidLine) return null;
89
+ const contractVersion = Number(header[1]);
90
+ if (!Number.isSafeInteger(contractVersion)) return null;
91
+ const fcid = fcidLine[1];
92
+ if (!fcid.startsWith(header[2])) return null;
93
+
94
+ const domainsLine = STAMP_DOMAINS_LINE.exec(text);
95
+ if (!domainsLine) return { contractVersion, fcid };
96
+
97
+ const domains = {} as Record<ContractDomain, string>;
98
+ for (const pair of domainsLine[1].split(/\s+/u)) {
99
+ const match = /^([a-zA-Z]+)=([0-9a-f]{64})$/u.exec(pair);
100
+ if (!match || !(CONTRACT_DOMAINS as readonly string[]).includes(match[1])) return null;
101
+ domains[match[1] as ContractDomain] = match[2];
102
+ }
103
+ if (CONTRACT_DOMAINS.some((domain) => !domains[domain])) return null;
104
+ return { contractVersion, fcid, domains };
105
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Component Contract Parser — converts .contract.json files to CompiledFragment.
3
+ *
4
+ * Maps the framework-agnostic ComponentContract format to the same
5
+ * CompiledFragment type used by .fragment.tsx, so all downstream consumers
6
+ * (MCP tools, governance, search) work unchanged.
7
+ */
8
+
9
+ import { componentContractSchema } from './component-contract.js';
10
+ import type { ComponentContract } from './component-contract.js';
11
+
12
+ // Use the context package's CompiledFragment type via re-export from types.ts
13
+ // Since core can't depend on context, we define the minimal output shape here
14
+ // that matches CompiledFragment structurally.
15
+
16
+ export interface CompiledContractOutput {
17
+ filePath: string;
18
+ meta: {
19
+ name: string;
20
+ description: string;
21
+ category: string;
22
+ tags?: string[];
23
+ status?: 'stable' | 'beta' | 'deprecated' | 'experimental';
24
+ figma?: string;
25
+ figmaProps?: Record<string, unknown>;
26
+ };
27
+ usage: {
28
+ when: string[];
29
+ whenNot: string[];
30
+ guidelines?: string[];
31
+ accessibility?: string[];
32
+ };
33
+ props: Record<string, {
34
+ type: string;
35
+ description: string;
36
+ default?: unknown;
37
+ required?: boolean;
38
+ values?: string[];
39
+ constraints?: string[];
40
+ }>;
41
+ relations?: Array<{
42
+ component: string;
43
+ relationship: 'alternative' | 'parent' | 'child' | 'sibling' | 'composition' | 'complementary' | 'used-by';
44
+ note: string;
45
+ }>;
46
+ variants: Array<{
47
+ name: string;
48
+ description: string;
49
+ code?: string;
50
+ args?: Record<string, unknown>;
51
+ }>;
52
+ contract?: {
53
+ propsSummary?: string[];
54
+ scenarioTags?: string[];
55
+ a11yRules?: string[];
56
+ bans?: Array<{ pattern: string; message: string }>;
57
+ compoundChildren?: Record<string, {
58
+ required?: boolean;
59
+ accepts?: string[];
60
+ description?: string;
61
+ }>;
62
+ canonicalUsage?: string[];
63
+ performanceBudget?: number;
64
+ };
65
+ /** Framework hint from contract, used by extractor adapter selection */
66
+ framework?: string;
67
+ /** AI metadata for compound component generation */
68
+ ai?: {
69
+ compositionPattern?: 'compound' | 'simple' | 'controlled' | 'wrapper';
70
+ subComponents?: string[];
71
+ requiredChildren?: string[];
72
+ commonPatterns?: string[];
73
+ };
74
+ /** Top-level compact prop summaries for agent first-pass */
75
+ propsSummary?: string[];
76
+ /** Provenance tracking */
77
+ provenance?: {
78
+ source: string;
79
+ verified: boolean;
80
+ frameworkSupport?: string;
81
+ sourceHash?: string;
82
+ extractedAt?: string;
83
+ };
84
+ /** Source file path relative to config root */
85
+ sourcePath?: string;
86
+ /** Named export from source file */
87
+ exportName?: string;
88
+ }
89
+
90
+ /**
91
+ * Check if a file path is a component contract file.
92
+ */
93
+ export function isContractFile(filePath: string): boolean {
94
+ return filePath.endsWith('.contract.json');
95
+ }
96
+
97
+ /**
98
+ * Parse a component contract JSON file into CompiledFragment-compatible output.
99
+ */
100
+ export function parseComponentContract(
101
+ content: string,
102
+ filePath: string,
103
+ ): CompiledContractOutput {
104
+ const raw = JSON.parse(content);
105
+ const validated: ComponentContract = componentContractSchema.parse(raw);
106
+
107
+ return {
108
+ filePath,
109
+ meta: {
110
+ name: validated.name,
111
+ description: validated.description,
112
+ category: validated.category,
113
+ tags: validated.tags,
114
+ status: validated.status,
115
+ figma: validated.figma?.nodeUrl,
116
+ figmaProps: validated.figma?.propMappings as Record<string, unknown> | undefined,
117
+ },
118
+ usage: validated.usage,
119
+ props: validated.props,
120
+ relations: validated.relations ?? [],
121
+ variants: (validated.examples ?? []).map((ex) => ({
122
+ name: ex.name,
123
+ description: ex.description,
124
+ code: ex.code,
125
+ args: ex.args,
126
+ })),
127
+ contract: validated.contract ? {
128
+ ...validated.contract,
129
+ // Merge top-level propsSummary into contract.propsSummary if not already set
130
+ propsSummary: validated.contract.propsSummary ?? validated.propsSummary,
131
+ } : {
132
+ propsSummary: validated.propsSummary,
133
+ },
134
+ framework: validated.framework,
135
+ ai: validated.ai,
136
+ propsSummary: validated.propsSummary,
137
+ provenance: validated.provenance,
138
+ sourcePath: validated.sourcePath,
139
+ exportName: validated.exportName,
140
+ };
141
+ }
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defineFragment, compileFragment, normalizeToV1 } from './defineFragment.js';
3
+ import type {
4
+ FragmentDefinition,
5
+ FragmentDefinitionV2,
6
+ FragmentVariant,
7
+ } from './types.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers — minimal component stubs
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function StubComponent(_props: { children?: string }) {
14
+ return null;
15
+ }
16
+
17
+ const renderStub = () => null;
18
+
19
+ function makeV1(): FragmentDefinition<{ children?: string }> {
20
+ return {
21
+ component: StubComponent,
22
+ meta: { name: 'Stub', description: 'A stub', category: 'test', status: 'stable' },
23
+ usage: {
24
+ when: ['Testing v1'],
25
+ whenNot: ['Production'],
26
+ },
27
+ props: {
28
+ children: { type: 'node', description: 'Content', required: false },
29
+ },
30
+ variants: [
31
+ { name: 'Default', description: 'Default variant', render: renderStub },
32
+ ],
33
+ ai: {
34
+ compositionPattern: 'simple',
35
+ },
36
+ contract: {
37
+ propsSummary: ['children: node'],
38
+ },
39
+ _generated: {
40
+ source: 'manual',
41
+ confidence: 1,
42
+ },
43
+ };
44
+ }
45
+
46
+ function makeV2(): FragmentDefinitionV2<{ children?: string }> {
47
+ return {
48
+ component: StubComponent,
49
+ meta: { name: 'Stub', description: 'A stub', category: 'test', status: 'stable' },
50
+ guidance: {
51
+ when: ['Testing v2'],
52
+ whenNot: ['Production'],
53
+ },
54
+ props: {
55
+ children: { type: 'node', description: 'Content', required: false },
56
+ },
57
+ examples: [
58
+ { name: 'Default', description: 'Default example', render: renderStub },
59
+ ],
60
+ composition: {
61
+ pattern: 'compound',
62
+ subComponents: ['Header', 'Body'],
63
+ requiredChildren: ['Body'],
64
+ commonPatterns: ['<Stub><Stub.Header /><Stub.Body /></Stub>'],
65
+ },
66
+ contract: {
67
+ propsSummary: ['children: node'],
68
+ },
69
+ _provenance: {
70
+ source: 'scan',
71
+ confidence: 0.85,
72
+ autoFields: ['props', 'composition'],
73
+ humanFields: ['guidance', 'examples'],
74
+ },
75
+ };
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // defineFragment — v1 shape
80
+ // ---------------------------------------------------------------------------
81
+
82
+ describe('defineFragment — v1 shape', () => {
83
+ it('accepts and returns v1 definition unchanged', () => {
84
+ const v1 = makeV1();
85
+ const result = defineFragment(v1);
86
+ expect(result).toBe(v1);
87
+ expect(result.usage.when).toEqual(['Testing v1']);
88
+ expect(result.variants[0].name).toBe('Default');
89
+ expect(result.ai?.compositionPattern).toBe('simple');
90
+ });
91
+
92
+ it('throws on invalid v1 definition', () => {
93
+ const invalid = {
94
+ component: StubComponent,
95
+ meta: { name: '', description: 'bad', category: 'test' }, // empty name
96
+ usage: { when: [], whenNot: [] },
97
+ props: {},
98
+ variants: [],
99
+ } as unknown as FragmentDefinition;
100
+
101
+ expect(() => defineFragment(invalid)).toThrow('Invalid fragment definition');
102
+ });
103
+ });
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // defineFragment — v2 shape
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe('defineFragment — v2 shape', () => {
110
+ it('accepts and returns v2 definition unchanged', () => {
111
+ const v2 = makeV2();
112
+ const result = defineFragment(v2);
113
+ expect(result).toBe(v2);
114
+ expect((result as FragmentDefinitionV2).guidance.when).toEqual(['Testing v2']);
115
+ expect((result as FragmentDefinitionV2).examples[0].name).toBe('Default');
116
+ expect((result as FragmentDefinitionV2).composition?.pattern).toBe('compound');
117
+ });
118
+
119
+ it('validates v2 field names correctly', () => {
120
+ const v2 = makeV2();
121
+ // Should not throw
122
+ expect(() => defineFragment(v2)).not.toThrow();
123
+ });
124
+
125
+ it('throws on invalid v2 definition', () => {
126
+ const invalid = {
127
+ component: StubComponent,
128
+ meta: { name: 'Test', description: 'test', category: 'test' },
129
+ guidance: { when: 'not-an-array', whenNot: [] }, // wrong type
130
+ props: {},
131
+ examples: [],
132
+ } as unknown as FragmentDefinitionV2;
133
+
134
+ expect(() => defineFragment(invalid)).toThrow('Invalid fragment definition');
135
+ });
136
+
137
+ it('accepts wrapper composition pattern', () => {
138
+ const v2 = makeV2();
139
+ v2.composition = { pattern: 'wrapper' };
140
+ expect(() => defineFragment(v2)).not.toThrow();
141
+ });
142
+
143
+ it('accepts scan provenance source', () => {
144
+ const v2 = makeV2();
145
+ v2._provenance = { source: 'scan', confidence: 0.9 };
146
+ expect(() => defineFragment(v2)).not.toThrow();
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // normalizeToV1
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('normalizeToV1', () => {
155
+ it('maps guidance → usage', () => {
156
+ const v2 = makeV2();
157
+ const v1 = normalizeToV1(v2);
158
+ expect(v1.usage).toBe(v2.guidance);
159
+ expect(v1.usage.when).toEqual(['Testing v2']);
160
+ });
161
+
162
+ it('maps examples → variants', () => {
163
+ const v2 = makeV2();
164
+ const v1 = normalizeToV1(v2);
165
+ expect(v1.variants).toBe(v2.examples);
166
+ expect(v1.variants[0].name).toBe('Default');
167
+ });
168
+
169
+ it('maps composition → ai', () => {
170
+ const v2 = makeV2();
171
+ const v1 = normalizeToV1(v2);
172
+ expect(v1.ai).toEqual({
173
+ compositionPattern: 'compound',
174
+ subComponents: ['Header', 'Body'],
175
+ requiredChildren: ['Body'],
176
+ commonPatterns: ['<Stub><Stub.Header /><Stub.Body /></Stub>'],
177
+ });
178
+ });
179
+
180
+ it('maps _provenance → _generated (narrows scan → ai)', () => {
181
+ const v2 = makeV2();
182
+ const v1 = normalizeToV1(v2);
183
+ expect(v1._generated).toEqual({
184
+ source: 'ai', // scan → ai (v1 doesn't have scan)
185
+ sourceFile: undefined,
186
+ confidence: 0.85,
187
+ timestamp: undefined,
188
+ });
189
+ });
190
+
191
+ it('preserves storybook source as-is', () => {
192
+ const v2 = makeV2();
193
+ v2._provenance = { source: 'storybook', sourceFile: 'Button.stories.tsx' };
194
+ const v1 = normalizeToV1(v2);
195
+ expect(v1._generated!.source).toBe('storybook');
196
+ expect(v1._generated!.sourceFile).toBe('Button.stories.tsx');
197
+ });
198
+
199
+ it('handles undefined composition gracefully', () => {
200
+ const v2 = makeV2();
201
+ v2.composition = undefined;
202
+ const v1 = normalizeToV1(v2);
203
+ expect(v1.ai).toBeUndefined();
204
+ });
205
+
206
+ it('handles undefined _provenance gracefully', () => {
207
+ const v2 = makeV2();
208
+ v2._provenance = undefined;
209
+ const v1 = normalizeToV1(v2);
210
+ expect(v1._generated).toBeUndefined();
211
+ });
212
+
213
+ it('preserves contract and relations', () => {
214
+ const v2 = makeV2();
215
+ v2.relations = [{ component: 'Other', relationship: 'sibling', note: 'test' }];
216
+ const v1 = normalizeToV1(v2);
217
+ expect(v1.contract).toBe(v2.contract);
218
+ expect(v1.relations).toBe(v2.relations);
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // compileFragment — v2 support
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe('compileFragment — v2 support', () => {
227
+ it('compiles v1 definition', () => {
228
+ const v1 = makeV1();
229
+ const compiled = compileFragment(v1, 'test/Stub.fragment.tsx');
230
+ expect(compiled.meta.name).toBe('Stub');
231
+ expect(compiled.usage.when).toEqual(['Testing v1']);
232
+ expect(compiled.variants[0].name).toBe('Default');
233
+ expect(compiled.ai?.compositionPattern).toBe('simple');
234
+ });
235
+
236
+ it('compiles v2 definition (normalizes to v1 output)', () => {
237
+ const v2 = makeV2();
238
+ const compiled = compileFragment(v2, 'test/Stub.fragment.tsx');
239
+ expect(compiled.meta.name).toBe('Stub');
240
+ expect(compiled.usage.when).toEqual(['Testing v2']);
241
+ expect(compiled.variants[0].name).toBe('Default');
242
+ expect(compiled.ai?.compositionPattern).toBe('compound');
243
+ expect(compiled.ai?.subComponents).toEqual(['Header', 'Body']);
244
+ expect(compiled._generated?.source).toBe('ai'); // scan normalized to ai
245
+ });
246
+
247
+ it('compiles v2 without optional fields', () => {
248
+ const v2 = makeV2();
249
+ v2.composition = undefined;
250
+ v2._provenance = undefined;
251
+ v2.contract = undefined;
252
+ const compiled = compileFragment(v2, 'test/Stub.fragment.tsx');
253
+ expect(compiled.ai).toBeUndefined();
254
+ expect(compiled._generated).toBeUndefined();
255
+ expect(compiled.contract).toBeUndefined();
256
+ });
257
+ });