@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.
- package/LICENSE +21 -0
- package/dist/chunk-243QYRUF.js +2145 -0
- package/dist/chunk-243QYRUF.js.map +1 -0
- package/dist/chunk-3LLRNCPX.js +111 -0
- package/dist/chunk-3LLRNCPX.js.map +1 -0
- package/dist/chunk-EIYNNS77.js +48 -0
- package/dist/chunk-EIYNNS77.js.map +1 -0
- package/dist/chunk-JNBFJ34I.js +326 -0
- package/dist/chunk-JNBFJ34I.js.map +1 -0
- package/dist/chunk-R3X7UBHU.js +228 -0
- package/dist/chunk-R3X7UBHU.js.map +1 -0
- package/dist/chunk-SH4KPIYH.js +393 -0
- package/dist/chunk-SH4KPIYH.js.map +1 -0
- package/dist/chunk-V4VQB57N.js +56 -0
- package/dist/chunk-V4VQB57N.js.map +1 -0
- package/dist/chunk-X34IA4LR.js +319 -0
- package/dist/chunk-X34IA4LR.js.map +1 -0
- package/dist/chunk-YF65VYRY.js +493 -0
- package/dist/chunk-YF65VYRY.js.map +1 -0
- package/dist/codes/index.d.ts +40 -0
- package/dist/codes/index.js +23 -0
- package/dist/codes/index.js.map +1 -0
- package/dist/compiled-types/index.d.ts +5 -0
- package/dist/compiled-types/index.js +1 -0
- package/dist/compiled-types/index.js.map +1 -0
- package/dist/generate/index.d.ts +47 -0
- package/dist/generate/index.js +11 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/governance-BOa3KyiJ.d.ts +3279 -0
- package/dist/graph/index.d.ts +66 -0
- package/dist/graph/index.js +17 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +11388 -0
- package/dist/index.js +8711 -0
- package/dist/index.js.map +1 -0
- package/dist/package-identity.d.ts +42 -0
- package/dist/package-identity.js +23 -0
- package/dist/package-identity.js.map +1 -0
- package/dist/preview/index.d.ts +484 -0
- package/dist/preview/index.js +1129 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/react-types.d.ts +22 -0
- package/dist/react-types.js +1 -0
- package/dist/react-types.js.map +1 -0
- package/dist/registry.d.ts +2367 -0
- package/dist/registry.js +62 -0
- package/dist/registry.js.map +1 -0
- package/dist/schemas/index.d.ts +2584 -0
- package/dist/schemas/index.js +55 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/severity.d.ts +18 -0
- package/dist/severity.js +27 -0
- package/dist/severity.js.map +1 -0
- package/dist/test-utils.d.ts +14 -0
- package/dist/test-utils.js +63 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/topology/index.d.ts +48 -0
- package/dist/topology/index.js +7 -0
- package/dist/topology/index.js.map +1 -0
- package/dist/types-xJ2xyp_G.d.ts +119 -0
- package/package.json +120 -0
- package/src/__tests__/contract-parser.test.ts +614 -0
- package/src/__tests__/dtcg-generators.test.ts +439 -0
- package/src/__tests__/dtcg-parser.test.ts +884 -0
- package/src/__tests__/preview-runtime.test.tsx +111 -0
- package/src/__tests__/schema.test.ts +31 -0
- package/src/__tests__/severity.test.ts +34 -0
- package/src/__tests__/style-comparison.test.ts +662 -0
- package/src/__tests__/tokens-compat.test.ts +51 -0
- package/src/agent-format.test.ts +105 -0
- package/src/agent-format.ts +141 -0
- package/src/bundle.ts +149 -0
- package/src/codes/__tests__/codes.test.ts +73 -0
- package/src/codes/codes.ts +463 -0
- package/src/codes/index.ts +7 -0
- package/src/codes/source-finding-bridge.ts +113 -0
- package/src/compiled-types/index.ts +293 -0
- package/src/component-contract.ts +231 -0
- package/src/component-discovery.ts +80 -0
- package/src/composition.test.ts +262 -0
- package/src/composition.ts +318 -0
- package/src/config-paths.test.ts +78 -0
- package/src/config-paths.ts +47 -0
- package/src/config.ts +18 -0
- package/src/conform-prove.test.ts +274 -0
- package/src/conform-prove.ts +326 -0
- package/src/conform.ts +160 -0
- package/src/constants.ts +114 -0
- package/src/context.ts +2 -0
- package/src/contract/fixture.ts +110 -0
- package/src/contract/hash.test.ts +86 -0
- package/src/contract/hash.ts +195 -0
- package/src/contract/index.ts +43 -0
- package/src/contract/preimage.test.ts +254 -0
- package/src/contract/preimage.ts +365 -0
- package/src/contract/stamp.test.ts +100 -0
- package/src/contract/stamp.ts +105 -0
- package/src/contract-parser.ts +141 -0
- package/src/defineFragment.test.ts +257 -0
- package/src/defineFragment.ts +293 -0
- package/src/dtcg-generators.ts +324 -0
- package/src/dtcg-parser.ts +1 -0
- package/src/dtcg.ts +237 -0
- package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
- package/src/facts/builders.ts +835 -0
- package/src/facts/compile.ts +422 -0
- package/src/facts/fact-index.ts +394 -0
- package/src/facts/facts.test.ts +494 -0
- package/src/facts/ids.ts +125 -0
- package/src/facts/index.ts +119 -0
- package/src/facts/types.ts +614 -0
- package/src/figma.ts +263 -0
- package/src/fragment-types.ts +214 -0
- package/src/generate/generate.test.ts +594 -0
- package/src/generate/index.ts +438 -0
- package/src/governance-integrity.test.ts +186 -0
- package/src/governance-integrity.ts +282 -0
- package/src/governance.test.ts +300 -0
- package/src/governance.ts +605 -0
- package/src/governance.type-test.ts +75 -0
- package/src/graph/engine.ts +446 -0
- package/src/graph/graph.test.ts +746 -0
- package/src/graph/index.ts +32 -0
- package/src/graph/serialization.ts +55 -0
- package/src/graph/types.ts +178 -0
- package/src/index.ts +891 -0
- package/src/local-canonical.ts +102 -0
- package/src/mcp-snapshot.ts +222 -0
- package/src/package-identity-match.ts +33 -0
- package/src/package-identity.generated.ts +233 -0
- package/src/package-identity.test.ts +99 -0
- package/src/package-identity.ts +157 -0
- package/src/performance-presets.ts +142 -0
- package/src/preview/comparison-artifact.test.ts +228 -0
- package/src/preview/comparison-artifact.ts +181 -0
- package/src/preview/comparison-context.ts +93 -0
- package/src/preview/comparison-evidence.ts +112 -0
- package/src/preview/comparison-format.ts +21 -0
- package/src/preview/comparison-presentation.ts +187 -0
- package/src/preview/comparison-references.test.ts +430 -0
- package/src/preview/comparison-references.ts +72 -0
- package/src/preview/comparison-score.ts +59 -0
- package/src/preview/comparison-styles.ts +197 -0
- package/src/preview/comparison.test.ts +559 -0
- package/src/preview/comparison.ts +488 -0
- package/src/preview/index.ts +10 -0
- package/src/preview/types.ts +309 -0
- package/src/preview/validation.test.ts +290 -0
- package/src/preview/validation.ts +356 -0
- package/src/preview-runtime.tsx +144 -0
- package/src/raw-html-canonical.test.ts +41 -0
- package/src/raw-html-canonical.ts +179 -0
- package/src/react-types.ts +21 -0
- package/src/registry-install-plan.ts +344 -0
- package/src/registry.test.ts +245 -0
- package/src/registry.ts +369 -0
- package/src/rules/a11y-required-accessible-name.ts +93 -0
- package/src/rules/color-math.test.ts +72 -0
- package/src/rules/color-math.ts +192 -0
- package/src/rules/components-forbidden-prop-value.ts +108 -0
- package/src/rules/components-prefer-library.ts +943 -0
- package/src/rules/components-unknown-prop.ts +94 -0
- package/src/rules/composition-pattern-schema.ts +125 -0
- package/src/rules/composition-pattern.test.ts +320 -0
- package/src/rules/composition-pattern.ts +271 -0
- package/src/rules/emit-gate.test.ts +42 -0
- package/src/rules/emit-gate.ts +60 -0
- package/src/rules/finding.ts +54 -0
- package/src/rules/index.ts +235 -0
- package/src/rules/jsx-preferred-component.ts +68 -0
- package/src/rules/jsx-preferred-import-path.ts +67 -0
- package/src/rules/presets.ts +57 -0
- package/src/rules/props-invalid-value.ts +115 -0
- package/src/rules/rules.test.ts +3636 -0
- package/src/rules/spacing-resolution.ts +184 -0
- package/src/rules/styles-no-raw-color.ts +329 -0
- package/src/rules/styles-no-raw-dimensions.ts +357 -0
- package/src/rules/styles-no-raw-spacing.ts +236 -0
- package/src/rules/styles-no-raw-typography.ts +237 -0
- package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
- package/src/rules/tailwind-arbitrary-color.ts +93 -0
- package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
- package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
- package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
- package/src/rules/tailwind-forbidden-palette.ts +107 -0
- package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
- package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.ts +82 -0
- package/src/rules/tailwind-rule-utils.ts +106 -0
- package/src/rules/tailwind-unknown-class.test.ts +157 -0
- package/src/rules/tailwind-unknown-class.ts +121 -0
- package/src/rules/taxonomy.test.ts +238 -0
- package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
- package/src/rules/tiers.ts +111 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
- package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
- package/src/rules/tokens-require-dual-fallback.ts +78 -0
- package/src/rules/types.ts +27 -0
- package/src/rules/utils.test.ts +110 -0
- package/src/rules/utils.ts +599 -0
- package/src/schema.ts +427 -0
- package/src/schemas/index.ts +275 -0
- package/src/schemas/schemas.test.ts +124 -0
- package/src/severity.ts +54 -0
- package/src/storyAdapter.test.ts +571 -0
- package/src/storyAdapter.ts +761 -0
- package/src/storyFilters.test.ts +350 -0
- package/src/storyFilters.ts +253 -0
- package/src/storybook-csf.ts +11 -0
- package/src/style-comparison.ts +423 -0
- package/src/test-utils.ts +68 -0
- package/src/token-parser.ts +2 -0
- package/src/token-types.ts +287 -0
- package/src/tokens/categories.ts +131 -0
- package/src/tokens/color.ts +193 -0
- package/src/tokens/design-token-parser.test.ts +159 -0
- package/src/tokens/design-token-parser.ts +402 -0
- package/src/tokens/dtcg.ts +428 -0
- package/src/tokens/index.ts +130 -0
- package/src/tokens/lookup.ts +19 -0
- package/src/tokens/scss.ts +171 -0
- package/src/tokens/tailwind-v4.ts +187 -0
- package/src/tokens/types.ts +43 -0
- package/src/topology/index.ts +9 -0
- package/src/topology/resolve-area.test.ts +174 -0
- package/src/topology/resolve-area.ts +215 -0
- package/src/types.ts +1057 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { TokenDefinitionFact } from "../facts/index.js";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isApplicableTokenReference,
|
|
5
|
+
matchesScale,
|
|
6
|
+
nearestSignedScaleValue,
|
|
7
|
+
normalizeLengthForScale,
|
|
8
|
+
parseLengthValue,
|
|
9
|
+
tokenReference,
|
|
10
|
+
type ParsedLengthValue,
|
|
11
|
+
} from "./utils.js";
|
|
12
|
+
|
|
13
|
+
export interface SpacingResolutionScale {
|
|
14
|
+
name: string;
|
|
15
|
+
unit: "px" | "rem";
|
|
16
|
+
rootFontSizePx?: number;
|
|
17
|
+
emBasePx?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Spacing tokens keyed by their value normalized into a given scale's unit. */
|
|
21
|
+
export type SpacingTokenLookup = Map<number, TokenDefinitionFact>;
|
|
22
|
+
|
|
23
|
+
export interface CheckedSpacingValue {
|
|
24
|
+
normalizedValue: number;
|
|
25
|
+
/** Best-effort suggestion for display, even when no fix is emitted. */
|
|
26
|
+
suggestedValue?: string;
|
|
27
|
+
/** Token name backing suggestedValue, when the canonical token vocabulary has one. */
|
|
28
|
+
suggestedToken?: string;
|
|
29
|
+
/** Whether a fix object should be attached (suggestion reliable enough). */
|
|
30
|
+
fixEmittable: boolean;
|
|
31
|
+
deterministicFix: boolean;
|
|
32
|
+
/** Dominant reason for the finding — drives message wording. */
|
|
33
|
+
reason: "off-scale" | "token-equivalent";
|
|
34
|
+
/** Token name when the violation is a raw literal that equals a token. */
|
|
35
|
+
matchedToken?: string;
|
|
36
|
+
assumedRootFontSizePx?: number;
|
|
37
|
+
assumedEmBasePx?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildSpacingTokenLookup(
|
|
41
|
+
tokens: readonly TokenDefinitionFact[],
|
|
42
|
+
scale: SpacingResolutionScale
|
|
43
|
+
): SpacingTokenLookup {
|
|
44
|
+
const out: SpacingTokenLookup = new Map();
|
|
45
|
+
for (const token of tokens) {
|
|
46
|
+
const parsed = parseLengthValue(token.value);
|
|
47
|
+
if (!parsed) continue;
|
|
48
|
+
const normalized = normalizeLengthForScale(parsed, scale);
|
|
49
|
+
if (normalized === null) continue;
|
|
50
|
+
const key = Math.abs(normalized.value);
|
|
51
|
+
// First token wins so the suggestion is stable across runs.
|
|
52
|
+
if (!out.has(key)) out.set(key, token);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveSpacingValue(input: {
|
|
58
|
+
raw: string;
|
|
59
|
+
allowed: readonly number[];
|
|
60
|
+
scale: SpacingResolutionScale;
|
|
61
|
+
tokens: SpacingTokenLookup;
|
|
62
|
+
bareNumberFix?: boolean;
|
|
63
|
+
}): CheckedSpacingValue | null {
|
|
64
|
+
const parts = parseLengthParts(input.raw);
|
|
65
|
+
if (!parts) return null;
|
|
66
|
+
|
|
67
|
+
let firstViolation: CheckedSpacingValue | null = null;
|
|
68
|
+
const suggestedParts: string[] = [];
|
|
69
|
+
let anyViolation = false;
|
|
70
|
+
let anyOffScale = false;
|
|
71
|
+
let canFixAll = true;
|
|
72
|
+
// A snap is only reliable enough to *offer* when the value normalization was
|
|
73
|
+
// exact — an assumed em/rem base makes the px guess unreliable, so report the
|
|
74
|
+
// suggestion but attach no deterministic fix.
|
|
75
|
+
let reliableSuggestion = true;
|
|
76
|
+
// Deterministic only when every violating part is a value-preserving,
|
|
77
|
+
// verbatim-resolvable token swap.
|
|
78
|
+
let allApplicableValuePreserving = true;
|
|
79
|
+
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
const normalized = normalizeLengthForScale(part.parsed, input.scale);
|
|
82
|
+
if (normalized === null) return null;
|
|
83
|
+
const magnitude = Math.abs(normalized.value);
|
|
84
|
+
const onScale = matchesScale(normalized.value, input.allowed);
|
|
85
|
+
const exactToken = input.tokens.get(magnitude);
|
|
86
|
+
|
|
87
|
+
if (onScale) {
|
|
88
|
+
if (!exactToken) {
|
|
89
|
+
// On-grid value with no named token (e.g. `0`) — not a violation.
|
|
90
|
+
suggestedParts.push(part.raw);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// On-grid literal that equals a token: value-preserving swap.
|
|
94
|
+
anyViolation = true;
|
|
95
|
+
const reference = tokenReference(exactToken.name);
|
|
96
|
+
const applicable =
|
|
97
|
+
isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
|
|
98
|
+
if (!applicable) allApplicableValuePreserving = false;
|
|
99
|
+
suggestedParts.push(reference);
|
|
100
|
+
if (!firstViolation) {
|
|
101
|
+
firstViolation = {
|
|
102
|
+
normalizedValue: normalized.value,
|
|
103
|
+
suggestedValue: reference,
|
|
104
|
+
suggestedToken: exactToken.name,
|
|
105
|
+
fixEmittable: true,
|
|
106
|
+
deterministicFix: applicable,
|
|
107
|
+
reason: "token-equivalent",
|
|
108
|
+
matchedToken: exactToken.name,
|
|
109
|
+
assumedRootFontSizePx: normalized.assumedRootFontSizePx,
|
|
110
|
+
assumedEmBasePx: normalized.assumedEmBasePx,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Off the scale — flag, suggest the nearest scale value as its token when
|
|
117
|
+
// one exists. Snapping changes rendered value, so this is not deterministic.
|
|
118
|
+
anyViolation = true;
|
|
119
|
+
anyOffScale = true;
|
|
120
|
+
allApplicableValuePreserving = false;
|
|
121
|
+
|
|
122
|
+
const nearest = nearestSignedScaleValue(normalized.value, input.allowed);
|
|
123
|
+
const snapsToZero = nearest === 0 && normalized.value !== 0;
|
|
124
|
+
let suggestedPart: string | undefined;
|
|
125
|
+
let suggestedToken: string | undefined;
|
|
126
|
+
if (nearest === null || snapsToZero) {
|
|
127
|
+
suggestedPart = undefined;
|
|
128
|
+
canFixAll = false;
|
|
129
|
+
suggestedParts.push(part.raw);
|
|
130
|
+
} else {
|
|
131
|
+
const nearestToken = input.tokens.get(Math.abs(nearest));
|
|
132
|
+
suggestedToken = nearestToken?.name;
|
|
133
|
+
suggestedPart = nearestToken
|
|
134
|
+
? tokenReference(nearestToken.name)
|
|
135
|
+
: input.bareNumberFix
|
|
136
|
+
? `${nearest}`
|
|
137
|
+
: `${nearest}${input.scale.unit}`;
|
|
138
|
+
suggestedParts.push(suggestedPart);
|
|
139
|
+
if (!normalized.deterministicFix) reliableSuggestion = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!firstViolation) {
|
|
143
|
+
firstViolation = {
|
|
144
|
+
normalizedValue: normalized.value,
|
|
145
|
+
suggestedValue: suggestedPart,
|
|
146
|
+
suggestedToken,
|
|
147
|
+
fixEmittable: false,
|
|
148
|
+
deterministicFix: false,
|
|
149
|
+
reason: "off-scale",
|
|
150
|
+
assumedRootFontSizePx: normalized.assumedRootFontSizePx,
|
|
151
|
+
assumedEmBasePx: normalized.assumedEmBasePx,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!firstViolation || !anyViolation) return null;
|
|
157
|
+
|
|
158
|
+
const joined = suggestedParts.join(" ");
|
|
159
|
+
const changes = joined !== input.raw.trim().replace(/\s+/g, " ");
|
|
160
|
+
const fixEmittable = canFixAll && reliableSuggestion && changes;
|
|
161
|
+
const deterministic = fixEmittable && !anyOffScale && allApplicableValuePreserving;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...firstViolation,
|
|
165
|
+
reason: anyOffScale ? "off-scale" : "token-equivalent",
|
|
166
|
+
fixEmittable,
|
|
167
|
+
deterministicFix: deterministic,
|
|
168
|
+
suggestedValue: canFixAll ? joined : firstViolation.suggestedValue,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseLengthParts(raw: string): Array<{ raw: string; parsed: ParsedLengthValue }> | null {
|
|
173
|
+
const trimmed = raw.trim();
|
|
174
|
+
if (!trimmed || /var\(|calc\(|min\(|max\(|clamp\(|,/i.test(trimmed)) return null;
|
|
175
|
+
const parts = trimmed.split(/\s+/);
|
|
176
|
+
if (parts.length < 1 || parts.length > 4) return null;
|
|
177
|
+
|
|
178
|
+
const parsed = parts.map((part) => {
|
|
179
|
+
const length = parseLengthValue(part);
|
|
180
|
+
return length ? { raw: part, parsed: length } : null;
|
|
181
|
+
});
|
|
182
|
+
if (parsed.some((part) => part === null)) return null;
|
|
183
|
+
return parsed as Array<{ raw: string; parsed: ParsedLengthValue }>;
|
|
184
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `styles/no-raw-color` — fires when a color-bearing CSS declaration or JSX
|
|
3
|
+
* inline style uses a raw color literal (hex, rgb/rgba, hsl/hsla, named) and
|
|
4
|
+
* the global `style.rawColors.forbid` policy is active. Token references
|
|
5
|
+
* (`var(--...)`) and policy-supplied exemptions (`transparent`, etc.) pass.
|
|
6
|
+
*
|
|
7
|
+
* The rule looks at any `style_declaration` or `usage_inline_style` whose
|
|
8
|
+
* value contains a recognizable color literal, not just declarations whose
|
|
9
|
+
* property name suggests a color — backgrounds use `background:` shorthand,
|
|
10
|
+
* borders use `border-color:`, and SVG uses `fill:`/`stroke:`. Detecting on
|
|
11
|
+
* the *value* keeps the rule property-agnostic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
15
|
+
|
|
16
|
+
import { colorDistance, parseOpaqueColor } from "./color-math.js";
|
|
17
|
+
import { makeFinding } from "./finding.js";
|
|
18
|
+
import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
|
|
19
|
+
import {
|
|
20
|
+
detectRawColor,
|
|
21
|
+
indexComponentByNodeId,
|
|
22
|
+
isApplicableTokenReference,
|
|
23
|
+
isExemptColor,
|
|
24
|
+
readUsageNode,
|
|
25
|
+
tokenReference,
|
|
26
|
+
} from "./utils.js";
|
|
27
|
+
|
|
28
|
+
export const RULE_ID = "styles/no-raw-color";
|
|
29
|
+
export const RULE_VERSION = "1";
|
|
30
|
+
|
|
31
|
+
export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
32
|
+
const policy = ix.policy.rawColorPolicy();
|
|
33
|
+
if (!policy) return [];
|
|
34
|
+
|
|
35
|
+
const colorTokens = ix.tokens.byCategory("color");
|
|
36
|
+
const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
|
|
37
|
+
const findings: Finding[] = [];
|
|
38
|
+
|
|
39
|
+
for (const decl of ix.byKind("style_declaration")) {
|
|
40
|
+
if (decl.property.startsWith("--") && decl.declaredTokenSource === true) continue;
|
|
41
|
+
const color = detectRawColor(decl.value);
|
|
42
|
+
if (!color) continue;
|
|
43
|
+
if (isExemptColor(color, policy.except)) continue;
|
|
44
|
+
|
|
45
|
+
const resolution = resolveColorToken(decl.property, color, colorTokens);
|
|
46
|
+
const evidenceIds = resolution
|
|
47
|
+
? [decl.id, policy.id, resolution.token.id]
|
|
48
|
+
: [decl.id, policy.id];
|
|
49
|
+
|
|
50
|
+
findings.push(
|
|
51
|
+
makeFinding({
|
|
52
|
+
ruleId: RULE_ID,
|
|
53
|
+
ruleVersion: RULE_VERSION,
|
|
54
|
+
severity: policy.severity,
|
|
55
|
+
message: colorMessage(color, `\`${decl.property}\``, resolution, preferLabel),
|
|
56
|
+
location: decl.location,
|
|
57
|
+
evidence: ix.evidence(evidenceIds),
|
|
58
|
+
fingerprintIdentity: {
|
|
59
|
+
source: "style_declaration",
|
|
60
|
+
file: decl.file,
|
|
61
|
+
selector: decl.selector,
|
|
62
|
+
declarationPath: decl.declarationPath,
|
|
63
|
+
property: decl.property,
|
|
64
|
+
value: color,
|
|
65
|
+
},
|
|
66
|
+
fix:
|
|
67
|
+
resolution?.kind === "exact"
|
|
68
|
+
? buildTokenFix(decl.property, decl.value, color, resolution)
|
|
69
|
+
: undefined,
|
|
70
|
+
attributes: {
|
|
71
|
+
property: decl.property,
|
|
72
|
+
rawValue: decl.value,
|
|
73
|
+
color,
|
|
74
|
+
source: "css",
|
|
75
|
+
suggestedToken: resolution?.token.name,
|
|
76
|
+
...(resolution ? { tokenMatch: resolution.kind } : {}),
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const componentByNode = indexComponentByNodeId(ix);
|
|
83
|
+
for (const inline of ix.byKind("usage_inline_style")) {
|
|
84
|
+
if (inline.valueKind === "css-variable") continue;
|
|
85
|
+
const color = detectRawColor(inline.value);
|
|
86
|
+
if (!color) continue;
|
|
87
|
+
if (isExemptColor(color, policy.except)) continue;
|
|
88
|
+
|
|
89
|
+
const node = readUsageNode(ix, inline.nodeId);
|
|
90
|
+
if (!node) continue;
|
|
91
|
+
|
|
92
|
+
const componentEvidenceId = componentByNode.get(node.id)?.id;
|
|
93
|
+
const resolution = resolveColorToken(inline.property, color, colorTokens);
|
|
94
|
+
const baseEvidence = componentEvidenceId
|
|
95
|
+
? [node.id, componentEvidenceId, inline.id, policy.id]
|
|
96
|
+
: [node.id, inline.id, policy.id];
|
|
97
|
+
const evidenceIds = resolution ? [...baseEvidence, resolution.token.id] : baseEvidence;
|
|
98
|
+
|
|
99
|
+
findings.push(
|
|
100
|
+
makeFinding({
|
|
101
|
+
ruleId: RULE_ID,
|
|
102
|
+
ruleVersion: RULE_VERSION,
|
|
103
|
+
severity: policy.severity,
|
|
104
|
+
message: colorMessage(color, `inline \`${inline.property}\``, resolution, preferLabel),
|
|
105
|
+
location: node.location,
|
|
106
|
+
evidence: ix.evidence(evidenceIds),
|
|
107
|
+
fingerprintIdentity: {
|
|
108
|
+
source: "usage_inline_style",
|
|
109
|
+
file: node.file,
|
|
110
|
+
nodePath: node.nodePath,
|
|
111
|
+
element: node.element,
|
|
112
|
+
property: inline.property,
|
|
113
|
+
value: color,
|
|
114
|
+
},
|
|
115
|
+
fix:
|
|
116
|
+
resolution?.kind === "exact"
|
|
117
|
+
? buildTokenFix(inline.property, inline.value, color, resolution)
|
|
118
|
+
: undefined,
|
|
119
|
+
attributes: {
|
|
120
|
+
property: inline.property,
|
|
121
|
+
rawValue: inline.value,
|
|
122
|
+
color,
|
|
123
|
+
source: "jsx",
|
|
124
|
+
suggestedToken: resolution?.token.name,
|
|
125
|
+
...(resolution ? { tokenMatch: resolution.kind } : {}),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return findings;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface TokenMatch {
|
|
135
|
+
token: TokenDefinitionFact;
|
|
136
|
+
/** True when several tokens share the value and none matches the property's
|
|
137
|
+
* role — the swap is a guess, so the fix must not be applied automatically. */
|
|
138
|
+
ambiguous: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* How a raw color resolves against the project's tokens (mirrors the
|
|
143
|
+
* `styles/no-raw-dimensions` resolution semantics):
|
|
144
|
+
* - `exact` — value-identical token; deterministic fix unless role-ambiguous.
|
|
145
|
+
* - `nearest` — no token has this exact value, but one is perceptually close
|
|
146
|
+
* (an off-by-a-shade `#7c3acc` next to a `#7c3aed` accent token). Surfaced as
|
|
147
|
+
* a `suggestedToken` hint on the message + attribute with NO fix object:
|
|
148
|
+
* snapping changes the rendered color, and the conform engine auto-applies
|
|
149
|
+
* token-backed fixes, so attaching one would silently rewrite the value.
|
|
150
|
+
*/
|
|
151
|
+
type ColorResolution =
|
|
152
|
+
| { kind: "exact"; token: TokenDefinitionFact; ambiguous: boolean }
|
|
153
|
+
| { kind: "nearest"; token: TokenDefinitionFact };
|
|
154
|
+
|
|
155
|
+
function resolveColorToken(
|
|
156
|
+
property: string,
|
|
157
|
+
raw: string,
|
|
158
|
+
tokens: readonly TokenDefinitionFact[]
|
|
159
|
+
): ColorResolution | null {
|
|
160
|
+
const exact = findTokenMatch(property, raw, tokens);
|
|
161
|
+
if (exact) return { kind: "exact", token: exact.token, ambiguous: exact.ambiguous };
|
|
162
|
+
const nearest = findNearestColorToken(raw, tokens);
|
|
163
|
+
return nearest ? { kind: "nearest", token: nearest } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function colorMessage(
|
|
167
|
+
color: string,
|
|
168
|
+
propertyLabel: string,
|
|
169
|
+
resolution: ColorResolution | null,
|
|
170
|
+
preferLabel: string
|
|
171
|
+
): string {
|
|
172
|
+
if (resolution?.kind === "nearest") {
|
|
173
|
+
return `Raw color ${color} on ${propertyLabel}. Closest token is \`${resolution.token.name}\` (${String(resolution.token.value)}); use a ${preferLabel} instead.`;
|
|
174
|
+
}
|
|
175
|
+
return `Raw color ${color} on ${propertyLabel}. Use a ${preferLabel} instead.`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Redmean-weighted RGB distance cutoff for a "nearest" suggestion. Redmean
|
|
180
|
+
* ranges 0–~765; ~60 keeps suggestions within the same visual family (an
|
|
181
|
+
* off-by-one-shade brand color) while never bridging genuinely different hues.
|
|
182
|
+
* Both sides parse through `parseOpaqueColor` (hex, rgb/rgba, hsl/hsla,
|
|
183
|
+
* oklch), so the raw value and the token may be authored in different
|
|
184
|
+
* notations and still match.
|
|
185
|
+
*/
|
|
186
|
+
const NEAREST_COLOR_MAX_DISTANCE = 60;
|
|
187
|
+
|
|
188
|
+
function findNearestColorToken(
|
|
189
|
+
raw: string,
|
|
190
|
+
tokens: readonly TokenDefinitionFact[]
|
|
191
|
+
): TokenDefinitionFact | null {
|
|
192
|
+
const target = parseOpaqueColor(raw);
|
|
193
|
+
if (!target) return null;
|
|
194
|
+
|
|
195
|
+
let best: TokenDefinitionFact | null = null;
|
|
196
|
+
let bestDistance = Infinity;
|
|
197
|
+
for (const token of tokens) {
|
|
198
|
+
if (typeof token.value !== "string") continue;
|
|
199
|
+
const candidate = parseOpaqueColor(token.value);
|
|
200
|
+
if (!candidate) continue;
|
|
201
|
+
const distance = colorDistance(target, candidate);
|
|
202
|
+
// Strict `<` plus stable iteration keeps the pick deterministic on ties.
|
|
203
|
+
if (distance < bestDistance) {
|
|
204
|
+
best = token;
|
|
205
|
+
bestDistance = distance;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return bestDistance <= NEAREST_COLOR_MAX_DISTANCE ? best : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Find a color token whose value matches the offending raw color exactly.
|
|
213
|
+
* Comparison is normalized — short hex (`#abc`) is expanded to full hex
|
|
214
|
+
* (`#aabbcc`) so `#fff` and `#ffffff` collapse to the same key.
|
|
215
|
+
*
|
|
216
|
+
* When multiple tokens share the value (e.g. `--color-bg` and `--color-on-x`
|
|
217
|
+
* both `#ffffff`), prefer the one whose name matches the declaration's semantic
|
|
218
|
+
* role (`color:` → foreground, `background:` → surface, `border:` → border) so
|
|
219
|
+
* a foreground color is never auto-rewritten to a background token. If no token
|
|
220
|
+
* matches the role, the swap is flagged ambiguous and emitted as a non-applied
|
|
221
|
+
* suggestion rather than a deterministic fix.
|
|
222
|
+
*/
|
|
223
|
+
function findTokenMatch(
|
|
224
|
+
property: string,
|
|
225
|
+
raw: string,
|
|
226
|
+
tokens: readonly TokenDefinitionFact[]
|
|
227
|
+
): TokenMatch | null {
|
|
228
|
+
const target = normalizeColor(raw);
|
|
229
|
+
const matches = tokens.filter((token) => normalizeColor(token.value) === target);
|
|
230
|
+
if (matches.length === 0) return null;
|
|
231
|
+
|
|
232
|
+
const role = colorRoleForProperty(property);
|
|
233
|
+
|
|
234
|
+
if (matches.length === 1) {
|
|
235
|
+
// A single value-match is safe to apply automatically *unless* the token's
|
|
236
|
+
// own name signals a role that contradicts the property — e.g. a background
|
|
237
|
+
// token (`--color-bg`) swapped into `color:` would make text track the page
|
|
238
|
+
// surface and disappear on dark themes. In that case suggest, never apply.
|
|
239
|
+
const conflicts = role ? tokenRoleConflicts(matches[0].name, role) : false;
|
|
240
|
+
return { token: matches[0], ambiguous: conflicts };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const roleMatch = role
|
|
244
|
+
? matches.find((token) => tokenNameMatchesRole(token.name, role))
|
|
245
|
+
: undefined;
|
|
246
|
+
if (roleMatch) return { token: roleMatch, ambiguous: false };
|
|
247
|
+
return { token: matches[0], ambiguous: true };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* True when a token's name matches a color role *other than* the property's and
|
|
252
|
+
* not the property's own role — applying it would cross semantic roles (the
|
|
253
|
+
* white-text → background-token trap). Role-neutral names (e.g.
|
|
254
|
+
* `--color-brand-500`) never conflict, so a brand color used as text still gets
|
|
255
|
+
* a deterministic swap.
|
|
256
|
+
*/
|
|
257
|
+
function tokenRoleConflicts(name: string, role: ColorRole): boolean {
|
|
258
|
+
if (tokenNameMatchesRole(name, role)) return false;
|
|
259
|
+
const others: ColorRole[] = (["fg", "bg", "border"] as ColorRole[]).filter((r) => r !== role);
|
|
260
|
+
return others.some((other) => tokenNameMatchesRole(name, other));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type ColorRole = "fg" | "bg" | "border";
|
|
264
|
+
|
|
265
|
+
function colorRoleForProperty(property: string): ColorRole | null {
|
|
266
|
+
const p = property.toLowerCase();
|
|
267
|
+
if (p === "color" || p === "fill" || p === "stroke" || p.endsWith("text-color")) return "fg";
|
|
268
|
+
if (p === "background" || p.startsWith("background") || p === "fill") return "bg";
|
|
269
|
+
if (p.startsWith("border") || p.startsWith("outline") || p === "box-shadow") return "border";
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function tokenNameMatchesRole(name: string, role: ColorRole): boolean {
|
|
274
|
+
const n = name.toLowerCase();
|
|
275
|
+
if (role === "fg")
|
|
276
|
+
return /\b(text|fg|foreground|content|ink|on-|label)\b|text|foreground/.test(n);
|
|
277
|
+
if (role === "bg")
|
|
278
|
+
return /\b(bg|background|surface|fill|elevated|canvas)\b|background|surface/.test(n);
|
|
279
|
+
return /\b(border|outline|stroke|divider|ring)\b|border|outline/.test(n);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeColor(value: string): string {
|
|
283
|
+
const lower = value.trim().toLowerCase();
|
|
284
|
+
const hex = /^#([0-9a-f]{3})$/.exec(lower);
|
|
285
|
+
if (hex) {
|
|
286
|
+
const [r, g, b] = hex[1];
|
|
287
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
288
|
+
}
|
|
289
|
+
const hex4 = /^#([0-9a-f]{4})$/.exec(lower);
|
|
290
|
+
if (hex4) {
|
|
291
|
+
const [r, g, b, a] = hex4[1];
|
|
292
|
+
return `#${r}${r}${g}${g}${b}${b}${a}${a}`;
|
|
293
|
+
}
|
|
294
|
+
return lower;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildTokenFix(
|
|
298
|
+
property: string,
|
|
299
|
+
rawValue: string,
|
|
300
|
+
rawColor: string,
|
|
301
|
+
match: TokenMatch
|
|
302
|
+
): FindingReplaceStyleValueFix {
|
|
303
|
+
const reference = tokenReference(match.token.name);
|
|
304
|
+
const value = replaceRawColor(rawValue, rawColor, reference);
|
|
305
|
+
return {
|
|
306
|
+
kind: "replaceStyleValue",
|
|
307
|
+
title: `Replace ${property} with ${value}`,
|
|
308
|
+
property,
|
|
309
|
+
value,
|
|
310
|
+
// Apply automatically only when the swap is unambiguous AND the reference
|
|
311
|
+
// resolves verbatim. An ambiguous match (several tokens share the value,
|
|
312
|
+
// none role-matching) or a token that can't be referenced from CSS (a Sass
|
|
313
|
+
// *map* member, or a DTCG JSON token with no emitted custom property) is a
|
|
314
|
+
// suggestion — applying it would cross roles or write non-compiling code.
|
|
315
|
+
deterministic: !match.ambiguous && isApplicableTokenReference(match.token.referenceFormat),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function replaceRawColor(rawValue: string, rawColor: string, replacement: string): string {
|
|
320
|
+
if (rawValue === rawColor) return replacement;
|
|
321
|
+
if (rawValue.includes(rawColor)) return rawValue.replace(rawColor, replacement);
|
|
322
|
+
|
|
323
|
+
const pattern = new RegExp(escapeRegExp(rawColor), "i");
|
|
324
|
+
return rawValue.replace(pattern, replacement);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function escapeRegExp(value: string): string {
|
|
328
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
329
|
+
}
|