@usefragments/core 1.3.0 → 1.4.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.
@@ -37,6 +37,12 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
37
37
  const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
38
38
  const findings: Finding[] = [];
39
39
 
40
+ // Newly-broadened detections (SVG paint attributes, runtime-constructed
41
+ // colors) ship advisory this release so CI gates do not harden on findings
42
+ // teams have not yet had a chance to triage. Existing passes keep the
43
+ // configured severity.
44
+ const advisorySeverity = policy.severity === "error" ? "warn" : policy.severity;
45
+
40
46
  for (const decl of ix.byKind("style_declaration")) {
41
47
  if (decl.property.startsWith("--") && decl.declaredTokenSource === true) continue;
42
48
  const color = detectRawColor(decl.value);
@@ -84,6 +90,43 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
84
90
 
85
91
  const componentByNode = indexComponentByNodeId(ix);
86
92
  for (const inline of ix.byKind("usage_inline_style")) {
93
+ // #11b — a runtime-constructed color (`` `#${red}` ``, `"rgb(" + r + …`).
94
+ // The extractor already proved the static fragments begin a color literal,
95
+ // so surface it detect-only: the value is partly runtime, so it never
96
+ // carries a deterministic fix and cannot resolve to a token.
97
+ if (inline.valueKind === "dynamic-raw") {
98
+ const node = readUsageNode(ix, inline.nodeId);
99
+ if (!node) continue;
100
+ const componentEvidenceId = componentByNode.get(node.id)?.id;
101
+ const evidenceIds = componentEvidenceId
102
+ ? [node.id, componentEvidenceId, inline.id, policy.id]
103
+ : [node.id, inline.id, policy.id];
104
+ findings.push(
105
+ makeFinding({
106
+ ruleId: RULE_ID,
107
+ ruleVersion: RULE_VERSION,
108
+ severity: advisorySeverity,
109
+ message: `Runtime-constructed raw color \`${inline.value}\` on inline \`${inline.property}\`. Use a ${preferLabel} instead.`,
110
+ location: node.location,
111
+ evidence: ix.evidence(evidenceIds),
112
+ fingerprintIdentity: {
113
+ source: "usage_inline_style_dynamic",
114
+ file: node.file,
115
+ nodePath: node.nodePath,
116
+ element: node.element,
117
+ property: inline.property,
118
+ value: inline.value,
119
+ },
120
+ attributes: {
121
+ property: inline.property,
122
+ rawValue: inline.value,
123
+ source: "jsx",
124
+ dynamic: true,
125
+ },
126
+ })
127
+ );
128
+ continue;
129
+ }
87
130
  if (inline.valueKind === "css-variable") continue;
88
131
  const color = detectRawColor(inline.value);
89
132
  if (!color) continue;
@@ -133,9 +176,91 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
133
176
  );
134
177
  }
135
178
 
179
+ // #9d — SVG presentation attributes carry color in JSX markup, not CSS, so
180
+ // they arrive as `usage_prop_resolved` facts the two passes above never read.
181
+ // Scan a fixed paint-attribute allowlist for static raw colors. Data-URI
182
+ // colors (hex inside an encoded `url("data:…")`) are intentionally left out.
183
+ for (const prop of ix.byKind("usage_prop_resolved")) {
184
+ if (prop.resolution !== "static") continue;
185
+ if (typeof prop.value !== "string") continue;
186
+ if (!SVG_PAINT_ATTRS.has(prop.prop)) continue;
187
+ const color = detectRawColor(prop.value);
188
+ if (!color) continue;
189
+ if (isExemptColor(color, policy.except)) continue;
190
+
191
+ const node = readUsageNode(ix, prop.nodeId);
192
+ if (!node) continue;
193
+
194
+ const componentEvidenceId = componentByNode.get(node.id)?.id;
195
+ const cssProperty = SVG_PAINT_CSS_PROPERTY[prop.prop] ?? prop.prop;
196
+ const resolution = resolveColorToken(cssProperty, color, colorTokens);
197
+ const fixEmission =
198
+ resolution?.kind === "exact"
199
+ ? buildTokenFix(cssProperty, prop.value, color, resolution, ix)
200
+ : undefined;
201
+ const baseEvidence = componentEvidenceId
202
+ ? [node.id, componentEvidenceId, prop.id, policy.id]
203
+ : [node.id, prop.id, policy.id];
204
+ const evidenceIds = resolution ? [...baseEvidence, resolution.token.id] : baseEvidence;
205
+
206
+ findings.push(
207
+ makeFinding({
208
+ ruleId: RULE_ID,
209
+ ruleVersion: RULE_VERSION,
210
+ severity: advisorySeverity,
211
+ message: colorMessage(color, `\`${prop.prop}\``, resolution, preferLabel),
212
+ location: prop.location ?? node.location,
213
+ evidence: ix.evidence(evidenceIds),
214
+ fingerprintIdentity: {
215
+ source: "usage_prop_resolved",
216
+ file: node.file,
217
+ nodePath: node.nodePath,
218
+ element: node.element,
219
+ property: prop.prop,
220
+ value: color,
221
+ },
222
+ fix: fixEmission?.fix,
223
+ attributes: {
224
+ property: prop.prop,
225
+ rawValue: prop.value,
226
+ color,
227
+ source: "jsx",
228
+ suggestedToken: resolution?.token.name,
229
+ ...(resolution ? { tokenMatch: resolution.kind } : {}),
230
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
231
+ },
232
+ })
233
+ );
234
+ }
235
+
136
236
  return findings;
137
237
  }
138
238
 
239
+ /**
240
+ * SVG presentation attributes that carry a paint (color) value in JSX markup.
241
+ * Both the raw-SVG kebab spelling and React's camelCase normalization are
242
+ * listed so `stop-color` and `stopColor` both resolve.
243
+ */
244
+ const SVG_PAINT_ATTRS = new Set([
245
+ "fill",
246
+ "stroke",
247
+ "color",
248
+ "stop-color",
249
+ "stopColor",
250
+ "flood-color",
251
+ "floodColor",
252
+ "lighting-color",
253
+ "lightingColor",
254
+ ]);
255
+
256
+ /** Map camelCase paint attributes to their CSS property name so the token
257
+ * role-resolver (`colorRoleForProperty`) reasons over a canonical spelling. */
258
+ const SVG_PAINT_CSS_PROPERTY: Record<string, string> = {
259
+ stopColor: "stop-color",
260
+ floodColor: "flood-color",
261
+ lightingColor: "lighting-color",
262
+ };
263
+
139
264
  interface TokenMatch {
140
265
  token: TokenDefinitionFact;
141
266
  /** True when several tokens share the value and none matches the property's
package/src/schema.ts CHANGED
@@ -244,6 +244,10 @@ const tokenConfigSchema = z
244
244
  .object({
245
245
  include: repoRelativePathsSchema.optional(),
246
246
  sources: z.array(tokenSourceSchema).min(1).optional(),
247
+ // Declared design-system packages whose shipped fragments.json token
248
+ // vocabulary seeds the known-token set (#7c). A packages-only tokens block
249
+ // is valid — see the superRefine below.
250
+ packages: z.array(z.string().min(1)).optional(),
247
251
  exclude: z.array(repoRelativePathSchema).optional(),
248
252
  themeSelectors: z.record(z.string()).optional(),
249
253
  enabled: z.boolean().optional(),
@@ -252,10 +256,10 @@ const tokenConfigSchema = z
252
256
  })
253
257
  .passthrough()
254
258
  .superRefine((tokens, ctx) => {
255
- if (!tokens.include?.length && !tokens.sources?.length) {
259
+ if (!tokens.include?.length && !tokens.sources?.length && !tokens.packages?.length) {
256
260
  ctx.addIssue({
257
261
  code: z.ZodIssueCode.custom,
258
- message: "Add tokens.include or tokens.sources",
262
+ message: "Add tokens.include, tokens.sources, or tokens.packages",
259
263
  path: ["sources"],
260
264
  });
261
265
  }
@@ -193,6 +193,18 @@ export interface TokenConfig {
193
193
  */
194
194
  exclude?: string[];
195
195
 
196
+ /**
197
+ * npm package names whose SHIPPED `fragments.json` token vocabulary should seed
198
+ * the known-token set (e.g. `["@usefragments/ui"]`). This is a DECLARED-manifest
199
+ * ingest — the parser reads each package's published `fragments.json` (via its
200
+ * `<pkg>/fragments.json` export or its package.json `fragments` field) and
201
+ * merges the token NAMES into the vocabulary consumed by
202
+ * `tokens/css-vars-must-be-defined`, so a consumer using those `--fui-*` vars is
203
+ * not flagged as off-contract drift. node_modules SCSS is NEVER globbed — only
204
+ * the declared manifest is trusted, keeping the no-inference mandate intact.
205
+ */
206
+ packages?: string[];
207
+
196
208
  /**
197
209
  * Map CSS selectors to theme names
198
210
  * @example { ":root": "default", "[data-theme='dark']": "dark" }
@@ -96,7 +96,48 @@ export function inferTokenGroup(name: string): string {
96
96
  }
97
97
  }
98
98
 
99
- export function normalizeTokenGroupComment(comment: string): string {
99
+ /**
100
+ * Curated section-comment → category map. ONLY these headings (and short, clean
101
+ * uncurated headings) become token categories. A prose comment latched as a
102
+ * category is the #7d smell (`the-@supports-block-below-…` etc.), so anything
103
+ * that is not curated AND does not read like a heading returns `null`, letting
104
+ * the caller fall back to name-based `inferTokenGroup`.
105
+ */
106
+ const TOKEN_GROUP_COMMENT_MAPPINGS: Record<string, string> = {
107
+ "base configuration": "base",
108
+ typography: "typography",
109
+ "spacing (micro)": "spacing",
110
+ spacing: "spacing",
111
+ "density padding": "spacing",
112
+ "border radius": "radius",
113
+ transitions: "transitions",
114
+ colors: "colors",
115
+ surfaces: "surfaces",
116
+ text: "text",
117
+ borders: "borders",
118
+ shadows: "shadows",
119
+ focus: "focus",
120
+ scrollbar: "scrollbar",
121
+ "component heights": "component-sizing",
122
+ "appshell layout": "layout",
123
+ codeblock: "code",
124
+ tooltip: "tooltip",
125
+ "hero/marketing gradient": "marketing",
126
+ };
127
+
128
+ /**
129
+ * A "clean heading" is alphanumerics + spaces + hyphens only (no punctuation,
130
+ * symbols, em/en dashes, parens, `@`, `/`, etc.) and at most three words. Prose
131
+ * comments — which carry punctuation and/or run long — fail this and are NOT
132
+ * treated as categories.
133
+ */
134
+ function looksLikeCleanHeading(text: string): boolean {
135
+ if (!/^[a-z0-9][a-z0-9 -]*$/.test(text)) return false;
136
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
137
+ return wordCount > 0 && wordCount <= 3;
138
+ }
139
+
140
+ export function normalizeTokenGroupComment(comment: string): string | null {
100
141
  const text = comment
101
142
  .trim()
102
143
  .replace(/^\/\/\s*/, "")
@@ -105,27 +146,16 @@ export function normalizeTokenGroupComment(comment: string): string {
105
146
  .trim()
106
147
  .toLowerCase();
107
148
 
108
- const mappings: Record<string, string> = {
109
- "base configuration": "base",
110
- typography: "typography",
111
- "spacing (micro)": "spacing",
112
- spacing: "spacing",
113
- "density padding": "spacing",
114
- "border radius": "radius",
115
- transitions: "transitions",
116
- colors: "colors",
117
- surfaces: "surfaces",
118
- text: "text",
119
- borders: "borders",
120
- shadows: "shadows",
121
- focus: "focus",
122
- scrollbar: "scrollbar",
123
- "component heights": "component-sizing",
124
- "appshell layout": "layout",
125
- codeblock: "code",
126
- tooltip: "tooltip",
127
- "hero/marketing gradient": "marketing",
128
- };
129
-
130
- return mappings[text] ?? text.replace(/\s+/g, "-");
149
+ if (text in TOKEN_GROUP_COMMENT_MAPPINGS) {
150
+ return TOKEN_GROUP_COMMENT_MAPPINGS[text];
151
+ }
152
+
153
+ // Uncurated: only accept short, clean headings as categories. Prose comments
154
+ // (punctuation/symbols or >3 words) return null so the token falls through to
155
+ // name-based inference instead of minting a garbage category slug (#7d).
156
+ if (!looksLikeCleanHeading(text)) {
157
+ return null;
158
+ }
159
+
160
+ return text.replace(/\s+/g, "-");
131
161
  }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { normalizeTokenGroupComment } from "./categories.js";
4
+ import { parseScssTokens } from "./scss.js";
5
+
6
+ /**
7
+ * FRICTION #7d — stop prose SCSS comments from being slugified into token
8
+ * categories (the `the-@supports-block-below-…` / `colors-—-accent-(…)` smell in
9
+ * ui/fragments.json). Prose comments must fall through to name-based inference;
10
+ * curated section headings still map.
11
+ */
12
+
13
+ describe("normalizeTokenGroupComment (#7d)", () => {
14
+ it("maps curated section headings", () => {
15
+ expect(normalizeTokenGroupComment("// Typography")).toBe("typography");
16
+ expect(normalizeTokenGroupComment("// Spacing")).toBe("spacing");
17
+ expect(normalizeTokenGroupComment("// Spacing (micro)")).toBe("spacing");
18
+ expect(normalizeTokenGroupComment("// Component heights")).toBe("component-sizing");
19
+ });
20
+
21
+ it("returns null for prose comments (punctuation / symbols / long)", () => {
22
+ expect(
23
+ normalizeTokenGroupComment("// The @supports block below derives accent tokens")
24
+ ).toBeNull();
25
+ expect(
26
+ normalizeTokenGroupComment("// Colors — accent (light/dark for automatic dark mode)")
27
+ ).toBeNull();
28
+ expect(normalizeTokenGroupComment("// Shadows (not <color> type)")).toBeNull();
29
+ expect(
30
+ normalizeTokenGroupComment("// Touch targets (density-responsive, control floor: 32px)")
31
+ ).toBeNull();
32
+ });
33
+
34
+ it("slugifies a short, clean uncurated heading", () => {
35
+ expect(normalizeTokenGroupComment("// Custom Section")).toBe("custom-section");
36
+ });
37
+ });
38
+
39
+ describe("parseScssTokens category derivation (#7d)", () => {
40
+ it("does not create a category from a prose comment; tokens fall to name inference", () => {
41
+ const scss = `
42
+ :root {
43
+ // The @supports block below derives accent/semantic tokens from these seeds
44
+ --fui-color-accent: #39594d;
45
+ --fui-space-3: 12px;
46
+ }
47
+ `;
48
+ const out = parseScssTokens(scss);
49
+ const categoryKeys = Object.keys(out.categories);
50
+
51
+ // No garbage slug leaked in.
52
+ for (const key of categoryKeys) {
53
+ expect(key).not.toMatch(/[@(<>—/]/);
54
+ }
55
+ expect(categoryKeys).not.toContain(
56
+ "the-@supports-block-below-derives-accent/semantic-tokens-from-these-seeds"
57
+ );
58
+
59
+ // Tokens are categorized by NAME inference instead.
60
+ const accent = out.categories.colors?.find((t) => t.name === "--fui-color-accent");
61
+ expect(accent).toBeDefined();
62
+ const space = out.categories.spacing?.find((t) => t.name === "--fui-space-3");
63
+ expect(space).toBeDefined();
64
+ });
65
+
66
+ it("still honors curated section headings", () => {
67
+ const scss = `
68
+ :root {
69
+ // Typography
70
+ --fui-font-size-md: 1rem;
71
+ // Spacing
72
+ --fui-space-1: 4px;
73
+ }
74
+ `;
75
+ const out = parseScssTokens(scss);
76
+ expect(out.categories.typography?.some((t) => t.name === "--fui-font-size-md")).toBe(true);
77
+ expect(out.categories.spacing?.some((t) => t.name === "--fui-space-1")).toBe(true);
78
+ });
79
+
80
+ it("resets to name inference after a prose comment interrupts a curated section", () => {
81
+ const scss = `
82
+ :root {
83
+ // Typography
84
+ --fui-font-size-md: 1rem;
85
+ // This is an explanatory note about the following block, not a heading!
86
+ --fui-color-accent: #39594d;
87
+ }
88
+ `;
89
+ const out = parseScssTokens(scss);
90
+ // The prose comment must NOT latch "typography" onto the color token.
91
+ expect(out.categories.typography?.some((t) => t.name === "--fui-color-accent")).not.toBe(true);
92
+ expect(out.categories.colors?.some((t) => t.name === "--fui-color-accent")).toBe(true);
93
+ });
94
+ });
@@ -87,7 +87,11 @@ export function parseScssTokens(content: string, filePath = "tokens.scss"): Toke
87
87
  const tokens: ParsedToken[] = [];
88
88
  const seenNames = new Set<string>();
89
89
  let currentCategory = "other";
90
- let hasCommentCategories = false;
90
+ // Tracks whether the CURRENT section comment yielded a curated category. A
91
+ // prose comment (normalizeTokenGroupComment → null) resets this so subsequent
92
+ // tokens fall through to name-based inference instead of inheriting a stale or
93
+ // garbage comment-derived category (#7d).
94
+ let currentCategoryIsCurated = false;
91
95
 
92
96
  const scssVars = parseScssVariables(content);
93
97
  const varDeclRegex = /^\s*(--[\w-]+)\s*:\s*(.+?)\s*;/;
@@ -99,7 +103,10 @@ export function parseScssTokens(content: string, filePath = "tokens.scss"): Toke
99
103
  const normalized = normalizeTokenGroupComment(commentMatch[0]);
100
104
  if (normalized) {
101
105
  currentCategory = normalized;
102
- hasCommentCategories = true;
106
+ currentCategoryIsCurated = true;
107
+ } else {
108
+ // Prose comment: not a category. Drop back to per-token name inference.
109
+ currentCategoryIsCurated = false;
103
110
  }
104
111
  continue;
105
112
  }
@@ -119,7 +126,7 @@ export function parseScssTokens(content: string, filePath = "tokens.scss"): Toke
119
126
  tokens.push({
120
127
  name,
121
128
  value: cleanValue || undefined,
122
- category: hasCommentCategories ? currentCategory : inferTokenGroup(name),
129
+ category: currentCategoryIsCurated ? currentCategory : inferTokenGroup(name),
123
130
  description,
124
131
  });
125
132
  }
@@ -131,7 +138,7 @@ export function parseScssTokens(content: string, filePath = "tokens.scss"): Toke
131
138
  tokens.push({
132
139
  name,
133
140
  value,
134
- category: hasCommentCategories ? currentCategory : inferTokenGroup(name),
141
+ category: currentCategoryIsCurated ? currentCategory : inferTokenGroup(name),
135
142
  });
136
143
  }
137
144
 
package/src/types.ts CHANGED
@@ -581,6 +581,17 @@ export interface TokenConfig {
581
581
  /** Repo-root-relative token source files/globs for monorepos and Cloud setup. */
582
582
  sources?: TokenSourceConfig[];
583
583
 
584
+ /**
585
+ * npm package names whose shipped `fragments.json` token vocabulary seeds the
586
+ * known-token set (e.g. `["@usefragments/ui"]`). DECLARED-manifest ingest —
587
+ * the parser reads each package's published `fragments.json` and merges its
588
+ * token names into the vocabulary `tokens/css-vars-must-be-defined` consumes,
589
+ * so a consumer using those `--fui-*` vars is not flagged as drift. Never
590
+ * globs node_modules SCSS — only the declared manifest is trusted, keeping the
591
+ * no-inference mandate intact.
592
+ */
593
+ packages?: string[];
594
+
584
595
  /**
585
596
  * Glob patterns to exclude
586
597
  * @example ["node_modules"]