@usefragments/core 1.2.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.
Files changed (57) hide show
  1. package/dist/{chunk-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
  2. package/dist/chunk-57QDBEHQ.js.map +1 -0
  3. package/dist/{chunk-DBVTSUMT.js → chunk-CBSNXFOD.js} +20 -6
  4. package/dist/chunk-CBSNXFOD.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-DYSirwJu.d.ts → governance-DxdJV6lx.d.ts} +10 -0
  10. package/dist/index-0lmh0Lbo.d.ts +6981 -0
  11. package/dist/index.d.ts +65 -29
  12. package/dist/index.js +397 -127
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +18 -18
  16. package/dist/schemas/index.d.ts +3 -3377
  17. package/dist/schemas/index.js +1 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +75 -3
  21. package/src/agent-format.ts +28 -6
  22. package/src/constants.ts +3 -0
  23. package/src/evidence.ts +27 -0
  24. package/src/facts/builders.ts +3 -1
  25. package/src/facts/fact-index.ts +22 -2
  26. package/src/facts/facts.test.ts +1 -1
  27. package/src/facts/types.ts +16 -1
  28. package/src/index.ts +4 -0
  29. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  30. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  31. package/src/rules/components-prefer-library.test.ts +148 -0
  32. package/src/rules/components-prefer-library.ts +52 -1
  33. package/src/rules/emit-gate.test.ts +16 -0
  34. package/src/rules/emit-gate.ts +6 -3
  35. package/src/rules/finding.ts +3 -0
  36. package/src/rules/rules.test.ts +2 -1
  37. package/src/rules/spacing-resolution.ts +5 -8
  38. package/src/rules/styles-no-raw-color.test.ts +213 -0
  39. package/src/rules/styles-no-raw-color.ts +157 -23
  40. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  41. package/src/rules/styles-no-raw-spacing.ts +33 -11
  42. package/src/rules/styles-no-raw-typography.ts +37 -15
  43. package/src/rules/taxonomy.test.ts +13 -0
  44. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  45. package/src/rules/utils.ts +51 -0
  46. package/src/schema.ts +6 -2
  47. package/src/schemas/index.ts +81 -3
  48. package/src/token-types.ts +18 -0
  49. package/src/tokens/__tests__/source-names.test.ts +42 -0
  50. package/src/tokens/categories.ts +54 -24
  51. package/src/tokens/design-token-parser.test.ts +24 -5
  52. package/src/tokens/design-token-parser.ts +40 -8
  53. package/src/tokens/scss.test.ts +94 -0
  54. package/src/tokens/scss.ts +11 -4
  55. package/src/types.ts +11 -0
  56. package/dist/chunk-BQQTMGLA.js.map +0 -1
  57. package/dist/chunk-DBVTSUMT.js.map +0 -1
@@ -1,8 +1,8 @@
1
- import type { FactIndex } from "../facts/index.js";
1
+ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
2
2
 
3
3
  import { makeFinding } from "./finding.js";
4
4
  import type { Finding } from "./types.js";
5
- import { cssVariableNames } from "./utils.js";
5
+ import { emitFix } from "./utils.js";
6
6
 
7
7
  export const RULE_ID = "tokens/require-dual-fallback";
8
8
  export const RULE_VERSION = "1";
@@ -22,7 +22,7 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
22
22
  const policy = ix.policy.ruleConfig(RULE_ID);
23
23
  if (!policy?.enabled) return [];
24
24
 
25
- const knownTokenVars = cssVariableNames(ix.tokens.list());
25
+ const tokensByCssVariable = indexTokensByCssVariable(ix.tokens.list());
26
26
  const findings: Finding[] = [];
27
27
 
28
28
  for (const decl of ix.byKind("style_declaration")) {
@@ -33,21 +33,43 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
33
33
  const tokenName = match[1];
34
34
  const hasFallback = match[2] !== undefined;
35
35
  if (hasFallback) continue;
36
- if (!knownTokenVars.has(tokenName) && !tokenName.startsWith("--fui-")) continue;
36
+ const token = tokensByCssVariable.get(tokenName);
37
+ if (!token) continue;
37
38
 
38
39
  const rawValue = match[0];
39
40
  const scssVar = tokenName.replace(/^--/, "$");
40
- const replacement = `var(${tokenName}, ${scssVar})`;
41
- const nextValue = decl.value.replace(rawValue, replacement);
41
+ const hasScssVariable = token.referenceFormat === "scss-var";
42
+ const nextValue = hasScssVariable
43
+ ? decl.value.replace(rawValue, `var(${tokenName}, ${scssVar})`)
44
+ : undefined;
45
+ const fixEmission = nextValue
46
+ ? emitFix(
47
+ {
48
+ kind: "replaceStyleValue",
49
+ title: `Add fallback for ${tokenName}`,
50
+ property: decl.property,
51
+ value: nextValue,
52
+ deterministic: true,
53
+ },
54
+ {
55
+ symbols: [tokenName, scssVar],
56
+ editKind: "replaceStyleValue",
57
+ valuePreserving: true,
58
+ },
59
+ ix
60
+ )
61
+ : undefined;
42
62
 
43
63
  findings.push(
44
64
  makeFinding({
45
65
  ruleId: RULE_ID,
46
66
  ruleVersion: RULE_VERSION,
47
67
  severity: policy.severity ?? "warn",
48
- message: `var(${tokenName}) is missing an SCSS fallback. Use var(${tokenName}, ${scssVar}).`,
68
+ message: hasScssVariable
69
+ ? `var(${tokenName}) is missing an SCSS fallback. Use var(${tokenName}, ${scssVar}).`
70
+ : `var(${tokenName}) is missing a fallback. No matching SCSS variable was found in the token source; add a fallback manually or define ${scssVar}.`,
49
71
  location: decl.location,
50
- evidence: ix.evidence([decl.id, policy.id]),
72
+ evidence: ix.evidence([decl.id, token.id, policy.id]),
51
73
  fingerprintIdentity: {
52
74
  file: decl.file,
53
75
  selector: decl.selector,
@@ -55,24 +77,39 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
55
77
  property: decl.property,
56
78
  tokenName,
57
79
  },
58
- fix: {
59
- kind: "replaceStyleValue",
60
- title: `Add fallback for ${tokenName}`,
61
- property: decl.property,
62
- value: nextValue,
63
- deterministic: true,
64
- },
80
+ fix: fixEmission?.fix,
65
81
  attributes: {
66
82
  property: decl.property,
67
83
  rawValue: decl.value,
68
84
  tokenName,
69
- suggestedValue: nextValue,
85
+ ...(nextValue ? { suggestedValue: nextValue } : {}),
86
+ ...(fixEmission?.downgradeReason
87
+ ? { downgradeReason: fixEmission.downgradeReason }
88
+ : {}),
70
89
  source: "css",
71
90
  },
72
- }),
91
+ })
73
92
  );
74
93
  }
75
94
  }
76
95
 
77
96
  return findings;
78
97
  }
98
+
99
+ function indexTokensByCssVariable(
100
+ tokens: readonly TokenDefinitionFact[]
101
+ ): Map<string, TokenDefinitionFact> {
102
+ const indexed = new Map<string, TokenDefinitionFact>();
103
+ for (const token of tokens) {
104
+ const cssVariable = token.name.startsWith("--")
105
+ ? token.name
106
+ : token.name.startsWith("$")
107
+ ? `--${token.name.slice(1)}`
108
+ : `--${token.name}`;
109
+ const existing = indexed.get(cssVariable);
110
+ if (!existing || token.referenceFormat === "scss-var") {
111
+ indexed.set(cssVariable, token);
112
+ }
113
+ }
114
+ return indexed;
115
+ }
@@ -18,6 +18,57 @@ import type {
18
18
  UsagePropResolvedFact,
19
19
  UsageTextChildFact,
20
20
  } from "../facts/index.js";
21
+ import type { FindingFix } from "./types.js";
22
+
23
+ export interface FixProof {
24
+ symbols: readonly string[];
25
+ editKind: FindingFix["kind"];
26
+ valuePreserving: boolean;
27
+ }
28
+
29
+ export interface FixEmission<F extends FindingFix = FindingFix> {
30
+ fix: F;
31
+ downgradeReason?: string;
32
+ }
33
+
34
+ /**
35
+ * Gate token-substitution fixes on explicit proof. Callers attach any returned
36
+ * downgradeReason to the finding attributes so machine consumers can explain
37
+ * why a suggestion was not auto-applicable.
38
+ */
39
+ export function emitFix<F extends FindingFix>(
40
+ fix: F,
41
+ proof: FixProof,
42
+ ix: FactIndex
43
+ ): FixEmission<F> {
44
+ const reasons: string[] = [];
45
+ if (proof.editKind !== fix.kind) reasons.push("edit kind does not match proof");
46
+ if (!proof.valuePreserving) reasons.push("value-preservation proof missing");
47
+ if (proof.symbols.length === 0) reasons.push("token-symbol proof missing");
48
+
49
+ const missingSymbols = proof.symbols.filter((symbol) => !ix.tokens.symbolExists(symbol));
50
+ if (missingSymbols.length > 0) {
51
+ reasons.push(`absent token symbol: ${missingSymbols.join(", ")}`);
52
+ }
53
+
54
+ const inapplicableSymbols = proof.symbols.filter((symbol) => {
55
+ const definition = ix.tokens.definitionForSymbol(symbol);
56
+ return definition !== undefined && !isApplicableTokenReference(definition.referenceFormat);
57
+ });
58
+ if (inapplicableSymbols.length > 0) {
59
+ reasons.push(`token reference is not directly applicable: ${inapplicableSymbols.join(", ")}`);
60
+ }
61
+
62
+ return {
63
+ fix: { ...fix, deterministic: reasons.length === 0 },
64
+ ...(reasons.length > 0 ? { downgradeReason: reasons.join("; ") } : {}),
65
+ };
66
+ }
67
+
68
+ /** Extract authored token symbols from a replacement string, preserving order. */
69
+ export function tokenSymbolsInText(value: string): string[] {
70
+ return [...new Set(value.match(/\$[A-Za-z0-9_-]+|--[A-Za-z0-9_-]+/g) ?? [])];
71
+ }
21
72
 
22
73
  export interface LengthScale {
23
74
  unit: "px" | "rem";
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
  }
@@ -8,6 +8,15 @@ import {
8
8
  severitySchema,
9
9
  } from "../severity.js";
10
10
  import type { LegacySeverityLevel, Severity } from "../severity.js";
11
+ import type { EvidenceGrade } from "../evidence.js";
12
+
13
+ const evidenceGradeSchema: z.ZodType<EvidenceGrade> = z.enum([
14
+ "none",
15
+ "runtime_advisory",
16
+ "provenance_bound",
17
+ "source_backed",
18
+ "receipt_backed",
19
+ ]);
11
20
 
12
21
  export { legacySeverityLevelSchema, severityLevelSchema, severitySchema };
13
22
  export type { LegacySeverityLevel, Severity, SeverityLevel } from "../severity.js";
@@ -108,6 +117,7 @@ export const findingSchema = z.object({
108
117
  fingerprint: z.string(),
109
118
  location: factLocationSchema,
110
119
  evidence: z.array(factEvidenceSchema).min(1),
120
+ evidenceGrade: evidenceGradeSchema.optional(),
111
121
  fix: findingFixSchema.optional(),
112
122
  attributes: z.record(z.unknown()).optional(),
113
123
  /**
@@ -224,6 +234,7 @@ export const agentFindingSchema = z.object({
224
234
  severity: severitySchema,
225
235
  level: severityLevelSchema,
226
236
  message: z.string(),
237
+ evidenceGrade: evidenceGradeSchema.optional(),
227
238
  evidence: z.array(factEvidenceSchema),
228
239
  plan: agentPlanSchema,
229
240
  });
@@ -238,10 +249,34 @@ export const agentIntegritySchema = z.object({
238
249
  reasons: z.array(z.string()),
239
250
  });
240
251
 
241
- export const agentFormatSchema = z.object({
252
+ const agentRunProvenanceSchema = z.object({
253
+ runId: z.string().regex(/^run_[a-f0-9]{12}$/),
254
+ fcid: z.string().min(1),
255
+ baselineId: z.string().min(1).optional(),
256
+ scope: z.object({
257
+ method: z.enum([
258
+ "three-dot",
259
+ "tree-diff",
260
+ "status",
261
+ "fallback-full",
262
+ "explicit",
263
+ "full",
264
+ ]),
265
+ gitOk: z.boolean(),
266
+ }),
267
+ engineVersion: z.string().min(1),
268
+ scopeHash: z.string().regex(/^[a-f0-9]{64}$/),
269
+ inputFilesHash: z.string().regex(/^[a-f0-9]{64}$/),
270
+ });
271
+
272
+ const agentRunVerdictSchema = z.object({
273
+ exitCode: z.union([z.literal(0), z.literal(1)]),
274
+ reasons: z.array(z.string()),
275
+ gatingFindingCount: z.number().int().nonnegative(),
276
+ });
277
+
278
+ const agentFormatBaseSchema = z.object({
242
279
  schemaVersion: z.literal(AGENT_FORMAT_SCHEMA_VERSION),
243
- passed: z.boolean(),
244
- score: z.number(),
245
280
  summary: z.string(),
246
281
  integrity: agentIntegritySchema,
247
282
  fix_first: z.array(agentFindingSchema),
@@ -258,7 +293,45 @@ export const agentFormatSchema = z.object({
258
293
  }),
259
294
  findings: z.array(findingSchema).optional(),
260
295
  suppressions: z.array(suppressionDirectiveSchema).optional(),
296
+ provenance: agentRunProvenanceSchema.optional(),
297
+ verdict: agentRunVerdictSchema.optional(),
261
298
  });
299
+
300
+ export const agentFormatSchema = z.union([
301
+ agentFormatBaseSchema.extend({
302
+ passed: z.boolean(),
303
+ score: z.number(),
304
+ status: z.never().optional(),
305
+ reasons: z.never().optional(),
306
+ integrity: agentIntegritySchema.extend({
307
+ rulesLoaded: z.number().int().positive(),
308
+ }),
309
+ }),
310
+ agentFormatBaseSchema.extend({
311
+ passed: z.literal(false),
312
+ score: z.never().optional(),
313
+ status: z.literal("indeterminate"),
314
+ reasons: z.tuple([z.literal("zero_rules_loaded")]),
315
+ integrity: agentIntegritySchema.extend({
316
+ rulesLoaded: z.literal(0),
317
+ }),
318
+ }),
319
+ // A finalized Run can explicitly allow inert governance. Preserve the
320
+ // indeterminate integrity truth while making `passed` match that Run's sole
321
+ // verdict; requiring Run pins prevents generic zero-rule envelopes from
322
+ // accidentally becoming green.
323
+ agentFormatBaseSchema.extend({
324
+ passed: z.literal(true),
325
+ score: z.never().optional(),
326
+ status: z.literal("indeterminate"),
327
+ reasons: z.tuple([z.literal("zero_rules_loaded")]),
328
+ integrity: agentIntegritySchema.extend({
329
+ rulesLoaded: z.literal(0),
330
+ }),
331
+ provenance: agentRunProvenanceSchema,
332
+ verdict: agentRunVerdictSchema.extend({ exitCode: z.literal(0) }),
333
+ }),
334
+ ]);
262
335
  export type AgentFormat = z.infer<typeof agentFormatSchema>;
263
336
 
264
337
  export const agentErrorEnvelopeSchema = z.object({
@@ -268,6 +341,10 @@ export const agentErrorEnvelopeSchema = z.object({
268
341
  code: z.string().min(1),
269
342
  message: z.string().min(1),
270
343
  }),
344
+ provenance: agentRunProvenanceSchema.optional(),
345
+ verdict: agentRunVerdictSchema.optional(),
346
+ cloudReport: z.enum(["reported", "failed", "skipped"]).optional(),
347
+ notices: z.array(z.string()).optional(),
271
348
  });
272
349
  export type AgentErrorEnvelope = z.infer<typeof agentErrorEnvelopeSchema>;
273
350
 
@@ -285,6 +362,7 @@ export function normalizeFinding(
285
362
  ): Finding {
286
363
  return {
287
364
  ...input,
365
+ evidenceGrade: input.evidenceGrade ?? "source_backed",
288
366
  level: input.level ?? severityLevel(input.severity),
289
367
  };
290
368
  }
@@ -59,6 +59,12 @@ export interface DesignToken {
59
59
  /** Source file where this token was defined */
60
60
  sourceFile: string;
61
61
 
62
+ /** Authored reference form, when the parser can prove it from the source. */
63
+ referenceFormat?: "css-var" | "scss-var" | "scss-map" | "dtcg";
64
+
65
+ /** Authored spellings preserved before normalization (for example `$x` and `--x`). */
66
+ sourceNames?: string[];
67
+
62
68
  /** Line number in source file */
63
69
  lineNumber?: number;
64
70
 
@@ -187,6 +193,18 @@ export interface TokenConfig {
187
193
  */
188
194
  exclude?: string[];
189
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
+
190
208
  /**
191
209
  * Map CSS selectors to theme names
192
210
  * @example { ":root": "default", "[data-theme='dark']": "dark" }
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { FactIndex, makeTokenDefinitionFact } from "../../facts/index.js";
7
+ import { parseDesignTokenContent } from "../design-token-parser.js";
8
+
9
+ const tokenFile = fileURLToPath(
10
+ new URL("../../../../../libs/ui/src/tokens/_variables.scss", import.meta.url)
11
+ );
12
+
13
+ describe("SCSS source-name provenance", () => {
14
+ it("preserves both authored spellings through parser, facts, and rule-time lookup", () => {
15
+ const parsed = parseDesignTokenContent(readFileSync(tokenFile, "utf8"), {
16
+ filePath: tokenFile,
17
+ });
18
+ const token = parsed.tokens.find((candidate) => candidate.name === "--fui-space-2");
19
+
20
+ expect(token?.sourceNames).toEqual(expect.arrayContaining(["--fui-space-2", "$fui-space-2"]));
21
+
22
+ const ix = new FactIndex();
23
+ ix.addMany(
24
+ parsed.tokens.map((candidate) =>
25
+ makeTokenDefinitionFact({
26
+ name: candidate.name,
27
+ value: candidate.resolvedValue,
28
+ category: candidate.category === "spacing" ? "spacing" : "other",
29
+ referenceFormat: candidate.referenceFormat,
30
+ sourceNames: candidate.sourceNames,
31
+ })
32
+ )
33
+ );
34
+
35
+ expect(ix.tokens.symbolExists("$fui-space-2")).toBe(true);
36
+ expect(ix.tokens.symbolExists("--fui-space-2")).toBe(true);
37
+ expect(ix.tokens.symbolExists("$fui-space-99")).toBe(false);
38
+ expect(ix.tokens.definitionForSymbol("$fui-space-2")?.sourceNames).toEqual(
39
+ expect.arrayContaining(["--fui-space-2", "$fui-space-2"])
40
+ );
41
+ });
42
+ });
@@ -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
  }
@@ -67,15 +67,34 @@ describe("parseDesignTokenContent — DTCG/JSON format dispatch (#10)", () => {
67
67
  describe("parseDesignTokenContent — SCSS $-variable format dispatch (#32)", () => {
68
68
  it("parses SCSS $vars, normalizing $name to --name", () => {
69
69
  const content = "$fui-color-accent: #fff;\n$fui-space-2: 8px;";
70
- const tokens = names(content, "tokens.scss");
71
- expect(tokens).toContain("--fui-color-accent");
72
- expect(tokens).toContain("--fui-space-2");
70
+ const tokens = parseDesignTokenContent(content, { filePath: "tokens.scss" }).tokens;
71
+ expect(tokens).toEqual(
72
+ expect.arrayContaining([
73
+ expect.objectContaining({
74
+ name: "--fui-color-accent",
75
+ referenceFormat: "scss-var",
76
+ }),
77
+ expect.objectContaining({ name: "--fui-space-2", referenceFormat: "scss-var" }),
78
+ ])
79
+ );
73
80
  });
74
81
 
75
82
  it("dedupes a file declaring BOTH --fui-x and $fui-x to one --fui-x", () => {
76
83
  const content = ":root {\n --fui-x: red;\n}\n$fui-x: red;";
77
- const tokens = names(content, "tokens.scss");
78
- expect(tokens.filter((n) => n === "--fui-x")).toHaveLength(1);
84
+ const tokens = parseDesignTokenContent(content, { filePath: "tokens.scss" }).tokens.filter(
85
+ (token) => token.name === "--fui-x"
86
+ );
87
+ expect(tokens).toHaveLength(1);
88
+ expect(tokens[0].referenceFormat).toBe("scss-var");
89
+ });
90
+
91
+ it("marks a CSS-only declaration in SCSS as css-var", () => {
92
+ const content = ":root {\n --fui-color-accent: red;\n}";
93
+ const token = parseDesignTokenContent(content, { filePath: "tokens.scss" }).tokens.find(
94
+ (candidate) => candidate.name === "--fui-color-accent"
95
+ );
96
+
97
+ expect(token?.referenceFormat).toBe("css-var");
79
98
  });
80
99
 
81
100
  it("surfaces a NON-SILENT warning for a comment-only SCSS file (#32)", () => {
@@ -193,21 +193,51 @@ function parsedOutputToDesignTokens(output: TokenParseOutput, filePath: string):
193
193
  * and `$fui-x` yields one `--fui-x`.
194
194
  */
195
195
  function scssOutputToDesignTokens(output: TokenParseOutput, filePath: string): DesignToken[] {
196
- const names = new Set<string>();
197
- const tokens: DesignToken[] = [];
196
+ const tokensByName = new Map<
197
+ string,
198
+ {
199
+ parsed: ParsedToken;
200
+ referenceFormat: "css-var" | "scss-var";
201
+ sourceNames: Set<string>;
202
+ }
203
+ >();
198
204
  for (const cat of Object.values(output.categories)) {
199
205
  for (const t of cat) {
200
- const cssName = t.name.startsWith("$") ? `--${t.name.slice(1)}` : t.name;
201
- if (!cssName.startsWith("--") || names.has(cssName)) continue;
202
- names.add(cssName);
203
- tokens.push(designTokenFromParsed(cssName, t, filePath));
206
+ const isScssVariable = t.name.startsWith("$");
207
+ const cssName = isScssVariable ? `--${t.name.slice(1)}` : t.name;
208
+ if (!cssName.startsWith("--")) continue;
209
+
210
+ const existing = tokensByName.get(cssName);
211
+ if (!existing) {
212
+ tokensByName.set(cssName, {
213
+ parsed: t,
214
+ referenceFormat: isScssVariable ? "scss-var" : "css-var",
215
+ sourceNames: new Set([t.name]),
216
+ });
217
+ } else {
218
+ existing.sourceNames.add(t.name);
219
+ if (isScssVariable) {
220
+ existing.parsed = t;
221
+ existing.referenceFormat = "scss-var";
222
+ }
223
+ }
204
224
  }
205
225
  }
206
- return tokens;
226
+ return [...tokensByName].map(([name, token]) =>
227
+ designTokenFromParsed(name, token.parsed, filePath, token.referenceFormat, [
228
+ ...token.sourceNames,
229
+ ])
230
+ );
207
231
  }
208
232
 
209
233
  /** Build a `DesignToken` from a sibling-parser `ParsedToken` under a CSS name. */
210
- function designTokenFromParsed(name: string, parsed: ParsedToken, filePath: string): DesignToken {
234
+ function designTokenFromParsed(
235
+ name: string,
236
+ parsed: ParsedToken,
237
+ filePath: string,
238
+ referenceFormat?: DesignToken["referenceFormat"],
239
+ sourceNames?: string[]
240
+ ): DesignToken {
211
241
  return {
212
242
  name,
213
243
  rawValue: parsed.value ?? "",
@@ -220,6 +250,8 @@ function designTokenFromParsed(name: string, parsed: ParsedToken, filePath: stri
220
250
  level: 1,
221
251
  referenceChain: [],
222
252
  sourceFile: filePath,
253
+ referenceFormat,
254
+ ...(sourceNames?.length ? { sourceNames } : {}),
223
255
  theme: "default",
224
256
  selector: ":root",
225
257
  description: parsed.description,