@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,82 @@
1
+ /**
2
+ * `tailwind/raw-color-via-token` — flags Tailwind color tokens that resolve
3
+ * to literal colors when policy requires named palette families.
4
+ */
5
+
6
+ import type { FactIndex } from "../facts/index.js";
7
+
8
+ import { makeFinding } from "./finding.js";
9
+ import type { Finding } from "./types.js";
10
+ import {
11
+ indexResolvedTailwindTokens,
12
+ matchPattern,
13
+ resolvedForClass,
14
+ TAILWIND_COLOR_UTILITIES,
15
+ UNIVERSAL_TAILWIND_COLOR_TOKENS,
16
+ } from "./tailwind-rule-utils.js";
17
+ import { detectRawColor, readUsageNode } from "./utils.js";
18
+
19
+ export const RULE_ID = "tailwind/raw-color-via-token";
20
+ export const RULE_VERSION = "1";
21
+
22
+ export function ruleTailwindRawColorViaToken(ix: FactIndex): Finding[] {
23
+ const allow = ix.policy.tailwindPaletteAllow();
24
+ if (!allow || allow.patterns.length === 0) return [];
25
+
26
+ const resolvedByKey = indexResolvedTailwindTokens(ix);
27
+ const findings: Finding[] = [];
28
+
29
+ for (const klass of ix.byKind("tailwind_class")) {
30
+ if (klass.value.kind !== "token") continue;
31
+ if (!TAILWIND_COLOR_UTILITIES.has(klass.utility)) continue;
32
+ if (UNIVERSAL_TAILWIND_COLOR_TOKENS.has(klass.value.token)) continue;
33
+ if (matchPattern(klass.value.token, allow.patterns)) continue;
34
+
35
+ const resolved = resolvedForClass(resolvedByKey, klass);
36
+ if (!resolved || resolved.resolved.kind !== "color") continue;
37
+ if (resolved.resolved.source === "theme-css") continue;
38
+
39
+ const color = detectRawColor(resolved.resolved.value);
40
+ if (!color) continue;
41
+
42
+ const node = readUsageNode(ix, klass.nodeId);
43
+ const evidenceIds = node
44
+ ? [node.id, klass.id, resolved.id, allow.id]
45
+ : [klass.id, resolved.id, allow.id];
46
+
47
+ findings.push(
48
+ makeFinding({
49
+ ruleId: RULE_ID,
50
+ ruleVersion: RULE_VERSION,
51
+ severity: allow.severity,
52
+ message: `\`${klass.raw}\` resolves to raw color \`${color}\` (${sourceLabel(resolved.resolved.source)}). Project policy requires \`${allow.patterns.join("`, `")}\` palette tokens.`,
53
+ location: klass.location,
54
+ evidence: ix.evidence(evidenceIds),
55
+ fingerprintIdentity: {
56
+ source: "tailwind_class",
57
+ file: klass.file,
58
+ nodeId: klass.nodeId,
59
+ originPath: klass.originPath,
60
+ raw: klass.raw,
61
+ utility: klass.utility,
62
+ token: klass.value.token,
63
+ resolvedValue: color,
64
+ },
65
+ attributes: {
66
+ rawClass: klass.raw,
67
+ utility: klass.utility,
68
+ token: klass.value.token,
69
+ resolvedValue: color,
70
+ source: resolved.resolved.source,
71
+ allowedPatterns: allow.patterns,
72
+ },
73
+ })
74
+ );
75
+ }
76
+
77
+ return findings;
78
+ }
79
+
80
+ function sourceLabel(source: "theme-css" | "default"): string {
81
+ return source === "default" ? "Tailwind default" : source;
82
+ }
@@ -0,0 +1,106 @@
1
+ import type { FactIndex, TailwindClassFact, TailwindTokenResolvedFact } from "../facts/index.js";
2
+
3
+ export const TAILWIND_COLOR_UTILITIES = new Set([
4
+ "accent",
5
+ "bg",
6
+ "border",
7
+ "border-b",
8
+ "border-e",
9
+ "border-l",
10
+ "border-r",
11
+ "border-s",
12
+ "border-t",
13
+ "border-x",
14
+ "border-y",
15
+ "caret",
16
+ "decoration",
17
+ "divide",
18
+ "fill",
19
+ "from",
20
+ "outline",
21
+ "placeholder",
22
+ "ring",
23
+ "ring-offset",
24
+ "shadow",
25
+ "stroke",
26
+ "text",
27
+ "to",
28
+ "via",
29
+ ]);
30
+
31
+ /**
32
+ * Tailwind color *keywords* that are not palette families and carry no
33
+ * satisfiable "use a named family" remedy. `bg-white`, `text-black`,
34
+ * `border-transparent`, `ring-current`, `divide-inherit` are stock utilities a
35
+ * project ships with regardless of its configured palette, so the palette
36
+ * allow/deny policy must never flag them (doing so produces an unfixable
37
+ * warning and double-reports against `raw-color-via-token`).
38
+ */
39
+ export const UNIVERSAL_TAILWIND_COLOR_TOKENS = new Set([
40
+ "white",
41
+ "black",
42
+ "transparent",
43
+ "current",
44
+ "inherit",
45
+ "none",
46
+ ]);
47
+
48
+ export const TAILWIND_SPACING_UTILITY_TO_PROPERTY: ReadonlyMap<string, string> = new Map([
49
+ ["m", "margin"],
50
+ ["mt", "margin"],
51
+ ["mr", "margin"],
52
+ ["mb", "margin"],
53
+ ["ml", "margin"],
54
+ ["ms", "margin"],
55
+ ["me", "margin"],
56
+ ["mx", "margin"],
57
+ ["my", "margin"],
58
+ ["p", "padding"],
59
+ ["pt", "padding"],
60
+ ["pr", "padding"],
61
+ ["pb", "padding"],
62
+ ["pl", "padding"],
63
+ ["ps", "padding"],
64
+ ["pe", "padding"],
65
+ ["px", "padding"],
66
+ ["py", "padding"],
67
+ ["gap", "gap"],
68
+ ["gap-x", "gap"],
69
+ ["gap-y", "gap"],
70
+ ["space-x", "gap"],
71
+ ["space-y", "gap"],
72
+ ] as const);
73
+
74
+ export function indexResolvedTailwindTokens(
75
+ ix: FactIndex
76
+ ): Map<string, TailwindTokenResolvedFact> {
77
+ const out = new Map<string, TailwindTokenResolvedFact>();
78
+ for (const fact of ix.byKind("tailwind_token_resolved")) {
79
+ out.set(resolvedKey(fact.utility, fact.token), fact);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ export function resolvedForClass(
85
+ resolvedByKey: ReadonlyMap<string, TailwindTokenResolvedFact>,
86
+ klass: TailwindClassFact
87
+ ): TailwindTokenResolvedFact | undefined {
88
+ if (klass.value.kind !== "token") return undefined;
89
+ return resolvedByKey.get(resolvedKey(klass.utility, klass.value.token));
90
+ }
91
+
92
+ export function resolvedKey(utility: string, token: string): string {
93
+ return `${utility}\0${token}`;
94
+ }
95
+
96
+ export function matchPattern(value: string, patterns: readonly string[]): string | null {
97
+ for (const pattern of patterns) {
98
+ if (matchesTokenGlob(value, pattern)) return pattern;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function matchesTokenGlob(value: string, pattern: string): boolean {
104
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
105
+ return new RegExp(`^${escaped}$`).test(value);
106
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ makeTailwindClassFact,
7
+ makeTailwindTokenResolvedFact,
8
+ makeUsageNodeFact,
9
+ ruleTailwindUnknownClass,
10
+ runRules,
11
+ } from "../index.js";
12
+
13
+ const FILE = "src/app.tsx";
14
+ const LOC = { file: FILE, line: 1, column: 7 };
15
+
16
+ function indexWithStrictMode(enabled = true): FactIndex {
17
+ const ix = new FactIndex();
18
+ ix.addMany(
19
+ compileGlobalGovernanceFacts({
20
+ rules: enabled ? { "tailwind/unknown-class": true } : {},
21
+ })
22
+ );
23
+ return ix;
24
+ }
25
+
26
+ function addUnknownResolvedClass(ix: FactIndex): void {
27
+ const node = makeUsageNodeFact({
28
+ file: FILE,
29
+ nodePath: "0",
30
+ element: "div",
31
+ location: LOC,
32
+ });
33
+ ix.add(node);
34
+ ix.add(
35
+ makeTailwindClassFact({
36
+ file: FILE,
37
+ nodeId: node.id,
38
+ raw: "bg-totally-made-up",
39
+ originPath: "attribute#0/class#0",
40
+ prefix: null,
41
+ modifiers: [],
42
+ important: false,
43
+ negative: false,
44
+ utility: "bg",
45
+ value: { kind: "token", token: "totally-made-up" },
46
+ location: LOC,
47
+ })
48
+ );
49
+ ix.add(
50
+ makeTailwindTokenResolvedFact({
51
+ utility: "bg",
52
+ token: "totally-made-up",
53
+ resolved: { kind: "unknown", source: "unknown" },
54
+ })
55
+ );
56
+ }
57
+
58
+ describe("tailwind/unknown-class", () => {
59
+ it("flags unresolved tokens when strict mode is enabled — ACCEPTANCE §5.31", () => {
60
+ const ix = indexWithStrictMode(true);
61
+ addUnknownResolvedClass(ix);
62
+
63
+ const findings = ruleTailwindUnknownClass(ix);
64
+ expect(findings).toHaveLength(1);
65
+ expect(findings[0]).toMatchObject({
66
+ ruleId: "tailwind/unknown-class",
67
+ severity: "minor",
68
+ message: "`bg-totally-made-up` did not resolve to any known palette token.",
69
+ attributes: {
70
+ utility: "bg",
71
+ token: "totally-made-up",
72
+ reason: "resolution-miss",
73
+ },
74
+ });
75
+ });
76
+
77
+ it("is silent by default — ACCEPTANCE §5.31", () => {
78
+ const ix = indexWithStrictMode(false);
79
+ addUnknownResolvedClass(ix);
80
+
81
+ expect(ruleTailwindUnknownClass(ix)).toHaveLength(0);
82
+ });
83
+
84
+ it("flags parser failures separately", () => {
85
+ const ix = indexWithStrictMode(true);
86
+ const node = makeUsageNodeFact({
87
+ file: FILE,
88
+ nodePath: "0",
89
+ element: "div",
90
+ location: LOC,
91
+ });
92
+ ix.add(node);
93
+ ix.add(
94
+ makeTailwindClassFact({
95
+ file: FILE,
96
+ nodeId: node.id,
97
+ raw: "totally-made-up",
98
+ originPath: "attribute#0/class#0",
99
+ prefix: null,
100
+ modifiers: [],
101
+ important: false,
102
+ negative: false,
103
+ utility: "unknown",
104
+ value: { kind: "none" },
105
+ location: LOC,
106
+ })
107
+ );
108
+
109
+ expect(ruleTailwindUnknownClass(ix)[0]?.attributes).toMatchObject({
110
+ reason: "parse-failure",
111
+ });
112
+ });
113
+
114
+ it("does not flag box-shadow size utilities as unresolved colors", () => {
115
+ const ix = indexWithStrictMode(true);
116
+ const node = makeUsageNodeFact({
117
+ file: FILE,
118
+ nodePath: "0",
119
+ element: "div",
120
+ location: LOC,
121
+ });
122
+ ix.add(node);
123
+ // `shadow` is in TAILWIND_COLOR_UTILITIES, but `shadow-lg` is a box-shadow
124
+ // size, not a color — it must not be reported as a resolution miss.
125
+ ix.add(
126
+ makeTailwindClassFact({
127
+ file: FILE,
128
+ nodeId: node.id,
129
+ raw: "shadow-lg",
130
+ originPath: "attribute#0/class#0",
131
+ prefix: null,
132
+ modifiers: [],
133
+ important: false,
134
+ negative: false,
135
+ utility: "shadow",
136
+ value: { kind: "token", token: "lg" },
137
+ location: LOC,
138
+ })
139
+ );
140
+ ix.add(
141
+ makeTailwindTokenResolvedFact({
142
+ utility: "shadow",
143
+ token: "lg",
144
+ resolved: { kind: "unknown", source: "unknown" },
145
+ })
146
+ );
147
+
148
+ expect(ruleTailwindUnknownClass(ix)).toHaveLength(0);
149
+ });
150
+
151
+ it("is registered in runRules but stays policy-gated", () => {
152
+ const ix = indexWithStrictMode(true);
153
+ addUnknownResolvedClass(ix);
154
+
155
+ expect(runRules(ix).map((f) => f.ruleId)).toContain("tailwind/unknown-class");
156
+ });
157
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `tailwind/unknown-class` — opt-in strict-mode diagnostics for classes that
3
+ * either fail parser utility recognition or fail token resolution.
4
+ */
5
+
6
+ import type { FactIndex } from "../facts/index.js";
7
+
8
+ import { makeFinding } from "./finding.js";
9
+ import type { Finding } from "./types.js";
10
+ import {
11
+ indexResolvedTailwindTokens,
12
+ resolvedForClass,
13
+ TAILWIND_COLOR_UTILITIES,
14
+ } from "./tailwind-rule-utils.js";
15
+ import { readUsageNode } from "./utils.js";
16
+
17
+ export const RULE_ID = "tailwind/unknown-class";
18
+ export const RULE_VERSION = "1";
19
+
20
+ /** Color keywords valid in any palette — never a "did not resolve" miss. */
21
+ const UNIVERSAL_COLOR_KEYWORDS = new Set([
22
+ "white",
23
+ "black",
24
+ "transparent",
25
+ "current",
26
+ "currentcolor",
27
+ "inherit",
28
+ "none",
29
+ ]);
30
+
31
+ export function ruleTailwindUnknownClass(ix: FactIndex): Finding[] {
32
+ const policy = ix.policy.tailwindUnknownClassEnabled();
33
+ if (!policy) return [];
34
+
35
+ const resolvedByKey = indexResolvedTailwindTokens(ix);
36
+ const findings: Finding[] = [];
37
+
38
+ for (const klass of ix.byKind("tailwind_class")) {
39
+ if (klass.utility === "unknown") {
40
+ const node = readUsageNode(ix, klass.nodeId);
41
+ const evidenceIds = node ? [node.id, klass.id, policy.id] : [klass.id, policy.id];
42
+ findings.push(
43
+ makeFinding({
44
+ ruleId: RULE_ID,
45
+ ruleVersion: RULE_VERSION,
46
+ severity: policy.severity,
47
+ message: `\`${klass.raw}\` could not be parsed as a known Tailwind utility.`,
48
+ location: klass.location,
49
+ evidence: ix.evidence(evidenceIds),
50
+ fingerprintIdentity: {
51
+ source: "tailwind_class",
52
+ file: klass.file,
53
+ nodeId: klass.nodeId,
54
+ originPath: klass.originPath,
55
+ raw: klass.raw,
56
+ reason: "parse-failure",
57
+ },
58
+ attributes: {
59
+ rawClass: klass.raw,
60
+ utility: klass.utility,
61
+ reason: "parse-failure",
62
+ },
63
+ })
64
+ );
65
+ continue;
66
+ }
67
+
68
+ if (klass.value.kind !== "token") continue;
69
+
70
+ // The resolution-miss diagnostic is about PALETTE tokens only. Don't flag
71
+ // non-color utilities (font-medium, items-center, border-2 width), bare
72
+ // numeric tokens (sizes/widths, not colors), or universal color keywords
73
+ // (white/black/transparent) as "did not resolve to a palette token".
74
+ if (!TAILWIND_COLOR_UTILITIES.has(klass.utility)) continue;
75
+ // `shadow` is dual-purpose: `shadow-{size}` (sm/lg/xl/...) is box-shadow,
76
+ // not a color. The resolution-miss check can't distinguish a size keyword
77
+ // from a misspelled color, so it would flag every `shadow-lg`. Skip it —
78
+ // shadow *color* misuse is rare and not worth the false positives.
79
+ if (klass.utility === "shadow") continue;
80
+ const token = klass.value.token;
81
+ if (/^\d/.test(token)) continue;
82
+ if (UNIVERSAL_COLOR_KEYWORDS.has(token.toLowerCase())) continue;
83
+
84
+ const resolved = resolvedForClass(resolvedByKey, klass);
85
+ if (!resolved || resolved.resolved.kind !== "unknown") continue;
86
+
87
+ const node = readUsageNode(ix, klass.nodeId);
88
+ const evidenceIds = node
89
+ ? [node.id, klass.id, resolved.id, policy.id]
90
+ : [klass.id, resolved.id, policy.id];
91
+
92
+ findings.push(
93
+ makeFinding({
94
+ ruleId: RULE_ID,
95
+ ruleVersion: RULE_VERSION,
96
+ severity: policy.severity,
97
+ message: `\`${klass.raw}\` did not resolve to any known palette token.`,
98
+ location: klass.location,
99
+ evidence: ix.evidence(evidenceIds),
100
+ fingerprintIdentity: {
101
+ source: "tailwind_class",
102
+ file: klass.file,
103
+ nodeId: klass.nodeId,
104
+ originPath: klass.originPath,
105
+ raw: klass.raw,
106
+ utility: klass.utility,
107
+ token: klass.value.token,
108
+ reason: "resolution-miss",
109
+ },
110
+ attributes: {
111
+ rawClass: klass.raw,
112
+ utility: klass.utility,
113
+ token: klass.value.token,
114
+ reason: "resolution-miss",
115
+ },
116
+ })
117
+ );
118
+ }
119
+
120
+ return findings;
121
+ }
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compareByVocabularyRank,
5
+ compileGlobalGovernanceFacts,
6
+ contractTierRuleIds,
7
+ customerDefaultRuleStates,
8
+ FactIndex,
9
+ FRAGMENTS_INTERNAL_RULE_IDS,
10
+ fragmentsPresetRuleStates,
11
+ isContractTierRule,
12
+ makeContractTokenFact,
13
+ makeStyleCssVarsMustBeDefinedFact,
14
+ makeStyleDeclarationFact,
15
+ makeUsageNodeFact,
16
+ makeUsageTextChildFact,
17
+ RULE_TIER,
18
+ RULES,
19
+ runRules,
20
+ tierFor,
21
+ } from "../index.js";
22
+ import type { Fact } from "../index.js";
23
+
24
+ /**
25
+ * Brief 02 — rule taxonomy. ACCEPTANCE §1–§3.
26
+ *
27
+ * The customer default preset enables only Tier A (contract vocabulary), which
28
+ * is inert until a contract is authored; Tier B (generic hygiene) is off and the
29
+ * Fragments-internal `tokens/require-dual-fallback` is excluded entirely. The
30
+ * full `fragments` preset keeps every rule for Fragments' own dogfooding.
31
+ */
32
+
33
+ // --- fixtures --------------------------------------------------------------
34
+
35
+ /** Raw-value declarations — the shape that floods the dashboard today. */
36
+ function rawValueFixture(): Fact[] {
37
+ const file = "src/Widget.module.scss";
38
+ return [
39
+ makeStyleDeclarationFact({
40
+ file,
41
+ selector: ".a",
42
+ declarationPath: "0",
43
+ property: "color",
44
+ value: "#3b82f6",
45
+ location: { file, line: 1, column: 2 },
46
+ }),
47
+ makeStyleDeclarationFact({
48
+ file,
49
+ selector: ".a",
50
+ declarationPath: "1",
51
+ property: "padding",
52
+ value: "13px",
53
+ location: { file, line: 2, column: 2 },
54
+ }),
55
+ makeStyleDeclarationFact({
56
+ file,
57
+ selector: ".a",
58
+ declarationPath: "2",
59
+ property: "background",
60
+ // a CSS token var with no SCSS fallback — the dual-fallback shape
61
+ value: "var(--fui-surface-secondary)",
62
+ location: { file, line: 3, column: 2 },
63
+ }),
64
+ ];
65
+ }
66
+
67
+ function index(facts: Fact[]): FactIndex {
68
+ const ix = new FactIndex();
69
+ ix.addMany(facts);
70
+ return ix;
71
+ }
72
+
73
+ // --- taxonomy SSOT coverage ------------------------------------------------
74
+
75
+ describe("rule taxonomy SSOT", () => {
76
+ it("classifies every rule in RULES, with no stray ids", () => {
77
+ const ruleIds = RULES.map((rule) => rule.id).sort();
78
+ const tieredIds = Object.keys(RULE_TIER).sort();
79
+ expect(tieredIds).toEqual(ruleIds);
80
+ });
81
+
82
+ it("treats exactly the two-choice-contract rules as Tier A", () => {
83
+ expect(contractTierRuleIds().sort()).toEqual([
84
+ "components/prefer-library",
85
+ "tokens/css-vars-must-be-defined",
86
+ ]);
87
+ });
88
+
89
+ it("fails safe to hygiene for unknown rule ids", () => {
90
+ expect(tierFor("some/unknown-rule")).toBe("hygiene");
91
+ expect(isContractTierRule("some/unknown-rule")).toBe(false);
92
+ });
93
+ });
94
+
95
+ // --- §1 — customer + no contract → zero findings ---------------------------
96
+
97
+ describe("§1 customer default, no contract", () => {
98
+ it("produces zero findings over a raw-value fixture (not the flood)", () => {
99
+ const ix = index([
100
+ ...compileGlobalGovernanceFacts({ rules: customerDefaultRuleStates() }),
101
+ ...rawValueFixture(),
102
+ ]);
103
+
104
+ const findings = runRules(ix);
105
+ expect(findings).toHaveLength(0);
106
+ });
107
+
108
+ it("contrast: the same fixture DOES flood under the full fragments preset", () => {
109
+ // The customer default's 0 is the taxonomy doing its job, not an empty
110
+ // fixture: the config-gated dual-fallback rule fires under `fragments` on the
111
+ // exact same facts, and is precisely what the customer default drops.
112
+ const fragments = index([
113
+ ...compileGlobalGovernanceFacts({ rules: fragmentsPresetRuleStates() }),
114
+ ...rawValueFixture(),
115
+ ]);
116
+ expect(runRules(fragments).some((f) => f.ruleId === "tokens/require-dual-fallback")).toBe(true);
117
+ });
118
+ });
119
+
120
+ // --- §2 — require-dual-fallback absent from the customer preset ------------
121
+
122
+ describe("§2 tokens/require-dual-fallback is Fragments-internal", () => {
123
+ const file = "src/Card.module.scss";
124
+ const decl = makeStyleDeclarationFact({
125
+ file,
126
+ selector: ".c",
127
+ declarationPath: "0",
128
+ property: "color",
129
+ value: "var(--fui-surface-secondary)",
130
+ location: { file, line: 1, column: 0 },
131
+ });
132
+
133
+ it("is excluded from the customer default entirely", () => {
134
+ expect(FRAGMENTS_INTERNAL_RULE_IDS.has("tokens/require-dual-fallback")).toBe(true);
135
+ expect(customerDefaultRuleStates()).not.toHaveProperty("tokens/require-dual-fallback");
136
+ });
137
+
138
+ it("customer preset = 0, fragments preset = 1", () => {
139
+ const customer = index([
140
+ ...compileGlobalGovernanceFacts({ rules: customerDefaultRuleStates() }),
141
+ decl,
142
+ ]);
143
+ expect(runRules(customer)).toHaveLength(0);
144
+
145
+ const fragments = index([
146
+ ...compileGlobalGovernanceFacts({ rules: fragmentsPresetRuleStates() }),
147
+ decl,
148
+ ]);
149
+ const fragmentsFindings = runRules(fragments);
150
+ expect(fragmentsFindings).toHaveLength(1);
151
+ expect(fragmentsFindings[0].ruleId).toBe("tokens/require-dual-fallback");
152
+ });
153
+ });
154
+
155
+ // --- §3 — contract authored → exactly the Tier-A drift, vocabulary-first ---
156
+
157
+ describe("§3 contract authored", () => {
158
+ // A contract: canonical Button declared (npm source) + a token source file
159
+ // declaring --color-accent. An agent writes a raw <button> and references an
160
+ // off-contract var(--color-brand).
161
+ function contractIndex(): FactIndex {
162
+ const file = "src/app.tsx";
163
+ const scss = "src/app.module.scss";
164
+ return index([
165
+ ...compileGlobalGovernanceFacts({
166
+ rules: customerDefaultRuleStates(),
167
+ canonicalSources: [{ kind: "npm", specifier: "@acme/ui", include: ["Button"] }],
168
+ }),
169
+ // token half of the contract (activation fact + vocabulary)
170
+ makeStyleCssVarsMustBeDefinedFact({ severity: "warn" }),
171
+ makeContractTokenFact({ name: "--color-accent" }),
172
+ // the agent's drift: a raw <button>
173
+ makeUsageNodeFact({
174
+ file,
175
+ nodePath: "0:0",
176
+ element: "button",
177
+ interactive: true,
178
+ location: { file, line: 10, column: 4 },
179
+ }),
180
+ // ...and an off-contract token reference (shares the --color- family)
181
+ makeStyleDeclarationFact({
182
+ file: scss,
183
+ selector: ".cta",
184
+ declarationPath: "0",
185
+ property: "background",
186
+ value: "var(--color-brand)",
187
+ location: { file: scss, line: 3, column: 2 },
188
+ }),
189
+ // ...plus a raw hex that Tier B would flag — proves hygiene stays silent
190
+ makeStyleDeclarationFact({
191
+ file: scss,
192
+ selector: ".cta",
193
+ declarationPath: "1",
194
+ property: "color",
195
+ value: "#3b82f6",
196
+ location: { file: scss, line: 4, column: 2 },
197
+ }),
198
+ ]);
199
+ }
200
+
201
+ // the raw <button> needs a child so it is not treated as icon-only
202
+ function withButtonText(ix: FactIndex): FactIndex {
203
+ const node = ix.byKind("usage_node").find((n) => n.element === "button");
204
+ if (node) ix.add(makeUsageTextChildFact({ nodeId: node.id, text: "Save", index: 0 }));
205
+ return ix;
206
+ }
207
+
208
+ it("yields exactly the two Tier-A findings, no hygiene", () => {
209
+ const findings = runRules(withButtonText(contractIndex()));
210
+ const ruleIds = findings.map((f) => f.ruleId);
211
+
212
+ expect(new Set(ruleIds)).toEqual(
213
+ new Set(["components/prefer-library", "tokens/css-vars-must-be-defined"])
214
+ );
215
+ expect(findings).toHaveLength(2);
216
+ // every headline finding is contract vocabulary — no raw-color/hygiene leak
217
+ expect(ruleIds.every(isContractTierRule)).toBe(true);
218
+ });
219
+
220
+ it("ranks canonical-component bypass first (vocabulary-first), with a directed fix", () => {
221
+ const findings = runRules(withButtonText(contractIndex()));
222
+ const ranked = findings.map((f) => f.ruleId).sort(compareByVocabularyRank);
223
+ expect(ranked).toEqual(["components/prefer-library", "tokens/css-vars-must-be-defined"]);
224
+
225
+ const canonical = findings.find((f) => f.ruleId === "components/prefer-library");
226
+ // "Replace your raw <button> with your <Button>" — a directed, deterministic fix.
227
+ expect(canonical?.fix).toMatchObject({
228
+ kind: "replaceComponent",
229
+ from: "button",
230
+ to: "Button",
231
+ deterministic: true,
232
+ });
233
+ expect(canonical?.attributes).toMatchObject({
234
+ rawValue: "button",
235
+ suggestedComponent: "Button",
236
+ });
237
+ });
238
+ });