@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,120 @@
1
+ /**
2
+ * `tailwind/arbitrary-spacing` — flags arbitrary Tailwind spacing utilities
3
+ * that cannot be proven to sit on the configured spacing scale.
4
+ */
5
+
6
+ import type { FactIndex, TailwindClassFact } from "../facts/index.js";
7
+
8
+ import { makeFinding } from "./finding.js";
9
+ import type { Finding, FindingReplaceClassTokenFix } from "./types.js";
10
+ import {
11
+ matchesScale,
12
+ normalizeLengthForScale,
13
+ parseLengthValue,
14
+ readUsageNode,
15
+ } from "./utils.js";
16
+
17
+ export const RULE_ID = "tailwind/arbitrary-spacing";
18
+ export const RULE_VERSION = "1";
19
+
20
+ const SPACING_UTILITY_TO_PROPERTY: ReadonlyMap<string, string> = new Map([
21
+ ["m", "margin"],
22
+ ["mt", "margin"],
23
+ ["mr", "margin"],
24
+ ["mb", "margin"],
25
+ ["ml", "margin"],
26
+ ["ms", "margin"],
27
+ ["me", "margin"],
28
+ ["mx", "margin"],
29
+ ["my", "margin"],
30
+ ["p", "padding"],
31
+ ["pt", "padding"],
32
+ ["pr", "padding"],
33
+ ["pb", "padding"],
34
+ ["pl", "padding"],
35
+ ["ps", "padding"],
36
+ ["pe", "padding"],
37
+ ["px", "padding"],
38
+ ["py", "padding"],
39
+ ["gap", "gap"],
40
+ ["gap-x", "gap"],
41
+ ["gap-y", "gap"],
42
+ ["space-x", "gap"],
43
+ ["space-y", "gap"],
44
+ ] as const);
45
+
46
+ export function ruleTailwindArbitrarySpacing(ix: FactIndex): Finding[] {
47
+ const findings: Finding[] = [];
48
+
49
+ for (const klass of ix.byKind("tailwind_class")) {
50
+ if (klass.value.kind !== "arbitrary") continue;
51
+
52
+ const property = SPACING_UTILITY_TO_PROPERTY.get(klass.utility);
53
+ if (!property) continue;
54
+
55
+ const policy = ix.policy.propertyScale(property);
56
+ if (!policy) continue;
57
+ const scale = ix.policy.scale(policy.scale);
58
+ if (!scale) continue;
59
+ const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
60
+ if (allowed.length === 0) continue;
61
+
62
+ const parsed = parseLengthValue(klass.value.raw);
63
+ const normalized = parsed ? normalizeLengthForScale(parsed, scale) : null;
64
+ if (normalized && matchesScale(normalized.value, allowed)) continue;
65
+
66
+ const node = readUsageNode(ix, klass.nodeId);
67
+ const evidenceIds = node
68
+ ? [node.id, klass.id, policy.id, scale.id]
69
+ : [klass.id, policy.id, scale.id];
70
+
71
+ const fix = buildFix(klass);
72
+
73
+ findings.push(
74
+ makeFinding({
75
+ ruleId: RULE_ID,
76
+ ruleVersion: RULE_VERSION,
77
+ severity: policy.severity,
78
+ message: `\`${klass.raw}\` is an arbitrary spacing value not on the project's spacing scale.`,
79
+ location: klass.location,
80
+ evidence: ix.evidence(evidenceIds),
81
+ fingerprintIdentity: {
82
+ source: "tailwind_class",
83
+ file: klass.file,
84
+ nodeId: klass.nodeId,
85
+ originPath: klass.originPath,
86
+ raw: klass.raw,
87
+ utility: klass.utility,
88
+ value: klass.value.raw,
89
+ },
90
+ fix,
91
+ attributes: {
92
+ rawClass: klass.raw,
93
+ rawValue: klass.value.raw,
94
+ utility: klass.utility,
95
+ property,
96
+ scale: scale.name,
97
+ allowed,
98
+ normalizedValue: normalized?.value,
99
+ normalizedUnit: normalized ? scale.unit : undefined,
100
+ assumedRootFontSizePx: normalized?.assumedRootFontSizePx,
101
+ assumedEmBasePx: normalized?.assumedEmBasePx,
102
+ source: "tailwind",
103
+ },
104
+ }),
105
+ );
106
+ }
107
+
108
+ return findings;
109
+ }
110
+
111
+ function buildFix(klass: TailwindClassFact): FindingReplaceClassTokenFix {
112
+ return {
113
+ kind: "replaceClassToken",
114
+ title: `Review ${klass.raw}`,
115
+ from: klass.raw,
116
+ to: null,
117
+ utility: klass.utility,
118
+ deterministic: false,
119
+ };
120
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ makeTailwindClassFact,
7
+ makeTailwindTokenResolvedFact,
8
+ makeUsageNodeFact,
9
+ ruleTailwindForbiddenPalette,
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 indexWithPolicy(): FactIndex {
17
+ const ix = new FactIndex();
18
+ ix.addMany(
19
+ compileGlobalGovernanceFacts({
20
+ tailwind: {
21
+ palette: {
22
+ deny: ["red-*"],
23
+ },
24
+ },
25
+ })
26
+ );
27
+ return ix;
28
+ }
29
+
30
+ function addResolvedClass(
31
+ ix: FactIndex,
32
+ raw = "bg-red-500",
33
+ token = "red-500",
34
+ source: "default" | "theme-css" = "default"
35
+ ): void {
36
+ const node = makeUsageNodeFact({
37
+ file: FILE,
38
+ nodePath: "0",
39
+ element: "div",
40
+ location: LOC,
41
+ });
42
+ ix.add(node);
43
+ ix.add(
44
+ makeTailwindClassFact({
45
+ file: FILE,
46
+ nodeId: node.id,
47
+ raw,
48
+ originPath: `attribute#0/class#${raw}`,
49
+ prefix: null,
50
+ modifiers: [],
51
+ important: false,
52
+ negative: false,
53
+ utility: "bg",
54
+ value: { kind: "token", token },
55
+ location: LOC,
56
+ })
57
+ );
58
+ ix.add(
59
+ makeTailwindTokenResolvedFact({
60
+ utility: "bg",
61
+ token,
62
+ resolved: { kind: "color", value: "#ef4444", source },
63
+ })
64
+ );
65
+ }
66
+
67
+ describe("tailwind/forbidden-palette", () => {
68
+ it("flags denied palette tokens — ACCEPTANCE §5.28", () => {
69
+ const ix = indexWithPolicy();
70
+ addResolvedClass(ix);
71
+
72
+ const findings = ruleTailwindForbiddenPalette(ix);
73
+ expect(findings).toHaveLength(1);
74
+ expect(findings[0]).toMatchObject({
75
+ ruleId: "tailwind/forbidden-palette",
76
+ severity: "moderate",
77
+ message:
78
+ "`bg-red-500` uses the `red-500` palette token, which is forbidden by policy `deny: red-*`.",
79
+ attributes: {
80
+ utility: "bg",
81
+ token: "red-500",
82
+ matchedPattern: "red-*",
83
+ source: "default",
84
+ },
85
+ });
86
+ });
87
+
88
+ it("is registered in runRules", () => {
89
+ const ix = indexWithPolicy();
90
+ addResolvedClass(ix);
91
+
92
+ expect(runRules(ix).map((f) => f.ruleId)).toContain("tailwind/forbidden-palette");
93
+ });
94
+
95
+ it("allows project-defined theme tokens even when their names do not match the stock allowlist", () => {
96
+ const ix = indexWithPolicy();
97
+ addResolvedClass(ix, "bg-danger", "danger", "theme-css");
98
+
99
+ expect(ruleTailwindForbiddenPalette(ix)).toHaveLength(0);
100
+ });
101
+
102
+ it("never flags universal color keywords (bg-white/text-black/border-transparent) — BUG 6", () => {
103
+ // An allow-list policy that white/black would otherwise violate.
104
+ const ix = new FactIndex();
105
+ ix.addMany(
106
+ compileGlobalGovernanceFacts({
107
+ tailwind: { palette: { allow: ["brand-*", "neutral-*"] } },
108
+ })
109
+ );
110
+ for (const token of ["white", "black", "transparent", "current"]) {
111
+ addResolvedClass(ix, `bg-${token}`, token, "default");
112
+ }
113
+
114
+ expect(ruleTailwindForbiddenPalette(ix)).toHaveLength(0);
115
+ });
116
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `tailwind/forbidden-palette` — enforces Tailwind palette allow/deny policy
3
+ * against resolved token-form color utilities.
4
+ */
5
+
6
+ import type { FactIndex, TailwindClassFact } from "../facts/index.js";
7
+
8
+ import { makeFinding } from "./finding.js";
9
+ import type { Finding, FindingReplaceClassTokenFix } 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 { readUsageNode } from "./utils.js";
18
+
19
+ export const RULE_ID = "tailwind/forbidden-palette";
20
+ export const RULE_VERSION = "1";
21
+
22
+ export function ruleTailwindForbiddenPalette(ix: FactIndex): Finding[] {
23
+ const deny = ix.policy.tailwindPaletteDeny();
24
+ const allow = ix.policy.tailwindPaletteAllow();
25
+ if (!deny && !allow) return [];
26
+
27
+ const resolvedByKey = indexResolvedTailwindTokens(ix);
28
+ const findings: Finding[] = [];
29
+
30
+ for (const klass of ix.byKind("tailwind_class")) {
31
+ if (klass.value.kind !== "token") continue;
32
+ const token = klass.value.token;
33
+ if (!TAILWIND_COLOR_UTILITIES.has(klass.utility)) continue;
34
+ if (UNIVERSAL_TAILWIND_COLOR_TOKENS.has(token)) continue;
35
+
36
+ const resolved = resolvedForClass(resolvedByKey, klass);
37
+ if (!resolved || resolved.resolved.kind !== "color") continue;
38
+ if (resolved.resolved.source === "theme-css") continue;
39
+
40
+ const deniedPattern = deny ? matchPattern(token, deny.patterns) : null;
41
+ const allowedPattern = allow ? matchPattern(token, allow.patterns) : null;
42
+ const allowViolation = allow && !allowedPattern;
43
+ if (!deniedPattern && !allowViolation) continue;
44
+
45
+ const policy = deniedPattern ? deny! : allow!;
46
+ const matchedPattern = deniedPattern ?? allowedPattern ?? null;
47
+ const node = readUsageNode(ix, klass.nodeId);
48
+ const evidenceIds = node
49
+ ? [node.id, klass.id, resolved.id, policy.id]
50
+ : [klass.id, resolved.id, policy.id];
51
+
52
+ findings.push(
53
+ makeFinding({
54
+ ruleId: RULE_ID,
55
+ ruleVersion: RULE_VERSION,
56
+ severity: policy.severity,
57
+ message: messageFor(klass, token, deniedPattern, allow?.patterns ?? []),
58
+ location: klass.location,
59
+ evidence: ix.evidence(evidenceIds),
60
+ fingerprintIdentity: {
61
+ source: "tailwind_class",
62
+ file: klass.file,
63
+ nodeId: klass.nodeId,
64
+ originPath: klass.originPath,
65
+ raw: klass.raw,
66
+ utility: klass.utility,
67
+ token,
68
+ matchedPattern,
69
+ },
70
+ fix: buildFix(klass),
71
+ attributes: {
72
+ rawClass: klass.raw,
73
+ utility: klass.utility,
74
+ token,
75
+ matchedPattern,
76
+ allowedPatterns: allow?.patterns,
77
+ source: resolved.resolved.source,
78
+ },
79
+ })
80
+ );
81
+ }
82
+
83
+ return findings;
84
+ }
85
+
86
+ function messageFor(
87
+ klass: TailwindClassFact,
88
+ token: string,
89
+ deniedPattern: string | null,
90
+ allowedPatterns: readonly string[]
91
+ ): string {
92
+ if (deniedPattern) {
93
+ return `\`${klass.raw}\` uses the \`${token}\` palette token, which is forbidden by policy \`deny: ${deniedPattern}\`.`;
94
+ }
95
+ return `\`${klass.raw}\` uses the \`${token}\` palette token, which is outside the allowed Tailwind palette patterns [${allowedPatterns.join(", ")}].`;
96
+ }
97
+
98
+ function buildFix(klass: TailwindClassFact): FindingReplaceClassTokenFix {
99
+ return {
100
+ kind: "replaceClassToken",
101
+ title: `Review ${klass.raw}`,
102
+ from: klass.raw,
103
+ to: null,
104
+ utility: klass.utility,
105
+ deterministic: false,
106
+ };
107
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ g,
7
+ makeTailwindClassFact,
8
+ makeTailwindTokenResolvedFact,
9
+ makeUsageNodeFact,
10
+ ruleTailwindOffScaleSpacingToken,
11
+ runRules,
12
+ } from "../index.js";
13
+
14
+ const FILE = "src/app.tsx";
15
+ const LOC = { file: FILE, line: 1, column: 7 };
16
+
17
+ function indexWithScale(): FactIndex {
18
+ const ix = new FactIndex();
19
+ ix.addMany(
20
+ compileGlobalGovernanceFacts({
21
+ scales: {
22
+ space: g.scale.px([0, 4, 8, 16, 32]),
23
+ },
24
+ styles: [
25
+ g.styles.rawSpacing().mustMatchScale("space", {
26
+ appliesTo: ["padding"],
27
+ severity: "warn",
28
+ }),
29
+ ],
30
+ })
31
+ );
32
+ return ix;
33
+ }
34
+
35
+ function addResolvedSpacing(ix: FactIndex, raw = "p-3", token = "3", value = "0.75rem"): void {
36
+ const node = makeUsageNodeFact({
37
+ file: FILE,
38
+ nodePath: "0",
39
+ element: "div",
40
+ location: LOC,
41
+ });
42
+ ix.add(node);
43
+ ix.add(
44
+ makeTailwindClassFact({
45
+ file: FILE,
46
+ nodeId: node.id,
47
+ raw,
48
+ originPath: `attribute#0/class#${raw}`,
49
+ prefix: null,
50
+ modifiers: [],
51
+ important: false,
52
+ negative: false,
53
+ utility: "p",
54
+ value: { kind: "token", token },
55
+ location: LOC,
56
+ })
57
+ );
58
+ ix.add(
59
+ makeTailwindTokenResolvedFact({
60
+ utility: "p",
61
+ token,
62
+ resolved: { kind: "length", value, source: "theme-css" },
63
+ })
64
+ );
65
+ }
66
+
67
+ describe("tailwind/off-scale-spacing-token", () => {
68
+ it("flags spacing tokens whose resolved value misses the scale — ACCEPTANCE §5.29", () => {
69
+ const ix = indexWithScale();
70
+ addResolvedSpacing(ix);
71
+
72
+ const findings = ruleTailwindOffScaleSpacingToken(ix);
73
+ expect(findings).toHaveLength(1);
74
+ expect(findings[0]).toMatchObject({
75
+ ruleId: "tailwind/off-scale-spacing-token",
76
+ severity: "moderate",
77
+ message:
78
+ "`p-3` resolves to 12px, which is not on the project's spacing scale [0, 4, 8, 16, 32].",
79
+ attributes: {
80
+ utility: "p",
81
+ token: "3",
82
+ resolvedPx: 12,
83
+ },
84
+ });
85
+ });
86
+
87
+ it("allows resolved spacing tokens on the scale", () => {
88
+ const ix = indexWithScale();
89
+ addResolvedSpacing(ix, "p-4", "4", "1rem");
90
+
91
+ expect(ruleTailwindOffScaleSpacingToken(ix)).toHaveLength(0);
92
+ });
93
+
94
+ it("is registered in runRules", () => {
95
+ const ix = indexWithScale();
96
+ addResolvedSpacing(ix);
97
+
98
+ expect(runRules(ix).map((f) => f.ruleId)).toContain("tailwind/off-scale-spacing-token");
99
+ });
100
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `tailwind/off-scale-spacing-token` — checks resolved Tailwind spacing
3
+ * tokens against the same project spacing scale used for CSS declarations.
4
+ */
5
+
6
+ import type { FactIndex } from "../facts/index.js";
7
+
8
+ import { makeFinding } from "./finding.js";
9
+ import type { Finding, FindingReplaceClassTokenFix } from "./types.js";
10
+ import {
11
+ indexResolvedTailwindTokens,
12
+ resolvedForClass,
13
+ TAILWIND_SPACING_UTILITY_TO_PROPERTY,
14
+ } from "./tailwind-rule-utils.js";
15
+ import {
16
+ matchesScale,
17
+ normalizeLengthForScale,
18
+ parseLengthValue,
19
+ readUsageNode,
20
+ } from "./utils.js";
21
+
22
+ export const RULE_ID = "tailwind/off-scale-spacing-token";
23
+ export const RULE_VERSION = "1";
24
+
25
+ export function ruleTailwindOffScaleSpacingToken(ix: FactIndex): Finding[] {
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
+ const property = TAILWIND_SPACING_UTILITY_TO_PROPERTY.get(klass.utility);
32
+ if (!property) continue;
33
+
34
+ const policy = ix.policy.propertyScale(property);
35
+ if (!policy) continue;
36
+ const scale = ix.policy.scale(policy.scale);
37
+ if (!scale) continue;
38
+ const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
39
+ if (allowed.length === 0) continue;
40
+
41
+ const resolved = resolvedForClass(resolvedByKey, klass);
42
+ if (!resolved || resolved.resolved.kind !== "length") continue;
43
+
44
+ const parsed = parseLengthValue(resolved.resolved.value);
45
+ if (!parsed) continue;
46
+ const normalized = normalizeLengthForScale(parsed, scale);
47
+ if (!normalized) continue;
48
+ const signedValue = klass.negative ? -normalized.value : normalized.value;
49
+ if (matchesScale(signedValue, allowed)) continue;
50
+
51
+ const node = readUsageNode(ix, klass.nodeId);
52
+ const evidenceIds = node
53
+ ? [node.id, klass.id, resolved.id, policy.id, scale.id]
54
+ : [klass.id, resolved.id, policy.id, scale.id];
55
+
56
+ findings.push(
57
+ makeFinding({
58
+ ruleId: RULE_ID,
59
+ ruleVersion: RULE_VERSION,
60
+ severity: policy.severity,
61
+ message: `\`${klass.raw}\` resolves to ${formatResolvedValue(signedValue, scale.unit)}, which is not on the project's spacing scale [${allowed.join(", ")}].`,
62
+ location: klass.location,
63
+ evidence: ix.evidence(evidenceIds),
64
+ fingerprintIdentity: {
65
+ source: "tailwind_class",
66
+ file: klass.file,
67
+ nodeId: klass.nodeId,
68
+ originPath: klass.originPath,
69
+ raw: klass.raw,
70
+ utility: klass.utility,
71
+ token: klass.value.token,
72
+ resolvedValue: signedValue,
73
+ scale: scale.name,
74
+ },
75
+ fix: buildFix(klass.raw, klass.utility),
76
+ attributes: {
77
+ rawClass: klass.raw,
78
+ utility: klass.utility,
79
+ token: klass.value.token,
80
+ property,
81
+ scale: scale.name,
82
+ allowed,
83
+ resolvedValue: resolved.resolved.value,
84
+ resolvedPx: scale.unit === "px" ? signedValue : undefined,
85
+ resolvedUnit: scale.unit,
86
+ source: resolved.resolved.source,
87
+ },
88
+ })
89
+ );
90
+ }
91
+
92
+ return findings;
93
+ }
94
+
95
+ function formatResolvedValue(value: number, unit: string): string {
96
+ return unit === "px" ? `${value}px` : `${value}${unit}`;
97
+ }
98
+
99
+ function buildFix(raw: string, utility: string): FindingReplaceClassTokenFix {
100
+ return {
101
+ kind: "replaceClassToken",
102
+ title: `Review ${raw}`,
103
+ from: raw,
104
+ to: null,
105
+ utility,
106
+ deterministic: false,
107
+ };
108
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ makeTailwindClassFact,
7
+ makeTailwindTokenResolvedFact,
8
+ makeUsageNodeFact,
9
+ ruleTailwindRawColorViaToken,
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 indexWithAllowPolicy(): FactIndex {
17
+ const ix = new FactIndex();
18
+ ix.addMany(
19
+ compileGlobalGovernanceFacts({
20
+ tailwind: {
21
+ palette: {
22
+ allow: ["brand-*"],
23
+ },
24
+ },
25
+ })
26
+ );
27
+ return ix;
28
+ }
29
+
30
+ function addResolvedColor(
31
+ ix: FactIndex,
32
+ raw = "bg-red-500",
33
+ token = "red-500",
34
+ source: "default" | "theme-css" = "default"
35
+ ): void {
36
+ const node = makeUsageNodeFact({
37
+ file: FILE,
38
+ nodePath: "0",
39
+ element: "div",
40
+ location: LOC,
41
+ });
42
+ ix.add(node);
43
+ ix.add(
44
+ makeTailwindClassFact({
45
+ file: FILE,
46
+ nodeId: node.id,
47
+ raw,
48
+ originPath: `attribute#0/class#${raw}`,
49
+ prefix: null,
50
+ modifiers: [],
51
+ important: false,
52
+ negative: false,
53
+ utility: "bg",
54
+ value: { kind: "token", token },
55
+ location: LOC,
56
+ })
57
+ );
58
+ ix.add(
59
+ makeTailwindTokenResolvedFact({
60
+ utility: "bg",
61
+ token,
62
+ resolved: { kind: "color", value: "#ef4444", source },
63
+ })
64
+ );
65
+ }
66
+
67
+ describe("tailwind/raw-color-via-token", () => {
68
+ it("flags default color tokens outside the allowed palette — ACCEPTANCE §5.30", () => {
69
+ const ix = indexWithAllowPolicy();
70
+ addResolvedColor(ix);
71
+
72
+ const findings = ruleTailwindRawColorViaToken(ix);
73
+ expect(findings).toHaveLength(1);
74
+ expect(findings[0]).toMatchObject({
75
+ ruleId: "tailwind/raw-color-via-token",
76
+ severity: "moderate",
77
+ message:
78
+ "`bg-red-500` resolves to raw color `#ef4444` (Tailwind default). Project policy requires `brand-*` palette tokens.",
79
+ attributes: {
80
+ utility: "bg",
81
+ token: "red-500",
82
+ resolvedValue: "#ef4444",
83
+ source: "default",
84
+ },
85
+ });
86
+ });
87
+
88
+ it("allows tokens matching the palette allow list", () => {
89
+ const ix = indexWithAllowPolicy();
90
+ addResolvedColor(ix, "bg-brand-500", "brand-500");
91
+
92
+ expect(ruleTailwindRawColorViaToken(ix)).toHaveLength(0);
93
+ });
94
+
95
+ it("allows project-defined theme tokens outside the stock palette names", () => {
96
+ const ix = indexWithAllowPolicy();
97
+ addResolvedColor(ix, "bg-danger", "danger", "theme-css");
98
+
99
+ expect(ruleTailwindRawColorViaToken(ix)).toHaveLength(0);
100
+ });
101
+
102
+ it("is registered in runRules", () => {
103
+ const ix = indexWithAllowPolicy();
104
+ addResolvedColor(ix);
105
+
106
+ expect(runRules(ix).map((f) => f.ruleId)).toContain("tailwind/raw-color-via-token");
107
+ });
108
+ });