@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.
- package/dist/{chunk-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
- package/dist/chunk-57QDBEHQ.js.map +1 -0
- package/dist/{chunk-DBVTSUMT.js → chunk-CBSNXFOD.js} +20 -6
- package/dist/chunk-CBSNXFOD.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +2 -2
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-DYSirwJu.d.ts → governance-DxdJV6lx.d.ts} +10 -0
- package/dist/index-0lmh0Lbo.d.ts +6981 -0
- package/dist/index.d.ts +65 -29
- package/dist/index.js +397 -127
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +18 -18
- package/dist/schemas/index.d.ts +3 -3377
- package/dist/schemas/index.js +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +75 -3
- package/src/agent-format.ts +28 -6
- package/src/constants.ts +3 -0
- package/src/evidence.ts +27 -0
- package/src/facts/builders.ts +3 -1
- package/src/facts/fact-index.ts +22 -2
- package/src/facts/facts.test.ts +1 -1
- package/src/facts/types.ts +16 -1
- package/src/index.ts +4 -0
- package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
- package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
- package/src/rules/components-prefer-library.test.ts +148 -0
- package/src/rules/components-prefer-library.ts +52 -1
- package/src/rules/emit-gate.test.ts +16 -0
- package/src/rules/emit-gate.ts +6 -3
- package/src/rules/finding.ts +3 -0
- package/src/rules/rules.test.ts +2 -1
- package/src/rules/spacing-resolution.ts +5 -8
- package/src/rules/styles-no-raw-color.test.ts +213 -0
- package/src/rules/styles-no-raw-color.ts +157 -23
- package/src/rules/styles-no-raw-dimensions.ts +31 -12
- package/src/rules/styles-no-raw-spacing.ts +33 -11
- package/src/rules/styles-no-raw-typography.ts +37 -15
- package/src/rules/taxonomy.test.ts +13 -0
- package/src/rules/tokens-require-dual-fallback.ts +54 -17
- package/src/rules/utils.ts +51 -0
- package/src/schema.ts +6 -2
- package/src/schemas/index.ts +81 -3
- package/src/token-types.ts +18 -0
- package/src/tokens/__tests__/source-names.test.ts +42 -0
- package/src/tokens/categories.ts +54 -24
- package/src/tokens/design-token-parser.test.ts +24 -5
- package/src/tokens/design-token-parser.ts +40 -8
- package/src/tokens/scss.test.ts +94 -0
- package/src/tokens/scss.ts +11 -4
- package/src/types.ts +11 -0
- package/dist/chunk-BQQTMGLA.js.map +0 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
41
|
-
const nextValue =
|
|
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:
|
|
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
|
+
}
|
package/src/rules/utils.ts
CHANGED
|
@@ -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.
|
|
262
|
+
message: "Add tokens.include, tokens.sources, or tokens.packages",
|
|
259
263
|
path: ["sources"],
|
|
260
264
|
});
|
|
261
265
|
}
|
package/src/schemas/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/token-types.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/tokens/categories.ts
CHANGED
|
@@ -96,7 +96,48 @@ export function inferTokenGroup(name: string): string {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
71
|
-
expect(tokens).
|
|
72
|
-
|
|
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 =
|
|
78
|
-
|
|
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
|
|
197
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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(
|
|
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,
|