@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,94 @@
1
+ /**
2
+ * `components/unknown-prop` — fires when JSX uses a prop that isn't in the
3
+ * component's declared prop schema and the global `jsx.unknownProps.forbid`
4
+ * policy is active. Universal React/DOM props (data-*, aria-*, on*, key, ref,
5
+ * className, style, etc.) are exempt.
6
+ *
7
+ * The rule only fires for components we have prop metadata for. If the
8
+ * component has no `prop_metadata` facts (e.g., third-party imports we don't
9
+ * model yet), we cannot tell whether a prop is unknown and the rule stays
10
+ * silent.
11
+ */
12
+
13
+ import type { FactIndex, FactId } from "../facts/index.js";
14
+
15
+ import { makeFinding } from "./finding.js";
16
+ import type { Finding } from "./types.js";
17
+ import {
18
+ indexComponentByNodeId,
19
+ indexPropsByNodeId,
20
+ isUniversalJsxProp,
21
+ readUsageNode,
22
+ } from "./utils.js";
23
+
24
+ export const RULE_ID = "components/unknown-prop";
25
+ export const RULE_VERSION = "1";
26
+
27
+ export function ruleComponentsUnknownProp(ix: FactIndex): Finding[] {
28
+ const policy = ix.policy.jsxUnknownPropsForbidden();
29
+ if (!policy) return [];
30
+
31
+ const findings: Finding[] = [];
32
+ const componentByNode = indexComponentByNodeId(ix);
33
+ const propsByNode = indexPropsByNodeId(ix);
34
+
35
+ for (const [nodeId, propUsages] of propsByNode) {
36
+ const usageComponent = componentByNode.get(nodeId);
37
+ if (!usageComponent) continue;
38
+
39
+ const knownProps = ix.components.propsOf(usageComponent.componentId);
40
+ if (knownProps.length === 0) continue;
41
+ const knownNames = new Set(knownProps.map((p) => p.prop));
42
+
43
+ const node = readUsageNode(ix, nodeId);
44
+ if (!node) continue;
45
+
46
+ for (const propUsage of propUsages) {
47
+ if (propUsage.resolution === "spread") continue;
48
+ if (isUniversalJsxProp(propUsage.prop)) continue;
49
+ if (knownNames.has(propUsage.prop)) continue;
50
+
51
+ findings.push(
52
+ makeFinding({
53
+ ruleId: RULE_ID,
54
+ ruleVersion: RULE_VERSION,
55
+ severity: policy.severity,
56
+ message: `Unknown prop "${propUsage.prop}" on <${node.element}>. Allowed props: ${formatPropList(knownNames)}.`,
57
+ location: node.location,
58
+ evidence: ix.evidence([node.id, usageComponent.id, propUsage.id, policy.id]),
59
+ fingerprintIdentity: {
60
+ file: node.file,
61
+ componentId: usageComponent.componentId,
62
+ element: node.element,
63
+ nodePath: node.nodePath,
64
+ prop: propUsage.prop,
65
+ } satisfies UnknownPropFingerprint,
66
+ attributes: {
67
+ componentId: usageComponent.componentId,
68
+ prop: propUsage.prop,
69
+ },
70
+ })
71
+ );
72
+ }
73
+ }
74
+
75
+ return findings;
76
+ }
77
+
78
+ interface UnknownPropFingerprint {
79
+ file: string;
80
+ componentId: string;
81
+ element: string;
82
+ nodePath: string;
83
+ prop: string;
84
+ }
85
+
86
+ function formatPropList(names: Set<string>): string {
87
+ if (names.size === 0) return "(none)";
88
+ const sorted = [...names].sort();
89
+ if (sorted.length <= 6) return sorted.join(", ");
90
+ return `${sorted.slice(0, 6).join(", ")}, …`;
91
+ }
92
+
93
+ // Re-exported for type-tests / tooling that wants to read the rule's id.
94
+ export type { FactId };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The composition config contract — shared across the policy boundary.
3
+ *
4
+ * `CompositionPattern` is what the `composition/cardinality` and
5
+ * `composition/co-occurrence` rules read from
6
+ * `governance_rule_config.options.patterns`. Brief 03 defined it inline in the
7
+ * rule; brief 04 lifted it here because the same shape now has to round-trip
8
+ * across three boundaries that must not all hand-roll it:
9
+ * - the rule's `isCompositionPattern` guard (validates untyped policy facts),
10
+ * - cloud policy storage + delivery (`apps/cloud` validates on write against
11
+ * `compositionPatternSchema` before persisting), and
12
+ * - fragment authoring sugar (`lowerCompositionContract` lowers a fragment's
13
+ * `contract.composition` block to the same `CompositionPattern[]`).
14
+ *
15
+ * Defining it once as a zod schema means the wire shape and the runtime guard
16
+ * are the same artifact — the guard is `safeParse().success`, never a second
17
+ * copy that can drift from the type.
18
+ *
19
+ * Browser-safe: imports `zod` only. No facts, no Node, no JSX.
20
+ */
21
+
22
+ import { z } from "zod";
23
+
24
+ // ── The rule-input contract ──────────────────────────────────────────────
25
+
26
+ /** Which container a pattern applies to. `component` = canonical componentId. */
27
+ export const compositionRegionSelectorSchema = z.object({
28
+ component: z.string().optional(),
29
+ /** Marker attribute (e.g. `data-fui-region`). Typed; matched in a later brief. */
30
+ marker: z.string().optional(),
31
+ role: z.string().optional(),
32
+ });
33
+
34
+ /** Which children inside the region the constraint counts. */
35
+ export const compositionChildSelectorSchema = z.object({
36
+ component: z.string(),
37
+ prop: z.string().optional(),
38
+ value: z.string().optional(),
39
+ });
40
+
41
+ export const compositionConstraintSchema = z.discriminatedUnion("kind", [
42
+ z.object({
43
+ kind: z.literal("cardinality"),
44
+ max: z.number().optional(),
45
+ min: z.number().optional(),
46
+ }),
47
+ z.object({
48
+ kind: z.literal("co-occurrence"),
49
+ requires: z.object({ prop: z.string(), value: z.string() }),
50
+ }),
51
+ ]);
52
+
53
+ export const compositionPatternSchema = z.object({
54
+ region: compositionRegionSelectorSchema,
55
+ select: compositionChildSelectorSchema,
56
+ constraint: compositionConstraintSchema,
57
+ });
58
+
59
+ export type CompositionRegionSelector = z.infer<typeof compositionRegionSelectorSchema>;
60
+ export type CompositionChildSelector = z.infer<typeof compositionChildSelectorSchema>;
61
+ export type CompositionConstraint = z.infer<typeof compositionConstraintSchema>;
62
+ export type CompositionPattern = z.infer<typeof compositionPatternSchema>;
63
+
64
+ /**
65
+ * Type guard for a single policy entry. Config arrives untyped from cloud
66
+ * policy / fragment prose and the facts layer passes `options` through without
67
+ * a shape, so a malformed entry would otherwise emit garbage findings. The rule
68
+ * filters `options.patterns` through this; a bad entry is skipped, the rest keep
69
+ * scanning (architecture failure mode).
70
+ */
71
+ export function isCompositionPattern(value: unknown): value is CompositionPattern {
72
+ return compositionPatternSchema.safeParse(value).success;
73
+ }
74
+
75
+ // ── Authoring sugar → constraint (Layer 3, brief 04 §10) ──────────────────
76
+
77
+ /**
78
+ * The structured block a fragment author writes in `contract.composition`. This
79
+ * is the enforceable form of the prose they already keep (`relations`,
80
+ * `usage.whenNot`); `lowerCompositionContract` turns it into the same
81
+ * `CompositionPattern[]` the rule reads from cloud policy. v0 is this explicit
82
+ * block — free-text inference from `whenNot` strings is out of scope.
83
+ */
84
+ export const compositionAuthoringEntrySchema = z.object({
85
+ inRegion: z.string(),
86
+ rule: z.union([
87
+ z.object({ atMost: z.number(), of: compositionChildSelectorSchema }),
88
+ z.object({
89
+ when: z.object({
90
+ component: z.string(),
91
+ prop: z.string(),
92
+ value: z.string(),
93
+ }),
94
+ require: z.object({ prop: z.string(), value: z.string() }),
95
+ }),
96
+ ]),
97
+ });
98
+
99
+ export type CompositionAuthoringEntry = z.infer<typeof compositionAuthoringEntrySchema>;
100
+
101
+ /**
102
+ * Lower a fragment's `contract.composition` block to the policy patterns the
103
+ * rule consumes. The authoring shape ({ inRegion, rule }) and the rule shape
104
+ * ({ region, select, constraint }) converge here, so fragment-authored and
105
+ * cloud-delivered constraints reach the rule as one identical config.
106
+ */
107
+ export function lowerCompositionContract(
108
+ entries: CompositionAuthoringEntry[] | undefined
109
+ ): CompositionPattern[] {
110
+ return (entries ?? []).map((entry) => {
111
+ const region = { component: entry.inRegion };
112
+ if ("atMost" in entry.rule) {
113
+ return {
114
+ region,
115
+ select: entry.rule.of,
116
+ constraint: { kind: "cardinality", max: entry.rule.atMost },
117
+ };
118
+ }
119
+ return {
120
+ region,
121
+ select: entry.rule.when,
122
+ constraint: { kind: "co-occurrence", requires: entry.rule.require },
123
+ };
124
+ });
125
+ }
@@ -0,0 +1,320 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ asComponentId,
5
+ compileGlobalGovernanceFacts,
6
+ componentId as makeComponentId,
7
+ FactIndex,
8
+ makeGovernanceRuleConfigFact,
9
+ makeUsageComponentFact,
10
+ makeUsageNodeFact,
11
+ makeUsagePropResolvedFact,
12
+ } from "../facts/index.js";
13
+ import type { GovernanceConfig } from "../governance.js";
14
+
15
+ import { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
16
+ import {
17
+ type CompositionAuthoringEntry,
18
+ type CompositionPattern,
19
+ lowerCompositionContract,
20
+ } from "./composition-pattern-schema.js";
21
+
22
+ const BUTTON_GROUP = makeComponentId("@usefragments/ui", "ButtonGroup");
23
+ const BUTTON = makeComponentId("@usefragments/ui", "Button");
24
+ const FILE = "src/App.tsx";
25
+
26
+ // The shared example policies from ACCEPTANCE.md.
27
+ const POLICY_CARDINALITY: CompositionPattern = {
28
+ region: { component: "ButtonGroup" },
29
+ select: { component: "Button", prop: "variant", value: "primary" },
30
+ constraint: { kind: "cardinality", max: 1 },
31
+ };
32
+ const POLICY_COOCCURRENCE: CompositionPattern = {
33
+ region: { component: "ButtonGroup" },
34
+ select: { component: "Button", prop: "variant", value: "primary" },
35
+ constraint: { kind: "co-occurrence", requires: { prop: "variant", value: "secondary" } },
36
+ };
37
+
38
+ interface NodeSpec {
39
+ path: string;
40
+ element: string;
41
+ /** Canonical component the node resolves to (omit for an untracked element). */
42
+ component?: ReturnType<typeof makeComponentId>;
43
+ /** A `variant` prop and how it resolves (omit for none). */
44
+ variant?: { value?: string; resolution?: "static" | "dynamic" };
45
+ }
46
+
47
+ function add(ix: FactIndex, spec: NodeSpec): void {
48
+ const node = makeUsageNodeFact({
49
+ file: FILE,
50
+ nodePath: spec.path,
51
+ element: spec.element,
52
+ location: { file: FILE, line: 1, column: 1 },
53
+ });
54
+ ix.add(node);
55
+ if (spec.component) {
56
+ ix.add(makeUsageComponentFact({ nodeId: node.id, componentId: spec.component }));
57
+ }
58
+ if (spec.variant) {
59
+ ix.add(
60
+ makeUsagePropResolvedFact({
61
+ nodeId: node.id,
62
+ prop: "variant",
63
+ resolution: spec.variant.resolution ?? "static",
64
+ value: spec.variant.value,
65
+ })
66
+ );
67
+ }
68
+ }
69
+
70
+ function withPolicy(ix: FactIndex, ruleId: string, patterns: CompositionPattern[]): FactIndex {
71
+ ix.add(makeGovernanceRuleConfigFact({ ruleId, enabled: true, options: { patterns } }));
72
+ return ix;
73
+ }
74
+
75
+ const buttonGroup = (path: string): NodeSpec => ({
76
+ path,
77
+ element: "ButtonGroup",
78
+ component: BUTTON_GROUP,
79
+ });
80
+ const button = (path: string, variant?: NodeSpec["variant"]): NodeSpec => ({
81
+ path,
82
+ element: "Button",
83
+ component: BUTTON,
84
+ variant,
85
+ });
86
+
87
+ describe("composition/cardinality", () => {
88
+ // ACCEPTANCE §3
89
+ it("flags a second primary Button in one ButtonGroup", () => {
90
+ const ix = new FactIndex();
91
+ add(ix, buttonGroup("0:0"));
92
+ add(ix, button("0:0/0", { value: "primary" }));
93
+ add(ix, button("0:0/1", { value: "primary" }));
94
+ withPolicy(ix, "composition/cardinality", [POLICY_CARDINALITY]);
95
+
96
+ const findings = ruleCompositionCardinality(ix);
97
+ expect(findings).toHaveLength(1);
98
+ expect(findings[0]).toMatchObject({
99
+ ruleId: "composition/cardinality",
100
+ level: "warn",
101
+ code: "FUI5003",
102
+ message: "At most one primary Button is allowed per ButtonGroup (found 2).",
103
+ });
104
+ });
105
+
106
+ it("matches fully-qualified component refs across package authoring epochs", () => {
107
+ const ix = new FactIndex();
108
+ add(ix, {
109
+ path: "0:0",
110
+ element: "ButtonGroup",
111
+ component: asComponentId("@usefragments/ui#ButtonGroup"),
112
+ });
113
+ add(ix, {
114
+ path: "0:0/0",
115
+ element: "Button",
116
+ component: asComponentId("@usefragments/ui#Button"),
117
+ variant: { value: "primary" },
118
+ });
119
+ add(ix, {
120
+ path: "0:0/1",
121
+ element: "Button",
122
+ component: asComponentId("@usefragments/ui#Button"),
123
+ variant: { value: "primary" },
124
+ });
125
+ withPolicy(ix, "composition/cardinality", [
126
+ {
127
+ region: { component: "@usefragments/ui#ButtonGroup" },
128
+ select: {
129
+ component: "@usefragments/ui#Button",
130
+ prop: "variant",
131
+ value: "primary",
132
+ },
133
+ constraint: { kind: "cardinality", max: 1 },
134
+ },
135
+ ]);
136
+
137
+ expect(ruleCompositionCardinality(ix)).toHaveLength(1);
138
+ });
139
+
140
+ // ACCEPTANCE §4
141
+ it("does not flag a primary + secondary Button", () => {
142
+ const ix = new FactIndex();
143
+ add(ix, buttonGroup("0:0"));
144
+ add(ix, button("0:0/0", { value: "primary" }));
145
+ add(ix, button("0:0/1", { value: "secondary" }));
146
+ withPolicy(ix, "composition/cardinality", [POLICY_CARDINALITY]);
147
+
148
+ expect(ruleCompositionCardinality(ix)).toHaveLength(0);
149
+ });
150
+
151
+ // ACCEPTANCE §5 — per region, not per file.
152
+ it("counts cardinality per region, not across the file", () => {
153
+ const ix = new FactIndex();
154
+ // <> <ButtonGroup><Button primary/></ButtonGroup> <ButtonGroup><Button primary/></ButtonGroup> </>
155
+ add(ix, { path: "0:0", element: "Fragment" }); // untracked container
156
+ add(ix, buttonGroup("0:0/0"));
157
+ add(ix, button("0:0/0/0", { value: "primary" }));
158
+ add(ix, buttonGroup("0:0/1"));
159
+ add(ix, button("0:0/1/0", { value: "primary" }));
160
+ withPolicy(ix, "composition/cardinality", [POLICY_CARDINALITY]);
161
+
162
+ expect(ruleCompositionCardinality(ix)).toHaveLength(0);
163
+ });
164
+
165
+ // ACCEPTANCE §6 — static-resolution ceiling.
166
+ it("does not flag dynamic variants (unknown, not primary)", () => {
167
+ const ix = new FactIndex();
168
+ add(ix, buttonGroup("0:0"));
169
+ add(ix, button("0:0/0", { resolution: "dynamic" }));
170
+ add(ix, button("0:0/1", { resolution: "dynamic" }));
171
+ withPolicy(ix, "composition/cardinality", [POLICY_CARDINALITY]);
172
+
173
+ expect(ruleCompositionCardinality(ix)).toHaveLength(0);
174
+ });
175
+
176
+ // Foundation guard (not an ACCEPTANCE case): `min` is symmetric to `max`.
177
+ it("flags fewer than the required minimum", () => {
178
+ const ix = new FactIndex();
179
+ add(ix, buttonGroup("0:0"));
180
+ add(ix, button("0:0/0", { value: "primary" }));
181
+ withPolicy(ix, "composition/cardinality", [
182
+ {
183
+ region: { component: "ButtonGroup" },
184
+ select: { component: "Button", prop: "variant", value: "primary" },
185
+ constraint: { kind: "cardinality", min: 2 },
186
+ },
187
+ ]);
188
+
189
+ const findings = ruleCompositionCardinality(ix);
190
+ expect(findings).toHaveLength(1);
191
+ expect(findings[0].message).toBe(
192
+ "At least 2 primary Buttons are required per ButtonGroup (found 1)."
193
+ );
194
+ });
195
+
196
+ it("emits nothing when no policy is configured", () => {
197
+ const ix = new FactIndex();
198
+ add(ix, buttonGroup("0:0"));
199
+ add(ix, button("0:0/0", { value: "primary" }));
200
+ add(ix, button("0:0/1", { value: "primary" }));
201
+
202
+ expect(ruleCompositionCardinality(ix)).toHaveLength(0);
203
+ });
204
+
205
+ // Hardening: a malformed pattern is skipped; valid ones in the same array still fire.
206
+ it("skips malformed patterns without breaking the scan", () => {
207
+ const ix = new FactIndex();
208
+ add(ix, buttonGroup("0:0"));
209
+ add(ix, button("0:0/0", { value: "primary" }));
210
+ add(ix, button("0:0/1", { value: "primary" }));
211
+ ix.add(
212
+ makeGovernanceRuleConfigFact({
213
+ ruleId: "composition/cardinality",
214
+ enabled: true,
215
+ options: { patterns: [{ nonsense: true }, POLICY_CARDINALITY] },
216
+ })
217
+ );
218
+
219
+ const findings = ruleCompositionCardinality(ix);
220
+ expect(findings).toHaveLength(1);
221
+ expect(findings[0].message).toBe(
222
+ "At most one primary Button is allowed per ButtonGroup (found 2)."
223
+ );
224
+ });
225
+ });
226
+
227
+ describe("composition/co-occurrence", () => {
228
+ // ACCEPTANCE §7
229
+ it("flags a primary Button without the required secondary", () => {
230
+ const ix = new FactIndex();
231
+ add(ix, buttonGroup("0:0"));
232
+ add(ix, button("0:0/0", { value: "primary" }));
233
+ withPolicy(ix, "composition/co-occurrence", [POLICY_COOCCURRENCE]);
234
+
235
+ const findings = ruleCompositionCoOccurrence(ix);
236
+ expect(findings).toHaveLength(1);
237
+ expect(findings[0]).toMatchObject({
238
+ ruleId: "composition/co-occurrence",
239
+ level: "warn",
240
+ code: "FUI5004",
241
+ message: "A ButtonGroup with a primary Button should also contain a secondary Button.",
242
+ });
243
+ });
244
+
245
+ // ACCEPTANCE §8
246
+ it("does not flag when the required secondary is present", () => {
247
+ const ix = new FactIndex();
248
+ add(ix, buttonGroup("0:0"));
249
+ add(ix, button("0:0/0", { value: "primary" }));
250
+ add(ix, button("0:0/1", { value: "secondary" }));
251
+ withPolicy(ix, "composition/co-occurrence", [POLICY_COOCCURRENCE]);
252
+
253
+ expect(ruleCompositionCoOccurrence(ix)).toHaveLength(0);
254
+ });
255
+ });
256
+
257
+ // ACCEPTANCE §9 — a cloud-managed config round-trips into the rule input.
258
+ describe("composition policy round-trip (§9)", () => {
259
+ it("compiles a delivered governance config into the §3 finding", () => {
260
+ const ix = new FactIndex();
261
+ add(ix, buttonGroup("0:0"));
262
+ add(ix, button("0:0/0", { value: "primary" }));
263
+ add(ix, button("0:0/1", { value: "primary" }));
264
+
265
+ // The exact shape Cloud delivers via GET /api/govern/policy (governConfig):
266
+ // a `composition/cardinality` rule config carrying `options.patterns`. This
267
+ // exercises the GovernanceConfig → governance_rule_config fact path the CLI
268
+ // runs, not a hand-built fact — proving the cloud→CLI contract end to end.
269
+ const govern: GovernanceConfig = {
270
+ rules: {
271
+ "composition/cardinality": {
272
+ enabled: true,
273
+ severity: "warn",
274
+ options: { patterns: [POLICY_CARDINALITY] },
275
+ },
276
+ },
277
+ };
278
+ for (const fact of compileGlobalGovernanceFacts(govern)) ix.add(fact);
279
+
280
+ const findings = ruleCompositionCardinality(ix);
281
+ expect(findings).toHaveLength(1);
282
+ expect(findings[0]).toMatchObject({
283
+ ruleId: "composition/cardinality",
284
+ level: "warn",
285
+ code: "FUI5003",
286
+ message: "At most one primary Button is allowed per ButtonGroup (found 2).",
287
+ });
288
+ });
289
+ });
290
+
291
+ // ACCEPTANCE §10 — fragment authoring sugar lowers to the same pattern the rule
292
+ // reads, so authoring and cloud policy converge on one config.
293
+ describe("lowerCompositionContract (§10)", () => {
294
+ it("lowers a cardinality block to POLICY_CARDINALITY", () => {
295
+ const block: CompositionAuthoringEntry[] = [
296
+ {
297
+ inRegion: "ButtonGroup",
298
+ rule: { atMost: 1, of: { component: "Button", prop: "variant", value: "primary" } },
299
+ },
300
+ ];
301
+ expect(lowerCompositionContract(block)).toEqual([POLICY_CARDINALITY]);
302
+ });
303
+
304
+ it("lowers a co-occurrence block to POLICY_COOCCURRENCE", () => {
305
+ const block: CompositionAuthoringEntry[] = [
306
+ {
307
+ inRegion: "ButtonGroup",
308
+ rule: {
309
+ when: { component: "Button", prop: "variant", value: "primary" },
310
+ require: { prop: "variant", value: "secondary" },
311
+ },
312
+ },
313
+ ];
314
+ expect(lowerCompositionContract(block)).toEqual([POLICY_COOCCURRENCE]);
315
+ });
316
+
317
+ it("returns no patterns for an absent block", () => {
318
+ expect(lowerCompositionContract(undefined)).toEqual([]);
319
+ });
320
+ });