@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,47 @@
1
+ import type { FragmentsConfig, TokenConfig } from "./types.js";
2
+
3
+ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
4
+
5
+ export function normalizeConfigPath(path: string): string {
6
+ return path.trim().replace(/^\.\//, "").replace(/\/+$/, "") || ".";
7
+ }
8
+
9
+ export function portableRepoPathError(path: string): string | null {
10
+ const normalized = path.trim();
11
+ if (!normalized) return "Path must not be empty";
12
+ if (normalized.startsWith("/")) return "Path must be repo-relative, not absolute";
13
+ if (WINDOWS_DRIVE_RE.test(normalized)) {
14
+ return "Path must be repo-relative, not absolute";
15
+ }
16
+ if (normalized.startsWith("~")) return "Path must not use a home directory shortcut";
17
+ if (normalized.includes("\\")) return "Path must use POSIX / separators";
18
+
19
+ const parts = normalized.split("/");
20
+ if (parts.some((part) => part === "..")) {
21
+ return "Path must not contain .. segments";
22
+ }
23
+ if (parts.some((part) => part.length === 0)) {
24
+ return "Path must not contain empty segments";
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ export function isPortableRepoPath(path: string): boolean {
31
+ return portableRepoPathError(path) === null;
32
+ }
33
+
34
+ export function resolveConfiguredAppPath(config: Pick<FragmentsConfig, "app">): string {
35
+ return normalizeConfigPath(config.app?.path ?? ".");
36
+ }
37
+
38
+ export function topologyBase(config: Pick<FragmentsConfig, "topology">): "app" | "repo" {
39
+ return config.topology?.base ?? "app";
40
+ }
41
+
42
+ export function tokenIncludesFromConfig(tokens: TokenConfig): string[] {
43
+ if (tokens.sources?.length) {
44
+ return tokens.sources.map((source) => source.path);
45
+ }
46
+ return tokens.include ?? [];
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { fragmentsConfigSchema } from "./schema.js";
2
+ import type { FragmentsConfig } from "./types.js";
3
+ import { normalizeGovernanceConfig } from "./governance.js";
4
+
5
+ function formatZodErrors(errors: Array<{ path: (string | number)[]; message: string }>) {
6
+ return errors.map((error) => ` - ${error.path.join(".")}: ${error.message}`).join("\n");
7
+ }
8
+
9
+ export function defineConfig<TConfig extends FragmentsConfig>(config: TConfig): TConfig {
10
+ const normalized = normalizeGovernanceConfig(config);
11
+ const result = fragmentsConfigSchema.safeParse(normalized);
12
+
13
+ if (!result.success) {
14
+ throw new Error(`Invalid fragments config:\n${formatZodErrors(result.error.errors)}`);
15
+ }
16
+
17
+ return result.data as TConfig;
18
+ }
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sha256Hex } from "./contract/hash.js";
3
+ import type { ConformInput, ConformResult } from "./conform.js";
4
+ import {
5
+ PROVE_DEFAULT_MAX_PASSES,
6
+ PROVE_MAX_PASSES,
7
+ type ProveSamplerArgs,
8
+ clampMaxPasses,
9
+ emptyConformResult,
10
+ proveCompliant,
11
+ proveIssueCount,
12
+ resolveProveVerdict,
13
+ } from "./conform-prove.js";
14
+
15
+ // ── conform stubs ─────────────────────────────────────────────────────────
16
+
17
+ function clean(code: string): ConformResult {
18
+ return {
19
+ conformed: code,
20
+ changed: false,
21
+ summary: "clean",
22
+ changes: [],
23
+ suggestions: [],
24
+ unresolved: [],
25
+ };
26
+ }
27
+
28
+ const oneChange: ConformResult["changes"] = [
29
+ {
30
+ kind: "replace-token",
31
+ ruleId: "styles/no-raw-color",
32
+ code: "FUI6001",
33
+ severity: "warning",
34
+ message: "Replaced hardcoded color.",
35
+ before: "#2563eb",
36
+ after: "var(--brand-600)",
37
+ confidence: "high",
38
+ },
39
+ ];
40
+
41
+ const oneSuggestion: ConformResult["suggestions"] = [
42
+ { kind: "review-low-confidence", message: "Ambiguous variant.", confidence: "medium" },
43
+ ];
44
+
45
+ /** Replaces a raw color once, then is clean — the canonical 2-pass convergence. */
46
+ function tokenizing(input: ConformInput): ConformResult {
47
+ if (input.code.includes("#2563eb")) {
48
+ return {
49
+ conformed: input.code.replace("#2563eb", "var(--brand-600)"),
50
+ changed: true,
51
+ summary: "Mapped one hardcoded value.",
52
+ changes: oneChange,
53
+ suggestions: [],
54
+ unresolved: [],
55
+ };
56
+ }
57
+ return clean(input.code);
58
+ }
59
+
60
+ /** Always stalls (no deterministic change) with one residual suggestion. */
61
+ function residual(input: ConformInput): ConformResult {
62
+ return {
63
+ conformed: input.code,
64
+ changed: false,
65
+ summary: "Needs review.",
66
+ changes: [],
67
+ suggestions: oneSuggestion,
68
+ unresolved: [],
69
+ };
70
+ }
71
+
72
+ describe("proveCompliant — pure validate-fix-validate primitive", () => {
73
+ it("loops deterministic fixes until a clean pass proves compliance", async () => {
74
+ const out = await proveCompliant(
75
+ { code: '<div style={{ color: "#2563eb" }}>Send</div>' },
76
+ { conform: tokenizing }
77
+ );
78
+ expect(out.verdict).toBe("pass");
79
+ expect(out.provedCompliant).toBe(true);
80
+ expect(out.issueCount).toBe(0);
81
+ expect(out.corrections).toBe(1);
82
+ expect(out.changed).toBe(true);
83
+ expect(out.conformed).toContain("var(--brand-600)");
84
+ expect(out.passHistory.map((p) => ({ pass: p.pass, issues: p.issues }))).toEqual([
85
+ { pass: 1, issues: 1 },
86
+ { pass: 2, issues: 0 },
87
+ ]);
88
+ expect(out.reason).toBe("0 findings after 2 passes.");
89
+ });
90
+
91
+ it("certifies a clean-on-first-pass result with no coverage signal", async () => {
92
+ const out = await proveCompliant({ code: "<div>hi</div>" }, { conform: (i) => clean(i.code) });
93
+ expect(out).toMatchObject({ verdict: "pass", provedCompliant: true, issueCount: 0 });
94
+ expect(out.passHistory).toHaveLength(1);
95
+ expect(out.reason).toBe("0 findings after 1 pass.");
96
+ expect(out.changed).toBe(false);
97
+ });
98
+
99
+ it("returns insufficient_coverage (NOT pass) for a clean run over an empty catalog", async () => {
100
+ const out = await proveCompliant(
101
+ { code: "<div>hi</div>" },
102
+ {
103
+ conform: (i) => ({
104
+ ...clean(i.code),
105
+ coverage: { tokens: 0, components: 0, evaluated: false },
106
+ }),
107
+ }
108
+ );
109
+ expect(out.verdict).toBe("insufficient_coverage");
110
+ expect(out.provedCompliant).toBe(false);
111
+ expect(out.issueCount).toBe(0);
112
+ expect(out.reason).toContain("could not be evaluated");
113
+ });
114
+
115
+ it("fails with residual findings when no sampler is provided", async () => {
116
+ const out = await proveCompliant({ code: "<x />" }, { conform: residual });
117
+ expect(out.verdict).toBe("fail");
118
+ expect(out.issueCount).toBe(1);
119
+ expect(out.reason).toBe("no sampler was available and residual issues remain.");
120
+ expect(out.residual.suggestions).toHaveLength(1);
121
+ });
122
+
123
+ it("does not sample when allowSampling is false", async () => {
124
+ let sampled = false;
125
+ const out = await proveCompliant(
126
+ { code: "<x />" },
127
+ {
128
+ conform: residual,
129
+ sampler: () => {
130
+ sampled = true;
131
+ return null;
132
+ },
133
+ },
134
+ { allowSampling: false }
135
+ );
136
+ expect(sampled).toBe(false);
137
+ expect(out.verdict).toBe("fail");
138
+ expect(out.reason).toBe("sampling was disabled and residual issues remain.");
139
+ });
140
+
141
+ it("uses the sampler for residual issues and re-validates the sampled code", async () => {
142
+ const out = await proveCompliant(
143
+ { code: "<button><Icon /></button>" },
144
+ {
145
+ conform: (i) => (i.code.includes("aria-label") ? clean(i.code) : residual(i)),
146
+ sampler: () => ({
147
+ code: '<button aria-label="Close"><Icon /></button>',
148
+ model: "test-model",
149
+ }),
150
+ }
151
+ );
152
+ expect(out.verdict).toBe("pass");
153
+ expect(out.conformed).toBe('<button aria-label="Close"><Icon /></button>');
154
+ expect(out.passHistory[0]?.sampling).toMatchObject({
155
+ requested: true,
156
+ used: true,
157
+ model: "test-model",
158
+ });
159
+ expect(out.passHistory).toHaveLength(2);
160
+ });
161
+
162
+ it("does NOT accept a sampled fix that still has residual findings", async () => {
163
+ const out = await proveCompliant(
164
+ { code: "<button><Icon /></button>" },
165
+ {
166
+ // Sampler returns a different-but-still-broken snippet; conform never
167
+ // reaches clean, so the loop must not certify compliance.
168
+ conform: (i) => residual(i),
169
+ sampler: ({ code }) => ({ code: `${code}\n{/* attempt */}` }),
170
+ },
171
+ { maxPasses: 3 }
172
+ );
173
+ expect(out.verdict).toBe("fail");
174
+ expect(out.provedCompliant).toBe(false);
175
+ expect(out.issueCount).toBe(1);
176
+ });
177
+
178
+ it("counts a declining sampler (null) as used:false and fails", async () => {
179
+ const out = await proveCompliant({ code: "<x />" }, { conform: residual, sampler: () => null });
180
+ expect(out.verdict).toBe("fail");
181
+ expect(out.passHistory[0]?.sampling).toMatchObject({ requested: true, used: false });
182
+ });
183
+
184
+ it("threads (pass, seq, codeHashAtRequest) into the sampler across rounds", async () => {
185
+ const seen: ProveSamplerArgs["metadata"][] = [];
186
+ const codesSampled: string[] = [];
187
+ await proveCompliant(
188
+ { code: "ROUND0" },
189
+ {
190
+ conform: (i) => residual(i), // always stalls -> sampler invoked each round
191
+ sampler: ({ code, metadata }) => {
192
+ seen.push(metadata);
193
+ codesSampled.push(code);
194
+ return { code: `ROUND${metadata.seq + 1}` }; // always a fresh snippet
195
+ },
196
+ },
197
+ { maxPasses: 4 }
198
+ );
199
+ // maxPasses 4 -> sampler fires at seq 0,1,2 (cap is samplingAttempts < maxPasses-1)
200
+ expect(seen.map((m) => m.seq)).toEqual([0, 1, 2]);
201
+ expect(seen.map((m) => m.pass)).toEqual([1, 2, 3]);
202
+ // codeHashAtRequest is the real SHA-256 of the code the sampler saw.
203
+ expect(seen.map((m) => m.codeHashAtRequest)).toEqual(codesSampled.map((c) => sha256Hex(c)));
204
+ expect(seen[0]?.codeHashAtRequest).toBe(sha256Hex("ROUND0"));
205
+ expect(seen[1]?.codeHashAtRequest).toBe(sha256Hex("ROUND1"));
206
+ });
207
+
208
+ it("stops at maxPasses when deterministic fixes keep producing findings", async () => {
209
+ let calls = 0;
210
+ const out = await proveCompliant(
211
+ { code: "raw" },
212
+ {
213
+ conform: (i) => {
214
+ calls += 1;
215
+ return {
216
+ conformed: `${i.code}\n/* ${calls} */`,
217
+ changed: true,
218
+ summary: "still dirty",
219
+ changes: oneChange,
220
+ suggestions: [],
221
+ unresolved: [],
222
+ };
223
+ },
224
+ },
225
+ { maxPasses: 2 }
226
+ );
227
+ expect(calls).toBe(2);
228
+ expect(out.verdict).toBe("fail");
229
+ expect(out.passHistory).toHaveLength(2);
230
+ expect(out.corrections).toBe(2);
231
+ expect(out.reason).toBe("stopped after 2 passes with 1 finding(s).");
232
+ });
233
+
234
+ it("is deterministic — identical inputs produce identical results", async () => {
235
+ const run = () =>
236
+ proveCompliant(
237
+ { code: '<div style={{ color: "#2563eb" }}>Send</div>' },
238
+ { conform: tokenizing }
239
+ );
240
+ expect(await run()).toEqual(await run());
241
+ });
242
+ });
243
+
244
+ describe("prove primitive helpers", () => {
245
+ it("proveIssueCount sums changes + suggestions + unresolved", () => {
246
+ expect(proveIssueCount(clean("x"))).toBe(0);
247
+ expect(proveIssueCount(residual({ code: "x" }))).toBe(1);
248
+ });
249
+
250
+ it("emptyConformResult is a neutral, unchanged result", () => {
251
+ const r = emptyConformResult("abc");
252
+ expect(r).toMatchObject({ conformed: "abc", changed: false });
253
+ expect(proveIssueCount(r)).toBe(0);
254
+ });
255
+
256
+ it("resolveProveVerdict: coverage trumps everything", () => {
257
+ expect(resolveProveVerdict({ coverageEvaluated: false, provedCompliant: true })).toBe(
258
+ "insufficient_coverage"
259
+ );
260
+ expect(resolveProveVerdict({ coverageEvaluated: true, provedCompliant: true })).toBe("pass");
261
+ expect(resolveProveVerdict({ coverageEvaluated: true, provedCompliant: false })).toBe("fail");
262
+ });
263
+
264
+ it("clampMaxPasses clamps to 1..8 and defaults non-numbers to 4", () => {
265
+ expect(clampMaxPasses(0)).toBe(1);
266
+ expect(clampMaxPasses(1)).toBe(1);
267
+ expect(clampMaxPasses(4)).toBe(PROVE_DEFAULT_MAX_PASSES);
268
+ expect(clampMaxPasses(8)).toBe(PROVE_MAX_PASSES);
269
+ expect(clampMaxPasses(99)).toBe(PROVE_MAX_PASSES);
270
+ expect(clampMaxPasses(3.7)).toBe(3);
271
+ expect(clampMaxPasses(undefined)).toBe(PROVE_DEFAULT_MAX_PASSES);
272
+ expect(clampMaxPasses(Number.NaN)).toBe(PROVE_DEFAULT_MAX_PASSES);
273
+ });
274
+ });
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Contract Mode — Phase 2: the prove-compliant loop as a PURE primitive.
3
+ *
4
+ * A validate-fix-validate loop over UI code: run the conform engine, apply its
5
+ * deterministic fixes, and re-validate until the engine proves zero findings,
6
+ * the pass budget is exhausted, or progress stalls. When deterministic fixes
7
+ * stall on residual issues, an optional async `sampler` repairs them (model
8
+ * call), and the loop re-validates the result — a sampled fix is never trusted
9
+ * without a clean re-validation pass.
10
+ *
11
+ * This is the engine behind `contract.prove` (architecture §2 move #4). It is
12
+ * deliberately framework-agnostic: it takes a `conform` callback (so Cloud,
13
+ * CLI, and the standalone MCP each inject their own engine binding) and returns
14
+ * a plain result — no MCP plumbing, no task store, no transport coupling.
15
+ *
16
+ * Determinism (architecture §6.3): the sampler receives `(seq, codeHashAtRequest)`
17
+ * so a durable-replay layer can key recorded sampler output on the exact code it
18
+ * was asked to repair, and hard-invalidate on a hash mismatch — instead of the
19
+ * pass-index-only key the standalone MCP tool uses.
20
+ *
21
+ * Browser-safe: depends only on the conform contract types + the pure SHA-256
22
+ * in this package. No Node APIs.
23
+ */
24
+ import type {
25
+ ConformInput,
26
+ ConformResult,
27
+ ConformSuggestion,
28
+ ConformUnresolved,
29
+ } from "./conform.js";
30
+ import { sha256Hex } from "./contract/hash.js";
31
+
32
+ /** Upper bound on validate/fix passes. */
33
+ export const PROVE_MAX_PASSES = 8;
34
+ /** Default validate/fix passes when the caller does not specify. */
35
+ export const PROVE_DEFAULT_MAX_PASSES = 4;
36
+
37
+ /**
38
+ * 3-valued verdict. `insufficient_coverage` is NOT a pass: a clean run over an
39
+ * empty catalog (no tokens, no component mappings) proves nothing, so the loop
40
+ * never certifies compliance it could not actually evaluate.
41
+ */
42
+ export type ProveVerdict = "pass" | "fail" | "insufficient_coverage";
43
+
44
+ export interface ProvePassSampling {
45
+ requested?: boolean;
46
+ used?: boolean;
47
+ model?: string;
48
+ stopReason?: string;
49
+ reason?: string;
50
+ }
51
+
52
+ export interface ProvePass {
53
+ pass: number;
54
+ issues: number;
55
+ deterministicChanges: number;
56
+ suggestions: number;
57
+ unresolved: number;
58
+ changed: boolean;
59
+ sampling?: ProvePassSampling;
60
+ }
61
+
62
+ export interface ProveResidual {
63
+ suggestions: ConformSuggestion[];
64
+ unresolved: ConformUnresolved[];
65
+ }
66
+
67
+ export interface ProveSample {
68
+ /** Repaired code, or omitted/empty when the sampler declined. */
69
+ code?: string;
70
+ model?: string;
71
+ stopReason?: string;
72
+ reason?: string;
73
+ }
74
+
75
+ export interface ProveSamplerArgs {
76
+ /** The current code the sampler is asked to repair. */
77
+ code: string;
78
+ /** The residual findings deterministic fixes could not resolve. */
79
+ residual: ProveResidual;
80
+ metadata: {
81
+ /** 1-indexed validate/fix pass that stalled. */
82
+ pass: number;
83
+ /** 0-indexed sampler attempt counter (independent of `pass`). */
84
+ seq: number;
85
+ /** SHA-256 of `code` — the durable-replay key + hard-invalidation guard. */
86
+ codeHashAtRequest: string;
87
+ };
88
+ }
89
+
90
+ export interface ProveCallbacks {
91
+ /** Validate + apply deterministic fixes for one pass. */
92
+ conform(input: ConformInput): Promise<ConformResult> | ConformResult;
93
+ /** Optional model-backed repair for residual issues conform cannot resolve. */
94
+ sampler?(args: ProveSamplerArgs): Promise<ProveSample | null> | ProveSample | null;
95
+ }
96
+
97
+ export interface ProveOptions {
98
+ /** Max validate/fix passes (clamped to 1..8). Defaults to 4. */
99
+ maxPasses?: number;
100
+ /** Allow the sampler to repair residual issues. Defaults to true. */
101
+ allowSampling?: boolean;
102
+ }
103
+
104
+ export interface ProveLoopResult {
105
+ /** Code after all passes + sampling. */
106
+ conformed: string;
107
+ /** Whether `conformed` differs from the input code. */
108
+ changed: boolean;
109
+ verdict: ProveVerdict;
110
+ /** True only on a clean, evaluated pass. */
111
+ provedCompliant: boolean;
112
+ /** Findings remaining in the final pass (changes + suggestions + unresolved). */
113
+ issueCount: number;
114
+ /** Total deterministic edits applied across all passes. */
115
+ corrections: number;
116
+ passHistory: ProvePass[];
117
+ residual: ProveResidual;
118
+ /** The final conform result (for receipt / summary rendering). */
119
+ result: ConformResult;
120
+ /** Human-readable reason for the verdict. */
121
+ reason: string;
122
+ }
123
+
124
+ /** Total findings in a conform result: applied changes + suggestions + unresolved. */
125
+ export function proveIssueCount(result: ConformResult): number {
126
+ return result.changes.length + result.suggestions.length + result.unresolved.length;
127
+ }
128
+
129
+ /** A neutral conform result for the "no result produced" fallback path. */
130
+ export function emptyConformResult(code: string): ConformResult {
131
+ return {
132
+ conformed: code,
133
+ changed: false,
134
+ summary: "No conform result was produced.",
135
+ changes: [],
136
+ suggestions: [],
137
+ unresolved: [],
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Resolve the 3-valued verdict. Coverage trumps everything: an unevaluated
143
+ * catalog is `insufficient_coverage` regardless of issue count.
144
+ */
145
+ export function resolveProveVerdict(args: {
146
+ coverageEvaluated: boolean;
147
+ provedCompliant: boolean;
148
+ }): ProveVerdict {
149
+ if (!args.coverageEvaluated) return "insufficient_coverage";
150
+ return args.provedCompliant ? "pass" : "fail";
151
+ }
152
+
153
+ /** Clamp a requested pass count into the supported 1..8 range (default 4). */
154
+ export function clampMaxPasses(value: number | undefined): number {
155
+ if (typeof value !== "number" || Number.isNaN(value)) {
156
+ return PROVE_DEFAULT_MAX_PASSES;
157
+ }
158
+ return Math.min(PROVE_MAX_PASSES, Math.max(1, Math.trunc(value)));
159
+ }
160
+
161
+ function incompleteReason(args: {
162
+ passCount: number;
163
+ maxPasses: number;
164
+ finalIssues: number;
165
+ allowSampling: boolean;
166
+ hasSampler: boolean;
167
+ }): string {
168
+ if (args.passCount >= args.maxPasses) {
169
+ return `stopped after ${args.maxPasses} passes with ${args.finalIssues} finding(s).`;
170
+ }
171
+ if (!args.allowSampling) {
172
+ return "sampling was disabled and residual issues remain.";
173
+ }
174
+ if (!args.hasSampler) {
175
+ return "no sampler was available and residual issues remain.";
176
+ }
177
+ return `${args.finalIssues} residual finding(s) remain after deterministic and sampled fixes.`;
178
+ }
179
+
180
+ /**
181
+ * Run the validate-fix-validate loop. Pure: never mutates `input`; all state
182
+ * flows through the return value.
183
+ */
184
+ export async function proveCompliant(
185
+ input: ConformInput,
186
+ callbacks: ProveCallbacks,
187
+ options: ProveOptions = {}
188
+ ): Promise<ProveLoopResult> {
189
+ const maxPasses = clampMaxPasses(options.maxPasses);
190
+ const allowSampling = options.allowSampling !== false;
191
+ const { filename } = input;
192
+ const hasSampler = typeof callbacks.sampler === "function";
193
+
194
+ let current = input.code;
195
+ const passHistory: ProvePass[] = [];
196
+ let corrections = 0;
197
+ let samplingAttempts = 0;
198
+ let lastResult: ConformResult | null = null;
199
+
200
+ while (passHistory.length < maxPasses) {
201
+ const pass = passHistory.length + 1;
202
+ const result = await callbacks.conform({
203
+ code: current,
204
+ filename,
205
+ apply: "deterministic",
206
+ });
207
+ lastResult = result;
208
+
209
+ const issues = proveIssueCount(result);
210
+ passHistory.push({
211
+ pass,
212
+ issues,
213
+ deterministicChanges: result.changes.length,
214
+ suggestions: result.suggestions.length,
215
+ unresolved: result.unresolved.length,
216
+ changed: result.changed,
217
+ });
218
+ corrections += result.changes.length;
219
+
220
+ if (issues === 0) {
221
+ const coverageEvaluated = result.coverage?.evaluated !== false;
222
+ return {
223
+ conformed: current,
224
+ changed: current !== input.code,
225
+ verdict: resolveProveVerdict({
226
+ coverageEvaluated,
227
+ provedCompliant: coverageEvaluated,
228
+ }),
229
+ provedCompliant: coverageEvaluated,
230
+ issueCount: 0,
231
+ corrections,
232
+ passHistory,
233
+ residual: { suggestions: [], unresolved: [] },
234
+ result,
235
+ reason: coverageEvaluated
236
+ ? `0 findings after ${pass} pass${pass === 1 ? "" : "es"}.`
237
+ : "the design-system catalog has no tokens or components, so compliance could not be evaluated.",
238
+ };
239
+ }
240
+
241
+ // Deterministic progress — keep looping on the rewritten code.
242
+ if (result.changed && result.conformed !== current) {
243
+ current = result.conformed;
244
+ continue;
245
+ }
246
+
247
+ // Conform stalled with residual issues. Try the sampler if it is available
248
+ // and we are under the attempt cap (kept strictly below maxPasses so a
249
+ // revalidation pass always remains in budget).
250
+ //
251
+ // Sampling semantics (intentional, and DIFFERENT from the standalone MCP
252
+ // tool): the loop re-samples only while the sampler keeps producing *new*
253
+ // code (each fresh snippet is re-validated on the next pass); a sample that
254
+ // returns nothing new — null, no `code`, or byte-identical code — ends the
255
+ // loop. Re-asking the model with the identical residual rarely helps and
256
+ // burns tokens. The MCP tool instead retries up to its cap on each stall
257
+ // (its suspend/resume transport makes per-stall retry the natural shape);
258
+ // both are bounded by maxPasses. See docs/contract-mode/01-architecture.md.
259
+ const residual: ProveResidual = {
260
+ suggestions: result.suggestions,
261
+ unresolved: result.unresolved,
262
+ };
263
+ const canSample =
264
+ allowSampling &&
265
+ hasSampler &&
266
+ samplingAttempts < maxPasses - 1 &&
267
+ (residual.suggestions.length > 0 || residual.unresolved.length > 0);
268
+
269
+ if (canSample) {
270
+ const seq = samplingAttempts;
271
+ const codeHashAtRequest = sha256Hex(current);
272
+ const last = passHistory[passHistory.length - 1];
273
+ if (last) last.sampling = { requested: true };
274
+
275
+ const sampled = await callbacks.sampler!({
276
+ code: current,
277
+ residual,
278
+ metadata: { pass, seq, codeHashAtRequest },
279
+ });
280
+ samplingAttempts += 1;
281
+
282
+ if (last) {
283
+ last.sampling = {
284
+ ...(last.sampling ?? {}),
285
+ used: Boolean(sampled?.code),
286
+ model: sampled?.model,
287
+ stopReason: sampled?.stopReason,
288
+ reason: sampled?.reason,
289
+ };
290
+ }
291
+
292
+ if (sampled?.code && sampled.code !== current) {
293
+ current = sampled.code;
294
+ continue;
295
+ }
296
+ }
297
+
298
+ break;
299
+ }
300
+
301
+ const fallback = lastResult ?? emptyConformResult(current);
302
+ const finalIssues = proveIssueCount(fallback);
303
+ const coverageEvaluated = fallback.coverage?.evaluated !== false;
304
+
305
+ return {
306
+ conformed: current,
307
+ changed: current !== input.code,
308
+ verdict: resolveProveVerdict({ coverageEvaluated, provedCompliant: false }),
309
+ provedCompliant: false,
310
+ issueCount: finalIssues,
311
+ corrections,
312
+ passHistory,
313
+ residual: {
314
+ suggestions: fallback.suggestions,
315
+ unresolved: fallback.unresolved,
316
+ },
317
+ result: fallback,
318
+ reason: incompleteReason({
319
+ passCount: passHistory.length,
320
+ maxPasses,
321
+ finalIssues,
322
+ allowSampling,
323
+ hasSampler,
324
+ }),
325
+ };
326
+ }