@usefragments/core 1.5.0 → 1.5.2
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-HXGE3O2F.js → chunk-BAHCOAVG.js} +133 -7
- package/dist/chunk-BAHCOAVG.js.map +1 -0
- package/dist/{chunk-57QDBEHQ.js → chunk-ZHS52OT4.js} +3 -9
- package/dist/chunk-ZHS52OT4.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- 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-BLsyk56o.d.ts → governance-pKrfh517.d.ts} +264 -4
- package/dist/{index-h_yWj15D.d.ts → index-DbkPE46t.d.ts} +40 -0
- package/dist/index.d.ts +472 -28
- package/dist/index.js +561 -39
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/schemas/index.d.ts +1 -1
- 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 +1 -0
- package/src/canonical-bridge.ts +46 -0
- package/src/canonical-direction.ts +118 -0
- package/src/codes/__tests__/codes.test.ts +1 -1
- package/src/codes/codes.ts +29 -0
- package/src/config.ts +20 -0
- package/src/contract/preimage.test.ts +40 -0
- package/src/contract/preimage.ts +20 -0
- package/src/effective-governance-inputs.test.ts +52 -0
- package/src/effective-governance-inputs.ts +106 -0
- package/src/facts/builders.ts +25 -1
- package/src/facts/compile.ts +20 -2
- package/src/facts/fact-index.ts +12 -1
- package/src/facts/facts.test.ts +14 -18
- package/src/facts/types.ts +25 -6
- package/src/governance-integrity.test.ts +127 -0
- package/src/governance-integrity.ts +326 -4
- package/src/governance.test.ts +87 -1
- package/src/governance.ts +121 -0
- package/src/index.ts +34 -5
- package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
- package/src/rules/components-prefer-library.test.ts +103 -0
- package/src/rules/components-prefer-library.ts +30 -4
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +7 -0
- package/src/rules/jsx-preferred-import-path.ts +45 -9
- package/src/rules/rules.test.ts +202 -6
- package/src/rules/spacing-resolution.ts +2 -2
- package/src/rules/styles-no-raw-color.test.ts +6 -1
- package/src/rules/styles-no-raw-color.ts +3 -2
- package/src/rules/styles-no-raw-dimensions.ts +5 -7
- package/src/rules/styles-no-raw-spacing.test.ts +48 -0
- package/src/rules/styles-no-raw-spacing.ts +10 -7
- package/src/rules/styles-no-raw-typography.ts +7 -4
- package/src/rules/taxonomy.test.ts +3 -0
- package/src/rules/tiers.ts +2 -0
- package/src/rules/token-candidates.ts +36 -0
- package/src/rules/tokens-require-dual-fallback.ts +2 -1
- package/src/rules/tokens-upstream-drift.test.ts +111 -0
- package/src/rules/tokens-upstream-drift.ts +57 -0
- package/src/rules/utils.ts +22 -14
- package/src/schema.ts +13 -0
- package/src/schemas/index.ts +5 -8
- package/src/token-types.ts +71 -1
- package/src/types.ts +27 -3
- package/dist/chunk-57QDBEHQ.js.map +0 -1
- package/dist/chunk-HXGE3O2F.js.map +0 -1
package/src/rules/rules.test.ts
CHANGED
|
@@ -509,6 +509,160 @@ describe("preferred JSX imports and components", () => {
|
|
|
509
509
|
expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
|
|
510
510
|
});
|
|
511
511
|
|
|
512
|
+
it("governs a confirmed package-to-local bridge without offering an unsafe fix", () => {
|
|
513
|
+
const ix = new FactIndex();
|
|
514
|
+
ix.addMany(
|
|
515
|
+
compileGlobalGovernanceFacts({
|
|
516
|
+
severity: "error",
|
|
517
|
+
canonicalBridges: [
|
|
518
|
+
{
|
|
519
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
520
|
+
local: {
|
|
521
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
522
|
+
moduleSpecifier: "@/components",
|
|
523
|
+
exportName: "Button",
|
|
524
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
525
|
+
},
|
|
526
|
+
decision: { state: "confirmed", source: "authored" },
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
})
|
|
530
|
+
);
|
|
531
|
+
ix.add(
|
|
532
|
+
makeUsageImportFact({
|
|
533
|
+
file: "src/app/page.tsx",
|
|
534
|
+
local: "MuiButton",
|
|
535
|
+
imported: "Button",
|
|
536
|
+
source: "@mui/material",
|
|
537
|
+
location: { file: "src/app/page.tsx", line: 1, column: 0 },
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const findings = ruleJsxPreferredImportPath(ix);
|
|
542
|
+
|
|
543
|
+
expect(findings).toHaveLength(1);
|
|
544
|
+
expect(findings[0]).toMatchObject({
|
|
545
|
+
ruleId: "imports/preferred-path",
|
|
546
|
+
severity: "serious",
|
|
547
|
+
attributes: {
|
|
548
|
+
canonicalBridge: true,
|
|
549
|
+
canonicalTarget: "src/components/Button.tsx#Button",
|
|
550
|
+
suggestedImport: "@/components",
|
|
551
|
+
suggestedComponent: "Button",
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
expect(findings[0].fix).toBeUndefined();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("matches an exact mapped subpath default import", () => {
|
|
558
|
+
const ix = new FactIndex();
|
|
559
|
+
ix.addMany(
|
|
560
|
+
compileGlobalGovernanceFacts({
|
|
561
|
+
canonicalBridges: [
|
|
562
|
+
{
|
|
563
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
564
|
+
local: {
|
|
565
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
566
|
+
moduleSpecifier: "@/components",
|
|
567
|
+
exportName: "Button",
|
|
568
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
569
|
+
},
|
|
570
|
+
decision: { state: "confirmed", source: "migration" },
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
})
|
|
574
|
+
);
|
|
575
|
+
ix.add(
|
|
576
|
+
makeUsageImportFact({
|
|
577
|
+
file: "src/app/page.tsx",
|
|
578
|
+
local: "Button",
|
|
579
|
+
imported: "default",
|
|
580
|
+
source: "@mui/material/Button",
|
|
581
|
+
location: { file: "src/app/page.tsx", line: 1, column: 0 },
|
|
582
|
+
})
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
expect(ruleJsxPreferredImportPath(ix)).toHaveLength(1);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("exempts only implementation files and leaves unmapped exports unaffected", () => {
|
|
589
|
+
const ix = new FactIndex();
|
|
590
|
+
ix.addMany(
|
|
591
|
+
compileGlobalGovernanceFacts({
|
|
592
|
+
canonicalBridges: [
|
|
593
|
+
{
|
|
594
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
595
|
+
local: {
|
|
596
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
597
|
+
moduleSpecifier: "@/components",
|
|
598
|
+
exportName: "Button",
|
|
599
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
600
|
+
},
|
|
601
|
+
decision: { state: "confirmed", source: "authored" },
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
})
|
|
605
|
+
);
|
|
606
|
+
ix.add(
|
|
607
|
+
makeUsageImportFact({
|
|
608
|
+
file: "./src/components/Button.tsx",
|
|
609
|
+
local: "MuiButton",
|
|
610
|
+
imported: "Button",
|
|
611
|
+
source: "@mui/material",
|
|
612
|
+
location: { file: "./src/components/Button.tsx", line: 1, column: 0 },
|
|
613
|
+
})
|
|
614
|
+
);
|
|
615
|
+
ix.add(
|
|
616
|
+
makeUsageImportFact({
|
|
617
|
+
file: "src/app/page.tsx",
|
|
618
|
+
local: "Stepper",
|
|
619
|
+
imported: "Stepper",
|
|
620
|
+
source: "@mui/material",
|
|
621
|
+
location: { file: "src/app/page.tsx", line: 1, column: 0 },
|
|
622
|
+
})
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("does not exempt sibling helpers, stories, barrels, or consumers", () => {
|
|
629
|
+
const ix = new FactIndex();
|
|
630
|
+
ix.addMany(
|
|
631
|
+
compileGlobalGovernanceFacts({
|
|
632
|
+
canonicalBridges: [
|
|
633
|
+
{
|
|
634
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
635
|
+
local: {
|
|
636
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
637
|
+
moduleSpecifier: "@/components",
|
|
638
|
+
exportName: "Button",
|
|
639
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
640
|
+
},
|
|
641
|
+
decision: { state: "confirmed", source: "authored" },
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
})
|
|
645
|
+
);
|
|
646
|
+
for (const file of [
|
|
647
|
+
"src/components/Button.helpers.tsx",
|
|
648
|
+
"src/components/Button.stories.tsx",
|
|
649
|
+
"src/components/index.ts",
|
|
650
|
+
"src/app/page.tsx",
|
|
651
|
+
]) {
|
|
652
|
+
ix.add(
|
|
653
|
+
makeUsageImportFact({
|
|
654
|
+
file,
|
|
655
|
+
local: "Button",
|
|
656
|
+
imported: "Button",
|
|
657
|
+
source: "@mui/material",
|
|
658
|
+
location: { file, line: 1, column: 0 },
|
|
659
|
+
})
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
expect(ruleJsxPreferredImportPath(ix)).toHaveLength(4);
|
|
664
|
+
});
|
|
665
|
+
|
|
512
666
|
it("matches preferred-component policy across component package epochs", () => {
|
|
513
667
|
const ix = new FactIndex();
|
|
514
668
|
ix.addMany(
|
|
@@ -1245,6 +1399,7 @@ describe("Phase 6 fix payloads", () => {
|
|
|
1245
1399
|
name: "--fui-color-accent",
|
|
1246
1400
|
value: "#2563eb",
|
|
1247
1401
|
category: "color",
|
|
1402
|
+
referenceFormat: "css-var",
|
|
1248
1403
|
})
|
|
1249
1404
|
);
|
|
1250
1405
|
ix.add(
|
|
@@ -1273,8 +1428,18 @@ describe("Phase 6 fix payloads", () => {
|
|
|
1273
1428
|
it("styles/no-raw-color prefers a role-matching token when several share a value", () => {
|
|
1274
1429
|
const ix = emptyIndexWithGovernance();
|
|
1275
1430
|
ix.addMany([
|
|
1276
|
-
makeTokenDefinitionFact({
|
|
1277
|
-
|
|
1431
|
+
makeTokenDefinitionFact({
|
|
1432
|
+
name: "--color-bg",
|
|
1433
|
+
value: "#ffffff",
|
|
1434
|
+
category: "color",
|
|
1435
|
+
referenceFormat: "css-var",
|
|
1436
|
+
}),
|
|
1437
|
+
makeTokenDefinitionFact({
|
|
1438
|
+
name: "--color-text",
|
|
1439
|
+
value: "#ffffff",
|
|
1440
|
+
category: "color",
|
|
1441
|
+
referenceFormat: "css-var",
|
|
1442
|
+
}),
|
|
1278
1443
|
]);
|
|
1279
1444
|
ix.add(
|
|
1280
1445
|
makeStyleDeclarationFact({
|
|
@@ -1396,6 +1561,7 @@ describe("Phase 6 fix payloads", () => {
|
|
|
1396
1561
|
name: "--fui-color-white",
|
|
1397
1562
|
value: "#ffffff",
|
|
1398
1563
|
category: "color",
|
|
1564
|
+
referenceFormat: "css-var",
|
|
1399
1565
|
})
|
|
1400
1566
|
);
|
|
1401
1567
|
ix.add(
|
|
@@ -1420,6 +1586,7 @@ describe("Phase 6 fix payloads", () => {
|
|
|
1420
1586
|
name: "--fui-color-accent",
|
|
1421
1587
|
value: "#2563eb",
|
|
1422
1588
|
category: "color",
|
|
1589
|
+
referenceFormat: "css-var",
|
|
1423
1590
|
})
|
|
1424
1591
|
);
|
|
1425
1592
|
ix.add(
|
|
@@ -1446,6 +1613,7 @@ describe("Phase 6 fix payloads", () => {
|
|
|
1446
1613
|
name: "--fui-color-accent",
|
|
1447
1614
|
value: "#2563eb",
|
|
1448
1615
|
category: "color",
|
|
1616
|
+
referenceFormat: "css-var",
|
|
1449
1617
|
})
|
|
1450
1618
|
);
|
|
1451
1619
|
addButtonUsage(ix, {
|
|
@@ -3367,14 +3535,15 @@ function spacingIndexWithTokens(
|
|
|
3367
3535
|
name: token.name,
|
|
3368
3536
|
value: token.value,
|
|
3369
3537
|
category: "spacing",
|
|
3370
|
-
referenceFormat:
|
|
3538
|
+
referenceFormat:
|
|
3539
|
+
token.referenceFormat ?? (token.name.startsWith("$") ? "scss-var" : "css-var"),
|
|
3371
3540
|
})
|
|
3372
3541
|
);
|
|
3373
3542
|
}
|
|
3374
3543
|
return ix;
|
|
3375
3544
|
}
|
|
3376
3545
|
|
|
3377
|
-
type TokenRefFormat = "css-var" | "scss-var" | "scss-map" | "dtcg";
|
|
3546
|
+
type TokenRefFormat = "css-var" | "scss-var" | "scss-map" | "dtcg" | "js-member";
|
|
3378
3547
|
|
|
3379
3548
|
describe("styles/no-raw-spacing — token-aware (BUG 1/13/15)", () => {
|
|
3380
3549
|
it("flags an on-scale raw literal that equals a token with a deterministic var() swap", () => {
|
|
@@ -3438,6 +3607,27 @@ describe("styles/no-raw-spacing — token-aware (BUG 1/13/15)", () => {
|
|
|
3438
3607
|
expect(findings[0].fix?.deterministic).toBe(false);
|
|
3439
3608
|
});
|
|
3440
3609
|
|
|
3610
|
+
it("keeps an exact Sass variable swap deterministic", () => {
|
|
3611
|
+
const ix = spacingIndexWithTokens([
|
|
3612
|
+
{ name: "$space-md", value: "16px", referenceFormat: "scss-var" },
|
|
3613
|
+
]);
|
|
3614
|
+
ix.add(
|
|
3615
|
+
makeStyleDeclarationFact({
|
|
3616
|
+
file: "src/x.scss",
|
|
3617
|
+
selector: ".a",
|
|
3618
|
+
declarationPath: "0",
|
|
3619
|
+
property: "padding",
|
|
3620
|
+
value: "16px",
|
|
3621
|
+
location: { file: "src/x.scss", line: 1, column: 0 },
|
|
3622
|
+
})
|
|
3623
|
+
);
|
|
3624
|
+
|
|
3625
|
+
expect(ruleStylesNoRawSpacing(ix)[0]?.fix).toMatchObject({
|
|
3626
|
+
value: "$space-md",
|
|
3627
|
+
deterministic: true,
|
|
3628
|
+
});
|
|
3629
|
+
});
|
|
3630
|
+
|
|
3441
3631
|
it("catches a camelCase longhand inline spacing prop (marginTop)", () => {
|
|
3442
3632
|
const ix = spacingIndexWithTokens([]);
|
|
3443
3633
|
const node = makeUsageNodeFact({
|
|
@@ -3477,7 +3667,8 @@ describe("styles/no-raw-color — role correctness + fixer safety (BUG 4/3/11)",
|
|
|
3477
3667
|
name: token.name,
|
|
3478
3668
|
value: token.value,
|
|
3479
3669
|
category: "color",
|
|
3480
|
-
referenceFormat:
|
|
3670
|
+
referenceFormat:
|
|
3671
|
+
token.referenceFormat ?? (token.name.startsWith("$") ? "scss-var" : "css-var"),
|
|
3481
3672
|
})
|
|
3482
3673
|
);
|
|
3483
3674
|
}
|
|
@@ -3548,7 +3739,12 @@ describe("styles/no-raw-typography (BUG 10)", () => {
|
|
|
3548
3739
|
);
|
|
3549
3740
|
for (const token of tokens) {
|
|
3550
3741
|
ix.add(
|
|
3551
|
-
makeTokenDefinitionFact({
|
|
3742
|
+
makeTokenDefinitionFact({
|
|
3743
|
+
name: token.name,
|
|
3744
|
+
value: token.value,
|
|
3745
|
+
category: "typography",
|
|
3746
|
+
referenceFormat: "css-var",
|
|
3747
|
+
})
|
|
3552
3748
|
);
|
|
3553
3749
|
}
|
|
3554
3750
|
return ix;
|
|
@@ -91,7 +91,7 @@ export function resolveSpacingValue(input: {
|
|
|
91
91
|
}
|
|
92
92
|
// On-grid literal that equals a token: value-preserving swap.
|
|
93
93
|
anyViolation = true;
|
|
94
|
-
const reference = tokenReference(exactToken
|
|
94
|
+
const reference = tokenReference(exactToken);
|
|
95
95
|
if (!normalized.deterministicFix) allValuePreserving = false;
|
|
96
96
|
suggestedParts.push(reference);
|
|
97
97
|
if (!firstViolation) {
|
|
@@ -128,7 +128,7 @@ export function resolveSpacingValue(input: {
|
|
|
128
128
|
const nearestToken = input.tokens.get(Math.abs(nearest));
|
|
129
129
|
suggestedToken = nearestToken?.name;
|
|
130
130
|
suggestedPart = nearestToken
|
|
131
|
-
? tokenReference(nearestToken
|
|
131
|
+
? tokenReference(nearestToken)
|
|
132
132
|
: input.bareNumberFix
|
|
133
133
|
? `${nearest}`
|
|
134
134
|
: `${nearest}${input.scale.unit}`;
|
|
@@ -77,7 +77,12 @@ describe("styles/no-raw-color — #9d SVG paint attributes", () => {
|
|
|
77
77
|
it("emits a deterministic token fix when stroke matches an accent token exactly", () => {
|
|
78
78
|
const ix = indexWithColorPolicy();
|
|
79
79
|
ix.add(
|
|
80
|
-
makeTokenDefinitionFact({
|
|
80
|
+
makeTokenDefinitionFact({
|
|
81
|
+
name: "--fui-color-accent",
|
|
82
|
+
value: "#7c3aed",
|
|
83
|
+
category: "color",
|
|
84
|
+
referenceFormat: "css-var",
|
|
85
|
+
})
|
|
81
86
|
);
|
|
82
87
|
const nodeId = addNode(ix, "path");
|
|
83
88
|
ix.add(
|
|
@@ -15,6 +15,7 @@ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
|
15
15
|
|
|
16
16
|
import { colorDistance, parseOpaqueColor } from "./color-math.js";
|
|
17
17
|
import { makeFinding } from "./finding.js";
|
|
18
|
+
import { localTokenCandidates } from "./token-candidates.js";
|
|
18
19
|
import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
|
|
19
20
|
import {
|
|
20
21
|
detectRawColor,
|
|
@@ -33,7 +34,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
33
34
|
const policy = ix.policy.rawColorPolicy();
|
|
34
35
|
if (!policy) return [];
|
|
35
36
|
|
|
36
|
-
const colorTokens = ix
|
|
37
|
+
const colorTokens = localTokenCandidates(ix, "color");
|
|
37
38
|
const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
|
|
38
39
|
const findings: Finding[] = [];
|
|
39
40
|
|
|
@@ -431,7 +432,7 @@ function buildTokenFix(
|
|
|
431
432
|
match: TokenMatch,
|
|
432
433
|
ix: FactIndex
|
|
433
434
|
): FixEmission<FindingReplaceStyleValueFix> {
|
|
434
|
-
const reference = tokenReference(match.token
|
|
435
|
+
const reference = tokenReference(match.token);
|
|
435
436
|
const value = replaceRawColor(rawValue, rawColor, reference);
|
|
436
437
|
return emitFix(
|
|
437
438
|
{
|
|
@@ -30,12 +30,14 @@
|
|
|
30
30
|
import type { FactId, FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
31
31
|
|
|
32
32
|
import { makeFinding } from "./finding.js";
|
|
33
|
+
import { localTokenCandidates } from "./token-candidates.js";
|
|
33
34
|
import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
|
|
34
35
|
import {
|
|
35
36
|
emitFix,
|
|
36
37
|
indexComponentByNodeId,
|
|
37
38
|
parseLengthValue,
|
|
38
39
|
readUsageNode,
|
|
40
|
+
tokenReference,
|
|
39
41
|
type FixEmission,
|
|
40
42
|
} from "./utils.js";
|
|
41
43
|
|
|
@@ -67,8 +69,8 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
|
|
|
67
69
|
if (!policy) return [];
|
|
68
70
|
|
|
69
71
|
const appliesTo = new Set(policy.appliesTo);
|
|
70
|
-
const spacingTokens = ix
|
|
71
|
-
const radiusTokens = ix
|
|
72
|
+
const spacingTokens = localTokenCandidates(ix, "spacing");
|
|
73
|
+
const radiusTokens = localTokenCandidates(ix, "radius");
|
|
72
74
|
const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
|
|
73
75
|
const findings: Finding[] = [];
|
|
74
76
|
|
|
@@ -368,9 +370,5 @@ function normalizeDimension(value: string): string | null {
|
|
|
368
370
|
}
|
|
369
371
|
|
|
370
372
|
function tokenVar(token: TokenDefinitionFact): string {
|
|
371
|
-
|
|
372
|
-
const cssVarName = token.name.startsWith("--")
|
|
373
|
-
? token.name
|
|
374
|
-
: `--${token.name.replace(/\./g, "-")}`;
|
|
375
|
-
return `var(${cssVarName})`;
|
|
373
|
+
return tokenReference(token);
|
|
376
374
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileGlobalGovernanceFacts,
|
|
5
|
+
FactIndex,
|
|
6
|
+
g,
|
|
7
|
+
makeStyleDeclarationFact,
|
|
8
|
+
ruleStylesNoRawSpacing,
|
|
9
|
+
} from "../index.js";
|
|
10
|
+
|
|
11
|
+
describe("styles/no-raw-spacing fork copy", () => {
|
|
12
|
+
it("names the active scale and its config lever without changing machine attributes", () => {
|
|
13
|
+
const ix = new FactIndex();
|
|
14
|
+
ix.addMany(
|
|
15
|
+
compileGlobalGovernanceFacts({
|
|
16
|
+
scales: {
|
|
17
|
+
space: g.scale.px([0, 4, 8, 12, 16, 20, 24]),
|
|
18
|
+
},
|
|
19
|
+
styles: [
|
|
20
|
+
g.styles.rawSpacing().mustMatchScale("space", {
|
|
21
|
+
appliesTo: ["margin-top"],
|
|
22
|
+
severity: "warn",
|
|
23
|
+
}),
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
ix.add(
|
|
28
|
+
makeStyleDeclarationFact({
|
|
29
|
+
file: "src/Card.css",
|
|
30
|
+
selector: ".card",
|
|
31
|
+
declarationPath: "0",
|
|
32
|
+
property: "margin-top",
|
|
33
|
+
value: "10px",
|
|
34
|
+
location: { file: "src/Card.css", line: 2, column: 3 },
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const [finding] = ruleStylesNoRawSpacing(ix);
|
|
39
|
+
|
|
40
|
+
expect(finding?.message).toContain("not on the `space` scale");
|
|
41
|
+
expect(finding?.message).toContain("Configure `govern.scales.space` to change it");
|
|
42
|
+
expect(finding?.attributes).toMatchObject({
|
|
43
|
+
scale: "space",
|
|
44
|
+
rawValue: "10px",
|
|
45
|
+
source: "css",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
} from "../facts/index.js";
|
|
36
36
|
|
|
37
37
|
import { makeFinding } from "./finding.js";
|
|
38
|
+
import { localTokenCandidates } from "./token-candidates.js";
|
|
38
39
|
import {
|
|
39
40
|
buildSpacingTokenLookup,
|
|
40
41
|
resolveSpacingValue,
|
|
@@ -62,7 +63,7 @@ export function ruleStylesNoRawSpacing(ix: FactIndex): Finding[] {
|
|
|
62
63
|
const lookupFor = (scale: ScaleFact): SpacingTokenLookup => {
|
|
63
64
|
let lookup = lookupCache.get(scale.name);
|
|
64
65
|
if (!lookup) {
|
|
65
|
-
lookup = buildSpacingTokenLookup(ix
|
|
66
|
+
lookup = buildSpacingTokenLookup(localTokenCandidates(ix, "spacing"), scale);
|
|
66
67
|
lookupCache.set(scale.name, lookup);
|
|
67
68
|
}
|
|
68
69
|
return lookup;
|
|
@@ -116,7 +117,7 @@ function checkDeclaration(
|
|
|
116
117
|
ruleId: RULE_ID,
|
|
117
118
|
ruleVersion: RULE_VERSION,
|
|
118
119
|
severity: policy.severity,
|
|
119
|
-
message: spacingMessage(decl.property, decl.value, allowed, scale
|
|
120
|
+
message: spacingMessage(decl.property, decl.value, allowed, scale, checked),
|
|
120
121
|
location: decl.location,
|
|
121
122
|
evidence: ix.evidence([decl.id, policy.id, scale.id]),
|
|
122
123
|
fingerprintIdentity: {
|
|
@@ -184,7 +185,7 @@ function checkInlineStyle(
|
|
|
184
185
|
ruleId: RULE_ID,
|
|
185
186
|
ruleVersion: RULE_VERSION,
|
|
186
187
|
severity: policy.severity,
|
|
187
|
-
message: spacingMessage(inline.property, inline.value, allowed, scale
|
|
188
|
+
message: spacingMessage(inline.property, inline.value, allowed, scale, checked),
|
|
188
189
|
location: node.location,
|
|
189
190
|
evidence: ix.evidence(evidenceIds),
|
|
190
191
|
fingerprintIdentity: {
|
|
@@ -230,7 +231,9 @@ function buildSpacingFix(
|
|
|
230
231
|
deterministic: true,
|
|
231
232
|
},
|
|
232
233
|
{
|
|
233
|
-
symbols:
|
|
234
|
+
symbols: checked.matchedToken
|
|
235
|
+
? [checked.matchedToken]
|
|
236
|
+
: tokenSymbolsInText(checked.suggestedValue),
|
|
234
237
|
editKind: "replaceStyleValue",
|
|
235
238
|
valuePreserving: checked.deterministicFix,
|
|
236
239
|
},
|
|
@@ -242,7 +245,7 @@ function spacingMessage(
|
|
|
242
245
|
property: string,
|
|
243
246
|
value: string,
|
|
244
247
|
allowed: number[],
|
|
245
|
-
|
|
248
|
+
scale: ScaleFact,
|
|
246
249
|
checked: CheckedSpacingValue
|
|
247
250
|
): string {
|
|
248
251
|
if (checked.reason === "token-equivalent" && checked.matchedToken) {
|
|
@@ -252,7 +255,7 @@ function spacingMessage(
|
|
|
252
255
|
.slice()
|
|
253
256
|
.sort((a, b) => a - b)
|
|
254
257
|
.slice(0, 6);
|
|
255
|
-
const suffix = `${sample.join(unit + ", ")}${unit}`;
|
|
258
|
+
const suffix = `${sample.join(scale.unit + ", ")}${scale.unit}`;
|
|
256
259
|
const ellipsis = allowed.length > sample.length ? ", …" : "";
|
|
257
|
-
return `\`${property}: ${value}\` is not on the
|
|
260
|
+
return `\`${property}: ${value}\` is not on the \`${scale.name}\` scale. Allowed: ${suffix}${ellipsis}. Configure \`govern.scales.${scale.name}\` to change it.`;
|
|
258
261
|
}
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
} from "../facts/index.js";
|
|
27
27
|
|
|
28
28
|
import { makeFinding } from "./finding.js";
|
|
29
|
+
import { localTokenCandidates } from "./token-candidates.js";
|
|
29
30
|
import type { Finding } from "./types.js";
|
|
30
31
|
import {
|
|
31
32
|
indexComponentByNodeId,
|
|
@@ -150,7 +151,7 @@ function buildTypographyTokenLookup(
|
|
|
150
151
|
scale: ScaleFact
|
|
151
152
|
): Map<number, TokenDefinitionFact> {
|
|
152
153
|
const out = new Map<number, TokenDefinitionFact>();
|
|
153
|
-
for (const token of ix
|
|
154
|
+
for (const token of localTokenCandidates(ix, "typography")) {
|
|
154
155
|
const parsed = parseLengthValue(token.value);
|
|
155
156
|
if (!parsed || parsed.unit === null) continue;
|
|
156
157
|
const normalized = normalizeLengthForScale(parsed, scale);
|
|
@@ -188,7 +189,7 @@ function checkFontSize(
|
|
|
188
189
|
if (matchesScale(normalized.value, allowed)) {
|
|
189
190
|
if (!exactToken) return null;
|
|
190
191
|
return {
|
|
191
|
-
suggestedValue: tokenReference(exactToken
|
|
192
|
+
suggestedValue: tokenReference(exactToken),
|
|
192
193
|
fixEmittable: true,
|
|
193
194
|
deterministicFix: normalized.deterministicFix,
|
|
194
195
|
reason: "token-equivalent",
|
|
@@ -202,7 +203,7 @@ function checkFontSize(
|
|
|
202
203
|
}
|
|
203
204
|
const nearestToken = tokens.get(Math.abs(nearest));
|
|
204
205
|
const suggestedValue = nearestToken
|
|
205
|
-
? tokenReference(nearestToken
|
|
206
|
+
? tokenReference(nearestToken)
|
|
206
207
|
: bareNumberFix
|
|
207
208
|
? `${nearest}`
|
|
208
209
|
: `${nearest}${scale.unit}`;
|
|
@@ -231,7 +232,9 @@ function buildFix(
|
|
|
231
232
|
deterministic: true,
|
|
232
233
|
},
|
|
233
234
|
{
|
|
234
|
-
symbols:
|
|
235
|
+
symbols: checked.matchedToken
|
|
236
|
+
? [checked.matchedToken]
|
|
237
|
+
: tokenSymbolsInText(checked.suggestedValue),
|
|
235
238
|
editKind: "replaceStyleValue",
|
|
236
239
|
valuePreserving: checked.deterministicFix,
|
|
237
240
|
},
|
|
@@ -95,6 +95,7 @@ describe("rule taxonomy SSOT", () => {
|
|
|
95
95
|
"components/prefer-library",
|
|
96
96
|
"components/shadow-component",
|
|
97
97
|
"tokens/css-vars-must-be-defined",
|
|
98
|
+
"tokens/upstream-drift",
|
|
98
99
|
]);
|
|
99
100
|
});
|
|
100
101
|
|
|
@@ -102,6 +103,7 @@ describe("rule taxonomy SSOT", () => {
|
|
|
102
103
|
expect(
|
|
103
104
|
[
|
|
104
105
|
"tokens/css-vars-must-be-defined",
|
|
106
|
+
"tokens/upstream-drift",
|
|
105
107
|
"components/prefer-library",
|
|
106
108
|
"components/shadow-component",
|
|
107
109
|
].sort(compareByVocabularyRank)
|
|
@@ -109,6 +111,7 @@ describe("rule taxonomy SSOT", () => {
|
|
|
109
111
|
"components/shadow-component",
|
|
110
112
|
"components/prefer-library",
|
|
111
113
|
"tokens/css-vars-must-be-defined",
|
|
114
|
+
"tokens/upstream-drift",
|
|
112
115
|
]);
|
|
113
116
|
});
|
|
114
117
|
|
package/src/rules/tiers.ts
CHANGED
|
@@ -30,6 +30,7 @@ export const RULE_TIER: Readonly<Record<string, RuleTier>> = {
|
|
|
30
30
|
"components/shadow-component": "contract", // canonical-component identity drift
|
|
31
31
|
"components/prefer-library": "contract", // canonical-component bypass (FUI1004)
|
|
32
32
|
"tokens/css-vars-must-be-defined": "contract", // token drift (FUI2015)
|
|
33
|
+
"tokens/upstream-drift": "contract", // authored local/upstream value drift (FUI2017)
|
|
33
34
|
|
|
34
35
|
// ---- Tier B — generic hygiene -----------------------------------------
|
|
35
36
|
"components/unknown-prop": "hygiene",
|
|
@@ -99,6 +100,7 @@ const VOCABULARY_ORDER: readonly string[] = [
|
|
|
99
100
|
"components/shadow-component",
|
|
100
101
|
"components/prefer-library",
|
|
101
102
|
"tokens/css-vars-must-be-defined",
|
|
103
|
+
"tokens/upstream-drift",
|
|
102
104
|
];
|
|
103
105
|
|
|
104
106
|
/** Headline rank of a rule id (lower = higher in the vocabulary-first list). */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
2
|
+
import { normalizeColor } from "../tokens/color.js";
|
|
3
|
+
|
|
4
|
+
/** Normalize token values using the same comparison contract as drift findings. */
|
|
5
|
+
export function normalizedTokenValue(token: TokenDefinitionFact): string {
|
|
6
|
+
const value = token.value.trim();
|
|
7
|
+
return token.category === "color" ? normalizeColor(value) : value.replace(/\s+/g, " ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Return local definitions that are safe to offer as replacement candidates.
|
|
12
|
+
* A local definition with one exact authored upstream counterpart is withheld
|
|
13
|
+
* while their values drift. Missing or ambiguous upstream input remains
|
|
14
|
+
* visible to drift diagnostics but is never guessed here.
|
|
15
|
+
*/
|
|
16
|
+
export function localTokenCandidates(
|
|
17
|
+
ix: FactIndex,
|
|
18
|
+
category?: TokenDefinitionFact["category"]
|
|
19
|
+
): TokenDefinitionFact[] {
|
|
20
|
+
const all = ix.tokens.list();
|
|
21
|
+
const upstreamByName = new Map<string, TokenDefinitionFact[]>();
|
|
22
|
+
for (const token of all) {
|
|
23
|
+
if (token.role !== "upstream") continue;
|
|
24
|
+
const matches = upstreamByName.get(token.name) ?? [];
|
|
25
|
+
matches.push(token);
|
|
26
|
+
upstreamByName.set(token.name, matches);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return all.filter((token) => {
|
|
30
|
+
if (token.role === "upstream" || (category !== undefined && token.category !== category)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const matches = upstreamByName.get(token.upstreamName ?? token.name) ?? [];
|
|
34
|
+
return matches.length !== 1 || normalizedTokenValue(token) === normalizedTokenValue(matches[0]);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
2
2
|
|
|
3
3
|
import { makeFinding } from "./finding.js";
|
|
4
|
+
import { localTokenCandidates } from "./token-candidates.js";
|
|
4
5
|
import type { Finding } from "./types.js";
|
|
5
6
|
import { emitFix } from "./utils.js";
|
|
6
7
|
|
|
@@ -22,7 +23,7 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
|
|
|
22
23
|
const policy = ix.policy.ruleConfig(RULE_ID);
|
|
23
24
|
if (!policy?.enabled) return [];
|
|
24
25
|
|
|
25
|
-
const tokensByCssVariable = indexTokensByCssVariable(ix
|
|
26
|
+
const tokensByCssVariable = indexTokensByCssVariable(localTokenCandidates(ix));
|
|
26
27
|
const findings: Finding[] = [];
|
|
27
28
|
|
|
28
29
|
for (const decl of ix.byKind("style_declaration")) {
|