@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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `styles/no-raw-dimensions` — fires when a length-bearing CSS declaration or
|
|
3
|
+
* JSX inline style uses a raw dimension literal (`16px`, `1rem`, …) on a
|
|
4
|
+
* property covered by the global `style.rawDimensions.forbid` policy.
|
|
5
|
+
*
|
|
6
|
+
* Suggestion strength is tiered, mirroring `styles/no-raw-spacing`:
|
|
7
|
+
*
|
|
8
|
+
* - **exact token match** — the raw value EXACTLY equals a tenant dimension
|
|
9
|
+
* token of the same unit (units are never cross-converted — matching `1rem`
|
|
10
|
+
* to a `16px` token would assume a root font size we cannot guarantee). Emits
|
|
11
|
+
* a value-preserving, deterministic `var(--token)` fix.
|
|
12
|
+
* - **off-scale single value** — `border-radius: 10px` when the tenant has no
|
|
13
|
+
* `10px` token. Names the NEAREST token of the same unit via `suggestedToken`
|
|
14
|
+
* and the message, but attaches NO fix — snapping changes the rendered value,
|
|
15
|
+
* so it must stay a suggestion the author confirms, never an auto-rewrite.
|
|
16
|
+
* Same-unit only: a `px` value never snaps to a `rem` token, keeping the
|
|
17
|
+
* suggestion free of an assumed root-font-size.
|
|
18
|
+
* - **no nearby token** (or a multi-value shorthand off-scale) — flagged with no
|
|
19
|
+
* fix and no suggestion, leaving the choice to the author.
|
|
20
|
+
*
|
|
21
|
+
* Nearest snapping adds NO new findings and changes NO rendered values: an
|
|
22
|
+
* off-scale dimension already fires under this policy; the snap only enriches
|
|
23
|
+
* that finding with a `suggestedToken` hint.
|
|
24
|
+
*
|
|
25
|
+
* The policy is intentionally opt-in (no preset enables it): a plain
|
|
26
|
+
* `fragments govern scan` never flags raw dimensions. Only callers that supply
|
|
27
|
+
* a `style.rawDimensions.forbid` record (e.g. the conform tool) activate it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { FactId, FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
31
|
+
|
|
32
|
+
import { makeFinding } from "./finding.js";
|
|
33
|
+
import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
|
|
34
|
+
import { indexComponentByNodeId, parseLengthValue, readUsageNode } from "./utils.js";
|
|
35
|
+
|
|
36
|
+
export const RULE_ID = "styles/no-raw-dimensions";
|
|
37
|
+
export const RULE_VERSION = "1";
|
|
38
|
+
|
|
39
|
+
interface DimensionFixMatch {
|
|
40
|
+
tokens: TokenDefinitionFact[];
|
|
41
|
+
fixValue: string;
|
|
42
|
+
suggestedToken: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How a raw dimension resolves against the tenant's tokens:
|
|
47
|
+
* - `exact` — value-preserving token swap (deterministic, auto-applicable fix)
|
|
48
|
+
* - `nearest` — closest same-unit token, surfaced as a `suggestedToken` hint with
|
|
49
|
+
* NO fix object: snapping changes the rendered value, and the conform engine
|
|
50
|
+
* auto-applies any token-backed `replaceStyleValue` fix, so attaching one would
|
|
51
|
+
* silently rewrite the value. The suggestion rides on the message + attribute.
|
|
52
|
+
* - `raw` — flagged, but no token to suggest (no fix)
|
|
53
|
+
*/
|
|
54
|
+
type DimensionResolution =
|
|
55
|
+
| { kind: "exact"; tokens: TokenDefinitionFact[]; fixValue: string; suggestedToken: string }
|
|
56
|
+
| { kind: "nearest"; token: TokenDefinitionFact; suggestedToken: string }
|
|
57
|
+
| { kind: "raw" };
|
|
58
|
+
|
|
59
|
+
export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
|
|
60
|
+
const policy = ix.policy.rawDimensionPolicy();
|
|
61
|
+
if (!policy) return [];
|
|
62
|
+
|
|
63
|
+
const appliesTo = new Set(policy.appliesTo);
|
|
64
|
+
const spacingTokens = ix.tokens.byCategory("spacing");
|
|
65
|
+
const radiusTokens = ix.tokens.byCategory("radius");
|
|
66
|
+
const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
|
|
67
|
+
const findings: Finding[] = [];
|
|
68
|
+
|
|
69
|
+
for (const decl of ix.byKind("style_declaration")) {
|
|
70
|
+
const property = toKebabCase(decl.property);
|
|
71
|
+
if (!appliesTo.has(property)) continue;
|
|
72
|
+
|
|
73
|
+
const tokens = tokensForProperty(property, spacingTokens, radiusTokens);
|
|
74
|
+
const resolution = resolveDimension(decl.value, tokens);
|
|
75
|
+
if (!resolution) continue;
|
|
76
|
+
const tokenIds = resolutionTokenIds(resolution);
|
|
77
|
+
const evidenceIds = tokenIds.length ? [decl.id, policy.id, ...tokenIds] : [decl.id, policy.id];
|
|
78
|
+
|
|
79
|
+
findings.push(
|
|
80
|
+
makeFinding({
|
|
81
|
+
ruleId: RULE_ID,
|
|
82
|
+
ruleVersion: RULE_VERSION,
|
|
83
|
+
severity: policy.severity,
|
|
84
|
+
message: dimensionMessage(decl.value, decl.property, preferLabel, resolution, false),
|
|
85
|
+
location: decl.location,
|
|
86
|
+
evidence: ix.evidence(evidenceIds),
|
|
87
|
+
fingerprintIdentity: {
|
|
88
|
+
source: "style_declaration",
|
|
89
|
+
file: decl.file,
|
|
90
|
+
selector: decl.selector,
|
|
91
|
+
declarationPath: decl.declarationPath,
|
|
92
|
+
property: decl.property,
|
|
93
|
+
value: decl.value,
|
|
94
|
+
},
|
|
95
|
+
fix: dimensionFix(decl.property, resolution),
|
|
96
|
+
attributes: {
|
|
97
|
+
property: decl.property,
|
|
98
|
+
rawValue: decl.value,
|
|
99
|
+
source: "css",
|
|
100
|
+
suggestedToken: resolutionSuggestedToken(resolution),
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const componentByNode = indexComponentByNodeId(ix);
|
|
107
|
+
for (const inline of ix.byKind("usage_inline_style")) {
|
|
108
|
+
if (inline.valueKind === "css-variable") continue;
|
|
109
|
+
const property = toKebabCase(inline.property);
|
|
110
|
+
if (!appliesTo.has(property)) continue;
|
|
111
|
+
|
|
112
|
+
const node = readUsageNode(ix, inline.nodeId);
|
|
113
|
+
if (!node) continue;
|
|
114
|
+
|
|
115
|
+
const componentEvidenceId = componentByNode.get(node.id)?.id;
|
|
116
|
+
const tokens = tokensForProperty(property, spacingTokens, radiusTokens);
|
|
117
|
+
const resolution = resolveDimension(inline.value, tokens);
|
|
118
|
+
if (!resolution) continue;
|
|
119
|
+
const baseEvidence = componentEvidenceId
|
|
120
|
+
? [node.id, componentEvidenceId, inline.id, policy.id]
|
|
121
|
+
: [node.id, inline.id, policy.id];
|
|
122
|
+
const tokenIds = resolutionTokenIds(resolution);
|
|
123
|
+
const evidenceIds = tokenIds.length ? [...baseEvidence, ...tokenIds] : baseEvidence;
|
|
124
|
+
|
|
125
|
+
findings.push(
|
|
126
|
+
makeFinding({
|
|
127
|
+
ruleId: RULE_ID,
|
|
128
|
+
ruleVersion: RULE_VERSION,
|
|
129
|
+
severity: policy.severity,
|
|
130
|
+
message: dimensionMessage(inline.value, inline.property, preferLabel, resolution, true),
|
|
131
|
+
location: node.location,
|
|
132
|
+
evidence: ix.evidence(evidenceIds),
|
|
133
|
+
fingerprintIdentity: {
|
|
134
|
+
source: "usage_inline_style",
|
|
135
|
+
file: node.file,
|
|
136
|
+
nodePath: node.nodePath,
|
|
137
|
+
element: node.element,
|
|
138
|
+
property: inline.property,
|
|
139
|
+
value: inline.value,
|
|
140
|
+
},
|
|
141
|
+
fix: dimensionFix(inline.property, resolution),
|
|
142
|
+
attributes: {
|
|
143
|
+
property: inline.property,
|
|
144
|
+
rawValue: inline.value,
|
|
145
|
+
source: "jsx",
|
|
146
|
+
suggestedToken: resolutionSuggestedToken(resolution),
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return findings;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Normalize a JSX camelCase style key (`paddingTop`) to CSS kebab (`padding-top`). */
|
|
156
|
+
function toKebabCase(property: string): string {
|
|
157
|
+
return property.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function tokensForProperty(
|
|
161
|
+
property: string,
|
|
162
|
+
spacingTokens: readonly TokenDefinitionFact[],
|
|
163
|
+
radiusTokens: readonly TokenDefinitionFact[]
|
|
164
|
+
): readonly TokenDefinitionFact[] {
|
|
165
|
+
return property.includes("radius") ? radiusTokens : spacingTokens;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a raw dimension against the candidate tokens, preferring an exact
|
|
170
|
+
* (value-preserving) match, then a nearest same-unit snap, then no token.
|
|
171
|
+
* Returns `null` when the value is neither a token match nor a raw dimension —
|
|
172
|
+
* i.e. nothing to flag.
|
|
173
|
+
*/
|
|
174
|
+
function resolveDimension(
|
|
175
|
+
raw: string,
|
|
176
|
+
tokens: readonly TokenDefinitionFact[]
|
|
177
|
+
): DimensionResolution | null {
|
|
178
|
+
const exact = findDimensionFixMatch(raw, tokens);
|
|
179
|
+
if (exact) {
|
|
180
|
+
return {
|
|
181
|
+
kind: "exact",
|
|
182
|
+
tokens: exact.tokens,
|
|
183
|
+
fixValue: exact.fixValue,
|
|
184
|
+
suggestedToken: exact.suggestedToken,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (!isRawDimensionValue(raw)) return null;
|
|
188
|
+
|
|
189
|
+
const nearest = findNearestDimensionToken(raw, tokens);
|
|
190
|
+
if (nearest) return { kind: "nearest", ...nearest };
|
|
191
|
+
return { kind: "raw" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolutionTokenIds(resolution: DimensionResolution): FactId[] {
|
|
195
|
+
if (resolution.kind === "exact") return resolution.tokens.map((token) => token.id);
|
|
196
|
+
if (resolution.kind === "nearest") return [resolution.token.id];
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolutionSuggestedToken(resolution: DimensionResolution): string | undefined {
|
|
201
|
+
if (resolution.kind === "exact") return resolution.suggestedToken;
|
|
202
|
+
if (resolution.kind === "nearest") return resolution.suggestedToken;
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function dimensionMessage(
|
|
207
|
+
rawValue: string,
|
|
208
|
+
property: string,
|
|
209
|
+
preferLabel: string,
|
|
210
|
+
resolution: DimensionResolution,
|
|
211
|
+
inline: boolean
|
|
212
|
+
): string {
|
|
213
|
+
const where = inline ? `inline \`${property}\`` : `\`${property}\``;
|
|
214
|
+
if (resolution.kind === "nearest") {
|
|
215
|
+
return `Raw dimension ${rawValue} on ${where}. Closest token is \`${resolution.suggestedToken}\` (${resolution.token.value}); use a ${preferLabel} instead.`;
|
|
216
|
+
}
|
|
217
|
+
return `Raw dimension ${rawValue} on ${where}. Use a ${preferLabel} instead.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function dimensionFix(
|
|
221
|
+
property: string,
|
|
222
|
+
resolution: DimensionResolution
|
|
223
|
+
): FindingReplaceStyleValueFix | undefined {
|
|
224
|
+
if (resolution.kind === "exact") {
|
|
225
|
+
return {
|
|
226
|
+
kind: "replaceStyleValue",
|
|
227
|
+
title: `Replace ${property} with ${resolution.fixValue}`,
|
|
228
|
+
property,
|
|
229
|
+
value: resolution.fixValue,
|
|
230
|
+
deterministic: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// `nearest` deliberately emits NO fix — a value-changing snap must stay a
|
|
234
|
+
// suggestion (see DimensionResolution); only the exact, value-preserving swap
|
|
235
|
+
// is safe to auto-apply.
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Find a dimension token whose value matches the raw literal exactly, compared
|
|
241
|
+
* in canonical px. No fuzzy/nearest matching — only value-preserving swaps earn
|
|
242
|
+
* an exact match.
|
|
243
|
+
*/
|
|
244
|
+
function findDimensionMatch(
|
|
245
|
+
raw: string,
|
|
246
|
+
tokens: readonly TokenDefinitionFact[]
|
|
247
|
+
): TokenDefinitionFact | null {
|
|
248
|
+
const target = normalizeDimension(raw);
|
|
249
|
+
if (target === null) return null;
|
|
250
|
+
for (const token of tokens) {
|
|
251
|
+
if (normalizeDimension(token.value) === target) return token;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function findDimensionFixMatch(
|
|
257
|
+
raw: string,
|
|
258
|
+
tokens: readonly TokenDefinitionFact[]
|
|
259
|
+
): DimensionFixMatch | null {
|
|
260
|
+
const single = findDimensionMatch(raw, tokens);
|
|
261
|
+
if (single) {
|
|
262
|
+
return {
|
|
263
|
+
tokens: [single],
|
|
264
|
+
fixValue: tokenVar(single),
|
|
265
|
+
suggestedToken: single.name,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const parts = raw.trim().split(/\s+/);
|
|
270
|
+
if (parts.length < 2 || parts.length > 4) return null;
|
|
271
|
+
|
|
272
|
+
const matches: TokenDefinitionFact[] = [];
|
|
273
|
+
for (const part of parts) {
|
|
274
|
+
const match = findDimensionMatch(part, tokens);
|
|
275
|
+
if (!match) return null;
|
|
276
|
+
matches.push(match);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
tokens: matches,
|
|
281
|
+
fixValue: matches.map(tokenVar).join(" "),
|
|
282
|
+
suggestedToken: matches.map((token) => token.name).join(" "),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* For an off-scale SINGLE dimension value, find the nearest token of the SAME
|
|
288
|
+
* unit. Same-unit only keeps the suggestion value-safe: matching a `px` value
|
|
289
|
+
* to a `rem` token would assume a root font size we cannot guarantee. A non-zero
|
|
290
|
+
* value never snaps to a `0` token (that silently removes the dimension), and a
|
|
291
|
+
* multi-value shorthand returns null (no single nearest target).
|
|
292
|
+
*/
|
|
293
|
+
function findNearestDimensionToken(
|
|
294
|
+
raw: string,
|
|
295
|
+
tokens: readonly TokenDefinitionFact[]
|
|
296
|
+
): { token: TokenDefinitionFact; suggestedToken: string } | null {
|
|
297
|
+
const parsed = parseLengthValue(raw);
|
|
298
|
+
if (!parsed) return null;
|
|
299
|
+
const rawUnit = parsed.unit ?? "px";
|
|
300
|
+
|
|
301
|
+
let best: { token: TokenDefinitionFact; value: number } | null = null;
|
|
302
|
+
for (const token of tokens) {
|
|
303
|
+
const tokenLength = parseLengthValue(token.value);
|
|
304
|
+
if (!tokenLength) continue;
|
|
305
|
+
const tokenUnit = tokenLength.unit ?? "px";
|
|
306
|
+
if (tokenUnit !== rawUnit) continue;
|
|
307
|
+
if (tokenLength.value === parsed.value) continue; // exact handled elsewhere
|
|
308
|
+
if (tokenLength.value === 0 && parsed.value !== 0) continue; // never snap to 0
|
|
309
|
+
if (best === null) {
|
|
310
|
+
best = { token, value: tokenLength.value };
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const delta = Math.abs(parsed.value - tokenLength.value);
|
|
314
|
+
const bestDelta = Math.abs(parsed.value - best.value);
|
|
315
|
+
// Ties break toward the smaller token value, then first-seen, for stability.
|
|
316
|
+
if (delta < bestDelta || (delta === bestDelta && tokenLength.value < best.value)) {
|
|
317
|
+
best = { token, value: tokenLength.value };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!best) return null;
|
|
322
|
+
return {
|
|
323
|
+
token: best.token,
|
|
324
|
+
suggestedToken: best.token.name,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isRawDimensionValue(raw: string): boolean {
|
|
329
|
+
if (normalizeDimension(raw) !== null) return true;
|
|
330
|
+
const parts = raw.trim().split(/\s+/);
|
|
331
|
+
return (
|
|
332
|
+
parts.length >= 2 &&
|
|
333
|
+
parts.length <= 4 &&
|
|
334
|
+
parts.every((part) => normalizeDimension(part) !== null)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Canonical match key `${amount}${unit}` (unitless treated as px, for React
|
|
340
|
+
* inline numerics and `0`). Units are intentionally NOT cross-converted:
|
|
341
|
+
* matching `1rem` to a `16px` token would assume a 16px root font size we
|
|
342
|
+
* cannot guarantee, so a unit mismatch never matches. This keeps every emitted
|
|
343
|
+
* fix value-preserving.
|
|
344
|
+
*/
|
|
345
|
+
function normalizeDimension(value: string): string | null {
|
|
346
|
+
const parsed = parseLengthValue(value);
|
|
347
|
+
if (!parsed) return null;
|
|
348
|
+
return `${parsed.value}${parsed.unit ?? "px"}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function tokenVar(token: TokenDefinitionFact): string {
|
|
352
|
+
if (token.name.startsWith("$")) return token.name;
|
|
353
|
+
const cssVarName = token.name.startsWith("--")
|
|
354
|
+
? token.name
|
|
355
|
+
: `--${token.name.replace(/\./g, "-")}`;
|
|
356
|
+
return `var(${cssVarName})`;
|
|
357
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `styles/no-raw-spacing` — fires when a length-bearing CSS declaration or
|
|
3
|
+
* JSX inline style on a scaled property uses a raw spacing literal instead of
|
|
4
|
+
* a design token.
|
|
5
|
+
*
|
|
6
|
+
* The rule needs both a `style.property.scale` policy (which property is
|
|
7
|
+
* scaled, and which scale name backs it) and a matching set of `scale_value`
|
|
8
|
+
* facts (the allowed numbers). If either is missing the rule is silent for
|
|
9
|
+
* that property.
|
|
10
|
+
*
|
|
11
|
+
* Two violation shapes, mirroring `styles/no-raw-color`:
|
|
12
|
+
*
|
|
13
|
+
* - **off the scale** — `padding: 13px` on a [0,4,8,12,16] scale. Flagged; the
|
|
14
|
+
* suggested fix snaps to the nearest scale value (as the matching token when
|
|
15
|
+
* one exists, else the raw grid value). Because snapping *changes* the
|
|
16
|
+
* rendered value, the fix is a suggestion (non-deterministic) — never applied
|
|
17
|
+
* silently.
|
|
18
|
+
* - **on the scale but a raw literal that equals a token** — `padding: 16px`
|
|
19
|
+
* where `--space-md` is `16px`. Flagged; the fix swaps in `var(--space-md)`.
|
|
20
|
+
* This is value-preserving, so it is a deterministic, auto-applicable fix.
|
|
21
|
+
* (Only fires when a matching spacing token exists — a pure-grid value with no
|
|
22
|
+
* named token, like `0`, stays silent.)
|
|
23
|
+
*
|
|
24
|
+
* `padding: var(--space-3)` → silent (token reference). `padding: 1.5rem` on a
|
|
25
|
+
* px scale → normalized through the configured root font size before matching.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
FactId,
|
|
30
|
+
FactIndex,
|
|
31
|
+
ScaleFact,
|
|
32
|
+
StyleDeclarationFact,
|
|
33
|
+
UsageComponentFact,
|
|
34
|
+
UsageInlineStyleFact,
|
|
35
|
+
} from "../facts/index.js";
|
|
36
|
+
|
|
37
|
+
import { makeFinding } from "./finding.js";
|
|
38
|
+
import {
|
|
39
|
+
buildSpacingTokenLookup,
|
|
40
|
+
resolveSpacingValue,
|
|
41
|
+
type CheckedSpacingValue,
|
|
42
|
+
type SpacingTokenLookup,
|
|
43
|
+
} from "./spacing-resolution.js";
|
|
44
|
+
import type { Finding } from "./types.js";
|
|
45
|
+
import { indexComponentByNodeId, readUsageNode } from "./utils.js";
|
|
46
|
+
|
|
47
|
+
export const RULE_ID = "styles/no-raw-spacing";
|
|
48
|
+
export const RULE_VERSION = "1";
|
|
49
|
+
|
|
50
|
+
export function ruleStylesNoRawSpacing(ix: FactIndex): Finding[] {
|
|
51
|
+
const findings: Finding[] = [];
|
|
52
|
+
|
|
53
|
+
// Token lookups are scale-relative (token values normalize to the scale's
|
|
54
|
+
// unit), so cache one per scale name to avoid rebuilding per declaration.
|
|
55
|
+
const lookupCache = new Map<string, SpacingTokenLookup>();
|
|
56
|
+
const lookupFor = (scale: ScaleFact): SpacingTokenLookup => {
|
|
57
|
+
let lookup = lookupCache.get(scale.name);
|
|
58
|
+
if (!lookup) {
|
|
59
|
+
lookup = buildSpacingTokenLookup(ix.tokens.byCategory("spacing"), scale);
|
|
60
|
+
lookupCache.set(scale.name, lookup);
|
|
61
|
+
}
|
|
62
|
+
return lookup;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const decl of ix.byKind("style_declaration")) {
|
|
66
|
+
const finding = checkDeclaration(ix, decl, lookupFor);
|
|
67
|
+
if (finding) findings.push(finding);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const componentByNode = indexComponentByNodeId(ix);
|
|
71
|
+
for (const inline of ix.byKind("usage_inline_style")) {
|
|
72
|
+
const finding = checkInlineStyle(ix, inline, componentByNode, lookupFor);
|
|
73
|
+
if (finding) findings.push(finding);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Inline style property names arrive camelCase (`marginTop`); the policy keys
|
|
81
|
+
* spacing scales by CSS property (`margin-top`). Normalize so longhand inline
|
|
82
|
+
* props resolve their scale the same way the CSS file path does.
|
|
83
|
+
*/
|
|
84
|
+
function cssPropertyName(property: string): string {
|
|
85
|
+
return property.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkDeclaration(
|
|
89
|
+
ix: FactIndex,
|
|
90
|
+
decl: StyleDeclarationFact,
|
|
91
|
+
lookupFor: (scale: ScaleFact) => SpacingTokenLookup
|
|
92
|
+
): Finding | null {
|
|
93
|
+
const policy = ix.policy.propertyScale(decl.property);
|
|
94
|
+
if (!policy) return null;
|
|
95
|
+
const scale = ix.policy.scale(policy.scale);
|
|
96
|
+
if (!scale) return null;
|
|
97
|
+
const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
|
|
98
|
+
if (allowed.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
const checked = resolveSpacingValue({
|
|
101
|
+
raw: decl.value,
|
|
102
|
+
allowed,
|
|
103
|
+
scale,
|
|
104
|
+
tokens: lookupFor(scale),
|
|
105
|
+
});
|
|
106
|
+
if (!checked) return null;
|
|
107
|
+
|
|
108
|
+
return makeFinding({
|
|
109
|
+
ruleId: RULE_ID,
|
|
110
|
+
ruleVersion: RULE_VERSION,
|
|
111
|
+
severity: policy.severity,
|
|
112
|
+
message: spacingMessage(decl.property, decl.value, allowed, scale.unit, checked),
|
|
113
|
+
location: decl.location,
|
|
114
|
+
evidence: ix.evidence([decl.id, policy.id, scale.id]),
|
|
115
|
+
fingerprintIdentity: {
|
|
116
|
+
source: "style_declaration",
|
|
117
|
+
file: decl.file,
|
|
118
|
+
selector: decl.selector,
|
|
119
|
+
declarationPath: decl.declarationPath,
|
|
120
|
+
property: decl.property,
|
|
121
|
+
value: decl.value,
|
|
122
|
+
},
|
|
123
|
+
fix: buildSpacingFix(decl.property, checked),
|
|
124
|
+
attributes: {
|
|
125
|
+
property: decl.property,
|
|
126
|
+
rawValue: decl.value,
|
|
127
|
+
scale: scale.name,
|
|
128
|
+
allowed,
|
|
129
|
+
suggestedValue: checked.suggestedValue,
|
|
130
|
+
matchedToken: checked.matchedToken,
|
|
131
|
+
suggestedToken: checked.suggestedToken,
|
|
132
|
+
reason: checked.reason,
|
|
133
|
+
normalizedValue: checked.normalizedValue,
|
|
134
|
+
normalizedUnit: scale.unit,
|
|
135
|
+
assumedRootFontSizePx: checked.assumedRootFontSizePx,
|
|
136
|
+
assumedEmBasePx: checked.assumedEmBasePx,
|
|
137
|
+
source: "css",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkInlineStyle(
|
|
143
|
+
ix: FactIndex,
|
|
144
|
+
inline: UsageInlineStyleFact,
|
|
145
|
+
componentByNode: Map<FactId, UsageComponentFact>,
|
|
146
|
+
lookupFor: (scale: ScaleFact) => SpacingTokenLookup
|
|
147
|
+
): Finding | null {
|
|
148
|
+
const policy = ix.policy.propertyScale(cssPropertyName(inline.property));
|
|
149
|
+
if (!policy) return null;
|
|
150
|
+
const scale = ix.policy.scale(policy.scale);
|
|
151
|
+
if (!scale) return null;
|
|
152
|
+
const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
|
|
153
|
+
if (allowed.length === 0) return null;
|
|
154
|
+
|
|
155
|
+
if (inline.valueKind === "css-variable") return null;
|
|
156
|
+
|
|
157
|
+
const checked = resolveSpacingValue({
|
|
158
|
+
raw: inline.value,
|
|
159
|
+
allowed,
|
|
160
|
+
scale,
|
|
161
|
+
tokens: lookupFor(scale),
|
|
162
|
+
bareNumberFix: inline.valueKind === "number",
|
|
163
|
+
});
|
|
164
|
+
if (!checked) return null;
|
|
165
|
+
|
|
166
|
+
const node = readUsageNode(ix, inline.nodeId);
|
|
167
|
+
if (!node) return null;
|
|
168
|
+
|
|
169
|
+
const componentEvidenceId = componentByNode.get(node.id)?.id;
|
|
170
|
+
const evidenceIds = componentEvidenceId
|
|
171
|
+
? [node.id, componentEvidenceId, inline.id, policy.id, scale.id]
|
|
172
|
+
: [node.id, inline.id, policy.id, scale.id];
|
|
173
|
+
|
|
174
|
+
return makeFinding({
|
|
175
|
+
ruleId: RULE_ID,
|
|
176
|
+
ruleVersion: RULE_VERSION,
|
|
177
|
+
severity: policy.severity,
|
|
178
|
+
message: spacingMessage(inline.property, inline.value, allowed, scale.unit, checked),
|
|
179
|
+
location: node.location,
|
|
180
|
+
evidence: ix.evidence(evidenceIds),
|
|
181
|
+
fingerprintIdentity: {
|
|
182
|
+
source: "usage_inline_style",
|
|
183
|
+
file: node.file,
|
|
184
|
+
nodePath: node.nodePath,
|
|
185
|
+
element: node.element,
|
|
186
|
+
property: inline.property,
|
|
187
|
+
value: inline.value,
|
|
188
|
+
},
|
|
189
|
+
fix: buildSpacingFix(inline.property, checked),
|
|
190
|
+
attributes: {
|
|
191
|
+
property: inline.property,
|
|
192
|
+
rawValue: inline.value,
|
|
193
|
+
scale: scale.name,
|
|
194
|
+
allowed,
|
|
195
|
+
suggestedValue: checked.suggestedValue,
|
|
196
|
+
matchedToken: checked.matchedToken,
|
|
197
|
+
suggestedToken: checked.suggestedToken,
|
|
198
|
+
reason: checked.reason,
|
|
199
|
+
normalizedValue: checked.normalizedValue,
|
|
200
|
+
normalizedUnit: scale.unit,
|
|
201
|
+
assumedRootFontSizePx: checked.assumedRootFontSizePx,
|
|
202
|
+
assumedEmBasePx: checked.assumedEmBasePx,
|
|
203
|
+
source: "jsx",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildSpacingFix(property: string, checked: CheckedSpacingValue) {
|
|
209
|
+
if (!checked.fixEmittable || checked.suggestedValue === undefined) return undefined;
|
|
210
|
+
return {
|
|
211
|
+
kind: "replaceStyleValue" as const,
|
|
212
|
+
title: `Replace ${property} with ${checked.suggestedValue}`,
|
|
213
|
+
property,
|
|
214
|
+
value: checked.suggestedValue,
|
|
215
|
+
deterministic: checked.deterministicFix,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function spacingMessage(
|
|
220
|
+
property: string,
|
|
221
|
+
value: string,
|
|
222
|
+
allowed: number[],
|
|
223
|
+
unit: "px" | "rem",
|
|
224
|
+
checked: CheckedSpacingValue
|
|
225
|
+
): string {
|
|
226
|
+
if (checked.reason === "token-equivalent" && checked.matchedToken) {
|
|
227
|
+
return `\`${property}: ${value}\` is a raw literal that matches token \`${checked.matchedToken}\`. Reference the token instead.`;
|
|
228
|
+
}
|
|
229
|
+
const sample = allowed
|
|
230
|
+
.slice()
|
|
231
|
+
.sort((a, b) => a - b)
|
|
232
|
+
.slice(0, 6);
|
|
233
|
+
const suffix = `${sample.join(unit + ", ")}${unit}`;
|
|
234
|
+
const ellipsis = allowed.length > sample.length ? ", …" : "";
|
|
235
|
+
return `\`${property}: ${value}\` is not on the spacing scale. Allowed: ${suffix}${ellipsis}.`;
|
|
236
|
+
}
|