@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,132 @@
1
+ import type { FactIndex } from "../facts/index.js";
2
+
3
+ import { makeFinding } from "./finding.js";
4
+ import type { Finding } from "./types.js";
5
+
6
+ export const RULE_ID = "theme/no-theme-coupled-literal";
7
+ export const RULE_VERSION = "1";
8
+
9
+ const COLOR_LITERAL_GLOBAL =
10
+ /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|oklab|oklch|lab|lch)\(\s*[\d.%\s,\/]+\s*\)/g;
11
+ const WHITE_RGBA = /^rgba?\(\s*255\s*,\s*255\s*,\s*255/i;
12
+ const BLACK_RGBA = /^rgba?\(\s*0\s*,\s*0\s*,\s*0/i;
13
+ const COLOR_MIX_OPEN = "color-mix(";
14
+ const BOX_SHADOW_PROP = /^(?:-webkit-|-moz-)?box-shadow$/i;
15
+ const NEUTRAL_SHADOW = /^(?:none|inherit|initial|unset|revert|revert-layer)\s*$/i;
16
+
17
+ export function ruleThemeNoThemeCoupledLiteral(ix: FactIndex): Finding[] {
18
+ const policy = ix.policy.ruleConfig(RULE_ID);
19
+ if (!policy?.enabled) return [];
20
+
21
+ const findings: Finding[] = [];
22
+ for (const decl of ix.byKind("style_declaration")) {
23
+ if (isTokenFile(decl.file)) continue;
24
+ const hit = findThemeCoupledLiteral(decl.property, decl.value);
25
+ if (!hit) continue;
26
+
27
+ findings.push(
28
+ makeFinding({
29
+ ruleId: RULE_ID,
30
+ ruleVersion: RULE_VERSION,
31
+ severity: policy.severity ?? "warn",
32
+ message: hit.message,
33
+ location: decl.location,
34
+ evidence: ix.evidence([decl.id, policy.id]),
35
+ fingerprintIdentity: {
36
+ file: decl.file,
37
+ selector: decl.selector,
38
+ declarationPath: decl.declarationPath,
39
+ property: decl.property,
40
+ rawValue: hit.rawValue,
41
+ reason: hit.reason,
42
+ },
43
+ attributes: {
44
+ property: decl.property,
45
+ rawValue: decl.value,
46
+ color: hit.rawValue,
47
+ suggestion: hit.suggestion,
48
+ source: "css",
49
+ },
50
+ }),
51
+ );
52
+ }
53
+
54
+ return findings;
55
+ }
56
+
57
+ function findThemeCoupledLiteral(
58
+ property: string,
59
+ value: string,
60
+ ): { rawValue: string; reason: string; message: string; suggestion: string } | null {
61
+ COLOR_LITERAL_GLOBAL.lastIndex = 0;
62
+ let match: RegExpExecArray | null;
63
+ while ((match = COLOR_LITERAL_GLOBAL.exec(value)) !== null) {
64
+ const raw = match[0];
65
+ if (insideLightDark(value, match.index)) continue;
66
+
67
+ if (insideColorMix(value, match.index)) {
68
+ return {
69
+ rawValue: raw,
70
+ reason: "color-mix",
71
+ message: `color-mix() argument ${raw} is theme-coupled. Use a semantic token or wrap the declaration in light-dark(...).`,
72
+ suggestion: `Replace ${raw} with a semantic token or light-dark(...)`,
73
+ };
74
+ }
75
+
76
+ if (BOX_SHADOW_PROP.test(property) && !NEUTRAL_SHADOW.test(value.trim())) {
77
+ return {
78
+ rawValue: raw,
79
+ reason: "box-shadow",
80
+ message: `Custom box-shadow with literal color ${raw} is theme-coupled. Use a shadow/elevation token or light-dark(...).`,
81
+ suggestion: "Use a shadow/elevation token or light-dark(...)",
82
+ };
83
+ }
84
+
85
+ if (WHITE_RGBA.test(raw) || BLACK_RGBA.test(raw)) {
86
+ return {
87
+ rawValue: raw,
88
+ reason: "white-black-rgba",
89
+ message: `Raw ${raw} assumes one theme. Wrap it in light-dark(...) or use a semantic token.`,
90
+ suggestion: WHITE_RGBA.test(raw)
91
+ ? `light-dark(/* TODO light-theme equivalent */, ${raw})`
92
+ : `light-dark(${raw}, /* TODO dark-theme equivalent */)`,
93
+ };
94
+ }
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ function insideLightDark(value: string, index: number): boolean {
101
+ return enclosingFunctionName(value, index) === "light-dark";
102
+ }
103
+
104
+ function insideColorMix(value: string, index: number): boolean {
105
+ return enclosingFunctionName(value, index) === "color-mix";
106
+ }
107
+
108
+ function enclosingFunctionName(value: string, index: number): string | null {
109
+ let depth = 0;
110
+ for (let i = index - 1; i >= 0; i--) {
111
+ const ch = value[i];
112
+ if (ch === ")") {
113
+ depth++;
114
+ continue;
115
+ }
116
+ if (ch !== "(") continue;
117
+ if (depth > 0) {
118
+ depth--;
119
+ continue;
120
+ }
121
+ const before = value.slice(0, i).match(/([a-z-]+)\s*$/i);
122
+ return before?.[1]?.toLowerCase() ?? null;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function isTokenFile(filePath: string): boolean {
128
+ const normalized = filePath.replace(/\\/g, "/");
129
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
130
+ if (base === "_tokens.scss" || base === "tokens.scss") return true;
131
+ return base.startsWith("_") && /(^|\/)tokens\//.test(normalized);
132
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Rule taxonomy — the single source of truth for tier classification.
3
+ *
4
+ * Every governance rule is either:
5
+ * - **Tier A (contract vocabulary)** — measures drift from a vocabulary the
6
+ * team *declared* (the two-choice contract: canonical components + token
7
+ * source files). Headline-eligible; on by default but *inert until a
8
+ * contract is authored* (each Tier-A rule self-gates on its contract facts).
9
+ * - **Tier B (generic hygiene)** — generic best-practice opinions (raw values,
10
+ * a11y, theme literals, Tailwind). Never the headline; off by default for
11
+ * customers, opt-in per rule.
12
+ *
13
+ * This map is consumed by the core customer-default preset (`./presets.ts`), the
14
+ * cloud policy composer, and the UI rules editor. There is exactly one copy —
15
+ * importers derive their defaults from it rather than re-listing rules (DRY).
16
+ *
17
+ * Identifiers are stable: this introduces no new rule ids, it only attaches a
18
+ * tier label to existing ones.
19
+ */
20
+
21
+ export type RuleTier = "contract" | "hygiene";
22
+
23
+ /**
24
+ * Tier of every rule in `RULES`. Tier A is exactly the two-choice-contract
25
+ * rules; everything else is hygiene. A coverage test asserts this map and
26
+ * `RULES` stay in lock-step (every rule id classified, no strays).
27
+ */
28
+ export const RULE_TIER: Readonly<Record<string, RuleTier>> = {
29
+ // ---- Tier A — contract vocabulary -------------------------------------
30
+ "components/prefer-library": "contract", // canonical-component bypass (FUI1001/1004)
31
+ "tokens/css-vars-must-be-defined": "contract", // token drift (FUI2015)
32
+
33
+ // ---- Tier B — generic hygiene -----------------------------------------
34
+ "components/unknown-prop": "hygiene",
35
+ "components/forbidden-prop-value": "hygiene",
36
+ "props/invalid-value": "hygiene",
37
+ "imports/preferred-path": "hygiene",
38
+ "components/preferred-component": "hygiene",
39
+ "styles/no-raw-color": "hygiene",
40
+ "styles/no-raw-dimensions": "hygiene",
41
+ "styles/no-raw-spacing": "hygiene",
42
+ "styles/no-raw-typography": "hygiene",
43
+ "tailwind/arbitrary-color": "hygiene",
44
+ "tailwind/arbitrary-spacing": "hygiene",
45
+ "tailwind/forbidden-palette": "hygiene",
46
+ "tailwind/off-scale-spacing-token": "hygiene",
47
+ "tailwind/raw-color-via-token": "hygiene",
48
+ "tailwind/unknown-class": "hygiene",
49
+ "tokens/require-dual-fallback": "hygiene",
50
+ "theme/no-theme-coupled-literal": "hygiene",
51
+ "a11y/required-accessible-name": "hygiene",
52
+ // Composition constraints are authored contracts, but not part of the
53
+ // two-choice headline contract; they self-activate when patterns are declared
54
+ // (the compiler injects their configs), so off-by-default here is harmless.
55
+ "composition/cardinality": "hygiene",
56
+ "composition/co-occurrence": "hygiene",
57
+ };
58
+
59
+ /**
60
+ * Rules removed from the customer default preset *entirely* — Fragments-internal
61
+ * conventions that are meaningless on a customer repo. They remain available
62
+ * under the full `fragments` preset for Fragments' own dogfooding; they are
63
+ * never even an opt-in toggle in the customer default.
64
+ *
65
+ * `tokens/require-dual-fallback` — the `var(--x, $x)` SCSS micro-convention —
66
+ * was ~51% of the dogfood flood and is the canonical example.
67
+ */
68
+ export const FRAGMENTS_INTERNAL_RULE_IDS: ReadonlySet<string> = new Set([
69
+ "tokens/require-dual-fallback",
70
+ ]);
71
+
72
+ /** Tier of a rule id. Unknown ids fail safe to `hygiene` (off by default). */
73
+ export function tierFor(ruleId: string): RuleTier {
74
+ return RULE_TIER[ruleId] ?? "hygiene";
75
+ }
76
+
77
+ /** True when the rule measures declared-contract drift (headline-eligible). */
78
+ export function isContractTierRule(ruleId: string): boolean {
79
+ return tierFor(ruleId) === "contract";
80
+ }
81
+
82
+ /** All Tier-A (contract vocabulary) rule ids, in declaration order. */
83
+ export function contractTierRuleIds(): string[] {
84
+ return Object.keys(RULE_TIER).filter(isContractTierRule);
85
+ }
86
+
87
+ /** All Tier-B (generic hygiene) rule ids, in declaration order. */
88
+ export function hygieneTierRuleIds(): string[] {
89
+ return Object.keys(RULE_TIER).filter((id) => !isContractTierRule(id));
90
+ }
91
+
92
+ /**
93
+ * Vocabulary-first headline order: canonical-component bypass ranks above token
94
+ * drift, both above any hygiene. This is the SSOT the dashboard headline (brief
95
+ * 05) consumes so "vocabulary-first" is defined once, not re-encoded per surface.
96
+ */
97
+ const VOCABULARY_ORDER: readonly string[] = [
98
+ "components/prefer-library",
99
+ "tokens/css-vars-must-be-defined",
100
+ ];
101
+
102
+ /** Headline rank of a rule id (lower = higher in the vocabulary-first list). */
103
+ export function vocabularyRank(ruleId: string): number {
104
+ const index = VOCABULARY_ORDER.indexOf(ruleId);
105
+ return index >= 0 ? index : VOCABULARY_ORDER.length;
106
+ }
107
+
108
+ /** Comparator ordering rule ids vocabulary-first (Tier A before hygiene). */
109
+ export function compareByVocabularyRank(a: string, b: string): number {
110
+ return vocabularyRank(a) - vocabularyRank(b);
111
+ }
@@ -0,0 +1,244 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ FactIndex,
5
+ makeContractTokenFact,
6
+ makeStyleCssVarsMustBeDefinedFact,
7
+ makeStyleDeclarationFact,
8
+ } from "../index.js";
9
+ import type { Fact } from "../index.js";
10
+ import { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
11
+
12
+ /**
13
+ * ACCEPTANCE §4 (rule level) + §11 (regression lock).
14
+ *
15
+ * The off-contract-token rule (FUI2015) is INERT unless the contract declares
16
+ * the `style.cssVars.mustBeDefined` policy fact — the cloud appends it only when
17
+ * `tokenSourceFiles` is non-empty, so the `fragments` preset alone never
18
+ * activates it. When active, a `var(--x)` drifts iff `--x` shares the authored
19
+ * vocabulary's prefix family but is not in the vocabulary; genuinely-foreign
20
+ * custom properties are ignored (no false-positive flood — the original revert).
21
+ */
22
+
23
+ const POLICY = makeStyleCssVarsMustBeDefinedFact({ severity: "warn" });
24
+
25
+ function vocabulary(): Fact[] {
26
+ return [
27
+ makeContractTokenFact({ name: "--fui-color-accent" }),
28
+ makeContractTokenFact({ name: "--fui-space-2" }),
29
+ ];
30
+ }
31
+
32
+ function decl(selector: string, value: string): Fact {
33
+ const file = "src/Card.module.scss";
34
+ return makeStyleDeclarationFact({
35
+ file,
36
+ selector,
37
+ declarationPath: selector,
38
+ property: "color",
39
+ value,
40
+ location: { file, line: 1, column: 0 },
41
+ });
42
+ }
43
+
44
+ function index(facts: Fact[]): FactIndex {
45
+ const ix = new FactIndex();
46
+ ix.addMany(facts);
47
+ return ix;
48
+ }
49
+
50
+ describe("ruleTokensCssVarsMustBeDefined", () => {
51
+ it("flags an off-contract token that shares the contract prefix family (§4)", () => {
52
+ const ix = index([POLICY, ...vocabulary(), decl(".b", "var(--fui-color-brand)")]);
53
+
54
+ const findings = ruleTokensCssVarsMustBeDefined(ix);
55
+ expect(findings).toHaveLength(1);
56
+ // `warn` (governance severity) normalizes to `moderate` at the finding level.
57
+ expect(findings[0].severity).toBe("moderate");
58
+ expect(findings[0].attributes).toMatchObject({ tokenName: "--fui-color-brand" });
59
+ });
60
+
61
+ it("does NOT flag an in-contract token reference (§4)", () => {
62
+ const ix = index([
63
+ POLICY,
64
+ ...vocabulary(),
65
+ decl(".c", "var(--fui-color-accent, $fui-color-accent)"),
66
+ ]);
67
+
68
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(0);
69
+ });
70
+
71
+ it("does NOT flag a genuinely-foreign var that shares no contract prefix (§4)", () => {
72
+ // `--brand-blue` / `--swiper-*` are outside the `--fui-` family — ignored,
73
+ // exactly the false positives that got the original brief reverted.
74
+ const ix = index([
75
+ POLICY,
76
+ ...vocabulary(),
77
+ decl(".x", "var(--brand-blue)"),
78
+ decl(".y", "var(--swiper-theme-color)"),
79
+ ]);
80
+
81
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(0);
82
+ });
83
+
84
+ it("flags a locally-defined parallel custom property even when it has no shared prefix", () => {
85
+ const file = "src/landing.scss";
86
+ const ix = index([
87
+ POLICY,
88
+ ...vocabulary(),
89
+ makeStyleDeclarationFact({
90
+ file,
91
+ selector: ":root",
92
+ declarationPath: "0",
93
+ property: "--lp-accent",
94
+ value: "#39594d",
95
+ location: { file, line: 1, column: 0 },
96
+ }),
97
+ makeStyleDeclarationFact({
98
+ file,
99
+ selector: ".hero",
100
+ declarationPath: "1",
101
+ property: "color",
102
+ value: "var(--lp-accent)",
103
+ location: { file, line: 4, column: 2 },
104
+ }),
105
+ decl(".swiper", "var(--swiper-theme-color)"),
106
+ ]);
107
+
108
+ const findings = ruleTokensCssVarsMustBeDefined(ix);
109
+ expect(findings).toHaveLength(1);
110
+ expect(findings[0].attributes).toMatchObject({ tokenName: "--lp-accent" });
111
+ });
112
+
113
+ it("is INERT without the activation policy fact, even with off-contract vars (§11)", () => {
114
+ // No `style.cssVars.mustBeDefined` fact → the `fragments` preset alone never
115
+ // fires the rule. This is the regression lock at the rule boundary.
116
+ const ix = index([...vocabulary(), decl(".b", "var(--fui-color-brand)")]);
117
+
118
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(0);
119
+ });
120
+
121
+ it("is INERT when the contract vocabulary is empty (no token definitions)", () => {
122
+ const ix = index([POLICY, decl(".b", "var(--fui-color-brand)")]);
123
+
124
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(0);
125
+ });
126
+
127
+ // #33 — CSS function names are case-insensitive; VAR()/Var() must be detected.
128
+ it("detects an off-contract token through case-insensitive VAR()/Var() (#33)", () => {
129
+ const ix = index([
130
+ POLICY,
131
+ ...vocabulary(),
132
+ decl(".b", "VAR(--fui-color-brand)"),
133
+ decl(".c", "Var(--fui-color-brand)"),
134
+ ]);
135
+
136
+ const findings = ruleTokensCssVarsMustBeDefined(ix);
137
+ expect(findings).toHaveLength(2);
138
+ expect(findings[0].attributes).toMatchObject({ tokenName: "--fui-color-brand" });
139
+ });
140
+
141
+ // #17 — only the PRIMARY var() position is judged; a nested fallback var() that
142
+ // is off-contract must NOT be flagged.
143
+ it("ignores an off-contract var in nested FALLBACK position (#17)", () => {
144
+ const ix = index([
145
+ POLICY,
146
+ ...vocabulary(),
147
+ decl(".c", "var(--fui-color-accent, var(--fui-color-brand))"),
148
+ ]);
149
+
150
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(0);
151
+ });
152
+
153
+ it("flags an off-contract PRIMARY even when its fallback is in-contract (#17)", () => {
154
+ const ix = index([
155
+ POLICY,
156
+ ...vocabulary(),
157
+ decl(".d", "var(--fui-color-brand, var(--fui-color-accent))"),
158
+ ]);
159
+
160
+ const findings = ruleTokensCssVarsMustBeDefined(ix);
161
+ expect(findings).toHaveLength(1);
162
+ expect(findings[0].attributes).toMatchObject({ tokenName: "--fui-color-brand" });
163
+ });
164
+
165
+ // #11 — one off-contract var referenced twice in ONE declaration is one fact.
166
+ it("dedupes the same off-contract var WITHIN one declaration (#11)", () => {
167
+ const ix = index([
168
+ POLICY,
169
+ ...vocabulary(),
170
+ decl(".b", "0 0 0 1px var(--fui-color-brand), inset 0 0 0 1px var(--fui-color-brand)"),
171
+ ]);
172
+
173
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(1);
174
+ });
175
+
176
+ it("does NOT dedupe the same off-contract var across SEPARATE declarations (#11)", () => {
177
+ const ix = index([
178
+ POLICY,
179
+ ...vocabulary(),
180
+ decl(".b", "var(--fui-color-brand)"),
181
+ decl(".c", "var(--fui-color-brand)"),
182
+ ]);
183
+
184
+ expect(ruleTokensCssVarsMustBeDefined(ix)).toHaveLength(2);
185
+ });
186
+
187
+ // #22/#25/#31/#36 — a FLAT vocabulary (single-segment names, no prefix family)
188
+ // must still enforce drift; multi-segment foreign vars stay ignored.
189
+ it("enforces drift for a FLAT (single-segment) vocabulary (#22/#25/#31/#36)", () => {
190
+ const flat = (): Fact[] => [
191
+ makeContractTokenFact({ name: "--accent" }),
192
+ makeContractTokenFact({ name: "--bg" }),
193
+ ];
194
+
195
+ expect(
196
+ ruleTokensCssVarsMustBeDefined(index([POLICY, ...flat(), decl(".b", "var(--brand)")]))
197
+ ).toHaveLength(1);
198
+
199
+ // Multi-segment foreign var stays ignored even in flat mode.
200
+ expect(
201
+ ruleTokensCssVarsMustBeDefined(
202
+ index([POLICY, ...flat(), decl(".x", "var(--swiper-theme-color)")])
203
+ )
204
+ ).toHaveLength(0);
205
+
206
+ // In-contract flat ref is fine.
207
+ expect(
208
+ ruleTokensCssVarsMustBeDefined(index([POLICY, ...flat(), decl(".y", "var(--accent)")]))
209
+ ).toHaveLength(0);
210
+ });
211
+
212
+ // #18 — a MIXED vocabulary (namespaced + flat) enforces BOTH namespaces
213
+ // symmetrically, order-independently; multi-segment foreign stays ignored.
214
+ it("enforces BOTH halves of a MIXED namespaced+flat vocabulary (#18)", () => {
215
+ const mixed = (): Fact[] => [
216
+ makeContractTokenFact({ name: "--fui-color-accent" }),
217
+ makeContractTokenFact({ name: "--accent" }),
218
+ ];
219
+
220
+ const findings = ruleTokensCssVarsMustBeDefined(
221
+ index([
222
+ POLICY,
223
+ ...mixed(),
224
+ decl(".a", "var(--fui-color-brand)"), // namespaced drift
225
+ decl(".b", "var(--bg)"), // flat drift
226
+ decl(".c", "var(--swiper-theme-color)"), // foreign multi-segment, ignored
227
+ ])
228
+ );
229
+ expect(findings).toHaveLength(2);
230
+
231
+ // Determinism: reversed token add-order yields identical findings count.
232
+ const reversed = ruleTokensCssVarsMustBeDefined(
233
+ index([
234
+ POLICY,
235
+ makeContractTokenFact({ name: "--accent" }),
236
+ makeContractTokenFact({ name: "--fui-color-accent" }),
237
+ decl(".a", "var(--fui-color-brand)"),
238
+ decl(".b", "var(--bg)"),
239
+ decl(".c", "var(--swiper-theme-color)"),
240
+ ])
241
+ );
242
+ expect(reversed).toHaveLength(2);
243
+ });
244
+ });
@@ -0,0 +1,156 @@
1
+ import type { FactIndex } from "../facts/index.js";
2
+
3
+ import { makeFinding } from "./finding.js";
4
+ import type { Finding } from "./types.js";
5
+ import { contractPrefixFamilies } from "./utils.js";
6
+
7
+ export const RULE_ID = "tokens/css-vars-must-be-defined";
8
+ export const RULE_VERSION = "1";
9
+
10
+ // The leading `var(--name` of a reference. The `var(` keyword is matched
11
+ // case-insensitively (CSS function names are case-insensitive per spec, so
12
+ // `VAR(`/`Var(` must be caught — #33) but the captured custom-property name stays
13
+ // case-preserving (custom-property names ARE case-sensitive; vocabulary.has must
14
+ // stay exact). We judge ONLY the PRIMARY var() position — everything in fallback
15
+ // position (a nested `var()`, a `$`/literal fallback) is ignored uniformly (#17):
16
+ // after each match we skip past this var()'s matching close-paren so an inner
17
+ // fallback var() is never re-scanned as a fresh primary.
18
+ const CSS_VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/gi;
19
+
20
+ /**
21
+ * Off-contract token drift (FUI2015).
22
+ *
23
+ * Activation is gated entirely on the `style.cssVars.mustBeDefined` policy fact,
24
+ * which is declared ONLY when a project has authored token source files (the
25
+ * cloud appends it when `tokenSourceFiles` is non-empty). No fact → no-op, so
26
+ * the `fragments` preset alone never activates this rule.
27
+ *
28
+ * When active, a `var(--x)` is drift iff `--x` is shaped like a contract token
29
+ * but is NOT in the vocabulary. "Shaped like a contract token" means one of:
30
+ * 1. it shares a prefix family of the authored vocabulary (e.g. `--fui-`); OR
31
+ * 2. it is single-segment (no hyphen after `--`, e.g. `--accent`) AND the
32
+ * vocabulary itself contains at least one single-segment token.
33
+ * Clause 2 keeps the rule live for FLAT token systems (`--accent`, `--bg`, which
34
+ * yield no prefix family — #22/#25/#31/#36) and enforces the flat half of a MIXED
35
+ * `--fui-*` + `--accent` vocabulary symmetrically with the namespaced half (#18),
36
+ * order-independently. Genuinely foreign custom properties (`--swiper-*`,
37
+ * `--radix-*`, layout/animation vars) are multi-segment with no shared family, so
38
+ * they remain ignored — the rule neither floods on unrelated `var()`s nor fires
39
+ * for projects without a contract. Hardcoded values are handled by the unchanged
40
+ * `styles/no-raw-color` rule; this rule only judges custom-property references.
41
+ */
42
+ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
43
+ const policy = ix.policy.cssVarsMustBeDefined();
44
+ if (!policy) return [];
45
+
46
+ // The contract vocabulary travels on its OWN fact channel (contract_token),
47
+ // injected by the CLI from the user's token source files — kept separate from
48
+ // the local `token_definition` facts the raw-* rules + scale detection read.
49
+ const vocabulary = new Set(ix.byKind("contract_token").map((token) => token.name));
50
+ // The truly-unauthored case (no contract token facts) is the only hard return —
51
+ // it preserves the §11 inert-without-contract regression lock.
52
+ if (vocabulary.size === 0) return [];
53
+
54
+ // A flat/single-segment vocabulary (`--accent`, `--bg`) yields no prefix family,
55
+ // but the rule must STILL enforce it (#22/#25/#31/#36) — we no longer hard-return
56
+ // on an empty family set. `hasFlatTokens` lets single-segment refs through the
57
+ // shaped gate whenever the vocabulary itself declares single-segment tokens,
58
+ // which also enforces the flat half of a mixed vocabulary symmetrically (#18).
59
+ const prefixes = contractPrefixFamilies(vocabulary);
60
+ const hasFlatTokens = [...vocabulary].some(isSingleSegmentVarName);
61
+ const locallyDefinedCustomProperties = new Set(
62
+ ix
63
+ .byKind("style_declaration")
64
+ .filter((decl) => decl.property.startsWith("--"))
65
+ .map((decl) => decl.property)
66
+ );
67
+
68
+ const findings: Finding[] = [];
69
+
70
+ for (const decl of ix.byKind("style_declaration")) {
71
+ // Dedupe token names WITHIN one declaration so a value referencing the SAME
72
+ // off-contract var twice yields ONE finding (#11) — otherwise the two share an
73
+ // identical fingerprintIdentity and silently collapse to one at ingest. Scoped
74
+ // per-declaration: the same var in two DIFFERENT declarations still yields two.
75
+ const seen = new Set<string>();
76
+ CSS_VAR_REFERENCE.lastIndex = 0;
77
+ let match: RegExpExecArray | null;
78
+ while ((match = CSS_VAR_REFERENCE.exec(decl.value)) !== null) {
79
+ const tokenName = match[1];
80
+ // Advance past this var()'s matching close-paren so an inner fallback var()
81
+ // is never re-scanned as a fresh primary — only the PRIMARY position is
82
+ // judged (#17). `var(` starts at match.index, so the `(` is at +3.
83
+ CSS_VAR_REFERENCE.lastIndex = skipToCloseParen(decl.value, match.index + 3);
84
+
85
+ if (vocabulary.has(tokenName)) continue;
86
+ const shaped =
87
+ sharesContractPrefix(tokenName, prefixes) ||
88
+ (hasFlatTokens && isSingleSegmentVarName(tokenName)) ||
89
+ locallyDefinedCustomProperties.has(tokenName);
90
+ if (!shaped) continue;
91
+ if (seen.has(tokenName)) continue;
92
+ seen.add(tokenName);
93
+
94
+ findings.push(
95
+ makeFinding({
96
+ ruleId: RULE_ID,
97
+ ruleVersion: RULE_VERSION,
98
+ severity: policy.severity,
99
+ message: `var(${tokenName}) is not in the contract token vocabulary. Use a token defined in your contract's token source files.`,
100
+ location: decl.location,
101
+ evidence: ix.evidence([decl.id, policy.id]),
102
+ fingerprintIdentity: {
103
+ file: decl.file,
104
+ selector: decl.selector,
105
+ declarationPath: decl.declarationPath,
106
+ property: decl.property,
107
+ tokenName,
108
+ },
109
+ attributes: {
110
+ property: decl.property,
111
+ rawValue: decl.value,
112
+ tokenName,
113
+ source: "css",
114
+ },
115
+ })
116
+ );
117
+ }
118
+ }
119
+
120
+ return findings;
121
+ }
122
+
123
+ function sharesContractPrefix(name: string, prefixes: ReadonlySet<string>): boolean {
124
+ for (const prefix of prefixes) {
125
+ if (name.startsWith(prefix)) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Whether a `--name` is single-segment (no hyphen after the leading `--`), e.g.
132
+ * `--accent` (true) vs `--fui-color-accent` / `--swiper-theme-color` (false).
133
+ * Mirrors `contractPrefixFamilies`' index-of(`-`, 2) so the two stay consistent.
134
+ */
135
+ function isSingleSegmentVarName(name: string): boolean {
136
+ return name.indexOf("-", 2) === -1;
137
+ }
138
+
139
+ /**
140
+ * Index just past the `)` that closes the open paren AT `openParenIndex`
141
+ * (a balanced-paren scan, so a nested fallback `var(--a, var(--b))` is skipped as
142
+ * one unit). Returns the string length if the value is unbalanced (no close),
143
+ * which safely ends the scan.
144
+ */
145
+ function skipToCloseParen(value: string, openParenIndex: number): number {
146
+ let depth = 0;
147
+ for (let i = openParenIndex; i < value.length; i++) {
148
+ const ch = value[i];
149
+ if (ch === "(") depth++;
150
+ else if (ch === ")") {
151
+ depth--;
152
+ if (depth === 0) return i + 1;
153
+ }
154
+ }
155
+ return value.length;
156
+ }