@usefragments/core 1.5.2 → 1.7.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-RANPUC6C.js +72 -0
- package/dist/chunk-RANPUC6C.js.map +1 -0
- package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
- package/dist/chunk-WNMWKUYG.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/compiled-types/index.js +8 -0
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
- package/dist/index.d.ts +800 -179
- package/dist/index.js +1003 -237
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +2 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- package/src/agent-format.test.ts +13 -0
- package/src/agent-format.ts +9 -3
- package/src/canonical-bridge.ts +69 -1
- package/src/canonical-direction.test.ts +118 -0
- package/src/canonical-direction.ts +43 -2
- package/src/codes/__tests__/codes.test.ts +14 -2
- package/src/codes/codes.ts +41 -2
- package/src/compiled-types/index.ts +81 -0
- package/src/compiled-types/parse.test.ts +47 -0
- package/src/facts/builders.ts +48 -0
- package/src/facts/compile.ts +148 -34
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +44 -3
- package/src/facts/index.ts +11 -6
- package/src/facts/types.ts +60 -9
- package/src/governance-integrity.test.ts +272 -4
- package/src/governance-integrity.ts +345 -25
- package/src/governance.ts +87 -1
- package/src/index.ts +46 -1
- package/src/policy-exclude.ts +113 -0
- package/src/rules/a11y-required-accessible-name.ts +175 -28
- package/src/rules/a11y-standard.ts +102 -0
- package/src/rules/a11y-utils.ts +7 -0
- package/src/rules/components-prefer-library.test.ts +75 -28
- package/src/rules/components-prefer-library.ts +35 -15
- package/src/rules/components-shadow-component.test.ts +21 -9
- package/src/rules/emit-gate.test.ts +74 -4
- package/src/rules/emit-gate.ts +24 -9
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +18 -2
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +188 -8
- package/src/rules/styles-no-raw-color.ts +13 -4
- package/src/rules/styles-no-raw-dimensions.ts +13 -4
- package/src/rules/styles-no-raw-spacing.ts +12 -4
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/tiers.ts +1 -0
- package/src/rules/utils.ts +39 -0
- package/src/tokens/design-token-parser.test.ts +131 -0
- package/src/tokens/design-token-parser.ts +362 -49
- package/src/types.ts +2 -2
- package/dist/chunk-BAHCOAVG.js.map +0 -1
- package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FactIndex } from "../facts/index.js";
|
|
1
|
+
import type { FactIndex, JsxImportPathPreferredFact, UsageImportFact } from "../facts/index.js";
|
|
2
2
|
import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
|
|
3
3
|
|
|
4
4
|
import { makeFinding } from "./finding.js";
|
|
@@ -15,22 +15,21 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
|
|
|
15
15
|
const seen = new Set<string>();
|
|
16
16
|
|
|
17
17
|
for (const usage of ix.byKind("usage_import")) {
|
|
18
|
+
// A confirmed bridge supersedes any non-bridge preference over the same
|
|
19
|
+
// underlying import (report #2 B6): without this, both policies emit with
|
|
20
|
+
// the same fingerprint (corrupting baseline/Cloud dedupe) and the hand
|
|
21
|
+
// rule flags the wrapper's own implementation file with a self-import fix.
|
|
22
|
+
// The bridge's domain — not its implementation-file exemption — decides
|
|
23
|
+
// supersession, so inside an exempt file the duplicate stays silent too.
|
|
24
|
+
const bridgeGoverned = policies.some((policy) => bridgeGovernsUnderlyingImport(policy, usage));
|
|
18
25
|
const matches = policies.filter((policy) => {
|
|
19
26
|
if (policy.bridge) {
|
|
20
27
|
if (policy.bridge.implementationFiles.some((file) => sameRepoPath(file, usage.file))) {
|
|
21
28
|
return false;
|
|
22
29
|
}
|
|
23
|
-
|
|
24
|
-
if (ownedImportsEqual(usage.source, policy.from)) {
|
|
25
|
-
return usage.imported === policy.bridge.underlyingExportName;
|
|
26
|
-
}
|
|
27
|
-
const subpath = usage.source.slice(policy.from.length + 1);
|
|
28
|
-
const leaf = subpath.split("/").at(-1);
|
|
29
|
-
return (
|
|
30
|
-
leaf === policy.bridge.underlyingExportName &&
|
|
31
|
-
(usage.imported === "default" || usage.imported === policy.bridge.underlyingExportName)
|
|
32
|
-
);
|
|
30
|
+
return bridgeGovernsUnderlyingImport(policy, usage);
|
|
33
31
|
}
|
|
32
|
+
if (bridgeGoverned) return false;
|
|
34
33
|
if (!ownedImportsEqual(policy.from, usage.source)) return false;
|
|
35
34
|
if (policy.imported !== undefined && policy.imported !== usage.imported) return false;
|
|
36
35
|
return true;
|
|
@@ -94,6 +93,25 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
|
|
|
94
93
|
return findings;
|
|
95
94
|
}
|
|
96
95
|
|
|
96
|
+
/** Whether the bridge policy's underlying-import domain covers this usage. */
|
|
97
|
+
function bridgeGovernsUnderlyingImport(
|
|
98
|
+
policy: JsxImportPathPreferredFact,
|
|
99
|
+
usage: UsageImportFact
|
|
100
|
+
): boolean {
|
|
101
|
+
const bridge = policy.bridge;
|
|
102
|
+
if (!bridge) return false;
|
|
103
|
+
if (!ownedImportMatchesRoot(usage.source, policy.from)) return false;
|
|
104
|
+
if (ownedImportsEqual(usage.source, policy.from)) {
|
|
105
|
+
return usage.imported === bridge.underlyingExportName;
|
|
106
|
+
}
|
|
107
|
+
const subpath = usage.source.slice(policy.from.length + 1);
|
|
108
|
+
const leaf = subpath.split("/").at(-1);
|
|
109
|
+
return (
|
|
110
|
+
leaf === bridge.underlyingExportName &&
|
|
111
|
+
(usage.imported === "default" || usage.imported === bridge.underlyingExportName)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
function sameRepoPath(left: string, right: string): boolean {
|
|
98
116
|
return normalizeRepoPath(left) === normalizeRepoPath(right);
|
|
99
117
|
}
|
package/src/rules/rules.test.ts
CHANGED
|
@@ -11,12 +11,15 @@ import {
|
|
|
11
11
|
makeClassNameDynamicFact,
|
|
12
12
|
makeStyleDeclarationFact,
|
|
13
13
|
makeTokenDefinitionFact,
|
|
14
|
+
makeUsageChildContentFact,
|
|
14
15
|
makeUsageComponentFact,
|
|
15
16
|
makeUsageImportFact,
|
|
16
17
|
makeUsageInlineStyleFact,
|
|
17
18
|
makeUsageNodeFact,
|
|
18
19
|
makeUsagePropResolvedFact,
|
|
19
20
|
makeUsageTextChildFact,
|
|
21
|
+
projectSupersededImportPathPreferences,
|
|
22
|
+
RULES,
|
|
20
23
|
ruleA11yRequiredAccessibleName,
|
|
21
24
|
ruleComponentsForbiddenPropValue,
|
|
22
25
|
ruleComponentsPreferLibrary,
|
|
@@ -32,7 +35,8 @@ import {
|
|
|
32
35
|
ruleTokensRequireDualFallback,
|
|
33
36
|
runRules,
|
|
34
37
|
} from "../index.js";
|
|
35
|
-
import type { Fact, FactId } from "../index.js";
|
|
38
|
+
import type { CanonicalBridgeV1, Fact, FactId } from "../index.js";
|
|
39
|
+
import { RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION } from "./a11y-required-accessible-name.js";
|
|
36
40
|
|
|
37
41
|
type ButtonProps = {
|
|
38
42
|
variant?: "primary" | "secondary" | "ghost" | "link";
|
|
@@ -137,6 +141,7 @@ interface ButtonUsageInput {
|
|
|
137
141
|
value: string;
|
|
138
142
|
}>;
|
|
139
143
|
text?: string;
|
|
144
|
+
childContent?: "static-text" | "runtime" | "structural" | "empty";
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
function addButtonUsage(ix: FactIndex, input: ButtonUsageInput): { nodeId: FactId } {
|
|
@@ -175,6 +180,14 @@ function addButtonUsage(ix: FactIndex, input: ButtonUsageInput): { nodeId: FactI
|
|
|
175
180
|
if (input.text !== undefined) {
|
|
176
181
|
ix.add(makeUsageTextChildFact({ nodeId: node.id, text: input.text, index: 0 }));
|
|
177
182
|
}
|
|
183
|
+
if (input.childContent !== undefined) {
|
|
184
|
+
ix.add(
|
|
185
|
+
makeUsageChildContentFact({
|
|
186
|
+
nodeId: node.id,
|
|
187
|
+
content: input.childContent,
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
}
|
|
178
191
|
return { nodeId: node.id };
|
|
179
192
|
}
|
|
180
193
|
|
|
@@ -625,6 +638,129 @@ describe("preferred JSX imports and components", () => {
|
|
|
625
638
|
expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
|
|
626
639
|
});
|
|
627
640
|
|
|
641
|
+
// Report #2 B6 (brownfield-canonical brief 03): a confirmed bridge
|
|
642
|
+
// supersedes any non-bridge preference over the same underlying import —
|
|
643
|
+
// otherwise both emit with the same fingerprint and the hand rule flags the
|
|
644
|
+
// wrapper's own implementation file with a self-import fix.
|
|
645
|
+
describe("bridge supersession of non-bridge import preferences", () => {
|
|
646
|
+
const B6_BRIDGE: CanonicalBridgeV1 = {
|
|
647
|
+
underlying: { packageName: "@mui/material", exportName: "Button" },
|
|
648
|
+
local: {
|
|
649
|
+
componentKey: "src/components/Button.tsx#Button",
|
|
650
|
+
moduleSpecifier: "@/components",
|
|
651
|
+
exportName: "Button",
|
|
652
|
+
implementationFiles: ["src/components/Button.tsx"],
|
|
653
|
+
},
|
|
654
|
+
decision: { state: "confirmed", source: "authored" },
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
it("emits only the bridge finding when a hand-authored record duplicates it", () => {
|
|
658
|
+
const ix = new FactIndex();
|
|
659
|
+
ix.addMany(
|
|
660
|
+
compileGlobalGovernanceFacts({
|
|
661
|
+
jsx: [g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" })],
|
|
662
|
+
canonicalBridges: [B6_BRIDGE],
|
|
663
|
+
})
|
|
664
|
+
);
|
|
665
|
+
ix.add(
|
|
666
|
+
makeUsageImportFact({
|
|
667
|
+
file: "src/app/page.tsx",
|
|
668
|
+
local: "MuiButton",
|
|
669
|
+
imported: "Button",
|
|
670
|
+
source: "@mui/material",
|
|
671
|
+
location: { file: "src/app/page.tsx", line: 1, column: 0 },
|
|
672
|
+
})
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
const findings = ruleJsxPreferredImportPath(ix);
|
|
676
|
+
expect(findings).toHaveLength(1);
|
|
677
|
+
expect(findings[0].attributes).toMatchObject({ canonicalBridge: true });
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("keeps the hand rule silent inside the wrapper implementation file", () => {
|
|
681
|
+
const ix = new FactIndex();
|
|
682
|
+
ix.addMany(
|
|
683
|
+
compileGlobalGovernanceFacts({
|
|
684
|
+
jsx: [g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" })],
|
|
685
|
+
canonicalBridges: [B6_BRIDGE],
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
ix.add(
|
|
689
|
+
makeUsageImportFact({
|
|
690
|
+
file: "src/components/Button.tsx",
|
|
691
|
+
local: "MuiButton",
|
|
692
|
+
imported: "Button",
|
|
693
|
+
source: "@mui/material",
|
|
694
|
+
location: { file: "src/components/Button.tsx", line: 1, column: 0 },
|
|
695
|
+
})
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("leaves the hand rule firing on imports the bridge does not govern", () => {
|
|
702
|
+
const ix = new FactIndex();
|
|
703
|
+
ix.addMany(
|
|
704
|
+
compileGlobalGovernanceFacts({
|
|
705
|
+
jsx: [g.jsx.importPath().prefer("@mui/material", "@/components")],
|
|
706
|
+
canonicalBridges: [B6_BRIDGE],
|
|
707
|
+
})
|
|
708
|
+
);
|
|
709
|
+
ix.add(
|
|
710
|
+
makeUsageImportFact({
|
|
711
|
+
file: "src/app/page.tsx",
|
|
712
|
+
local: "Stepper",
|
|
713
|
+
imported: "Stepper",
|
|
714
|
+
source: "@mui/material",
|
|
715
|
+
location: { file: "src/app/page.tsx", line: 1, column: 0 },
|
|
716
|
+
})
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
const findings = ruleJsxPreferredImportPath(ix);
|
|
720
|
+
expect(findings).toHaveLength(1);
|
|
721
|
+
expect(findings[0].attributes).toMatchObject({ imported: "Stepper" });
|
|
722
|
+
expect(findings[0].attributes?.canonicalBridge).toBeUndefined();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("names each superseded record through the compile-side projection", () => {
|
|
726
|
+
const superseded = projectSupersededImportPathPreferences({
|
|
727
|
+
jsx: [
|
|
728
|
+
g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" }),
|
|
729
|
+
g.jsx.importPath().prefer("@mui/material/Button", "@/components"),
|
|
730
|
+
g.jsx.importPath().prefer("@mui/material", "@/inputs", { imported: "TextField" }),
|
|
731
|
+
g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui"),
|
|
732
|
+
],
|
|
733
|
+
canonicalBridges: [B6_BRIDGE],
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
expect(superseded).toEqual([
|
|
737
|
+
{
|
|
738
|
+
from: "@mui/material",
|
|
739
|
+
imported: "Button",
|
|
740
|
+
to: "@/components",
|
|
741
|
+
packageName: "@mui/material",
|
|
742
|
+
underlyingExportName: "Button",
|
|
743
|
+
localExportName: "Button",
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
from: "@mui/material/Button",
|
|
747
|
+
to: "@/components",
|
|
748
|
+
packageName: "@mui/material",
|
|
749
|
+
underlyingExportName: "Button",
|
|
750
|
+
localExportName: "Button",
|
|
751
|
+
},
|
|
752
|
+
]);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("projects nothing without bridges", () => {
|
|
756
|
+
expect(
|
|
757
|
+
projectSupersededImportPathPreferences({
|
|
758
|
+
jsx: [g.jsx.importPath().prefer("@mui/material", "@/components")],
|
|
759
|
+
})
|
|
760
|
+
).toEqual([]);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
628
764
|
it("does not exempt sibling helpers, stories, barrels, or consumers", () => {
|
|
629
765
|
const ix = new FactIndex();
|
|
630
766
|
ix.addMany(
|
|
@@ -1148,6 +1284,47 @@ describe("a11y/required-accessible-name", () => {
|
|
|
1148
1284
|
}
|
|
1149
1285
|
});
|
|
1150
1286
|
|
|
1287
|
+
it("does not fire for conservatively classified runtime child content", () => {
|
|
1288
|
+
const ix = emptyIndexWithGovernance();
|
|
1289
|
+
addButtonUsage(ix, {
|
|
1290
|
+
file: "apps/x.tsx",
|
|
1291
|
+
nodePath: "0",
|
|
1292
|
+
line: 1,
|
|
1293
|
+
column: 0,
|
|
1294
|
+
props: [{ prop: "children", resolution: "jsx" }],
|
|
1295
|
+
childContent: "runtime",
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it.each(["structural", "empty"] as const)("still fires for %s child content", (childContent) => {
|
|
1302
|
+
const ix = emptyIndexWithGovernance();
|
|
1303
|
+
addButtonUsage(ix, {
|
|
1304
|
+
file: "apps/x.tsx",
|
|
1305
|
+
nodePath: "0",
|
|
1306
|
+
line: 1,
|
|
1307
|
+
column: 0,
|
|
1308
|
+
props: [{ prop: "children", resolution: "jsx" }],
|
|
1309
|
+
childContent,
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(1);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it("does not infer a name from the public children prop-resolution fact", () => {
|
|
1316
|
+
const ix = emptyIndexWithGovernance();
|
|
1317
|
+
addButtonUsage(ix, {
|
|
1318
|
+
file: "apps/x.tsx",
|
|
1319
|
+
nodePath: "0",
|
|
1320
|
+
line: 1,
|
|
1321
|
+
column: 0,
|
|
1322
|
+
props: [{ prop: "children", resolution: "dynamic" }],
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(1);
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1151
1328
|
it("fires for an empty Button with no name source", () => {
|
|
1152
1329
|
const ix = emptyIndexWithGovernance();
|
|
1153
1330
|
addButtonUsage(ix, {
|
|
@@ -1174,6 +1351,11 @@ describe("a11y/required-accessible-name", () => {
|
|
|
1174
1351
|
|
|
1175
1352
|
expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
|
|
1176
1353
|
});
|
|
1354
|
+
|
|
1355
|
+
it("keeps the registry version in lockstep with the emitted finding version", () => {
|
|
1356
|
+
const registered = RULES.find((rule) => rule.id === "a11y/required-accessible-name");
|
|
1357
|
+
expect(registered?.version).toBe(A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION);
|
|
1358
|
+
});
|
|
1177
1359
|
});
|
|
1178
1360
|
|
|
1179
1361
|
// ---------------------------------------------------------------------------
|
|
@@ -2991,7 +3173,7 @@ describe("components/prefer-library", () => {
|
|
|
2991
3173
|
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
|
|
2992
3174
|
});
|
|
2993
3175
|
|
|
2994
|
-
it("
|
|
3176
|
+
it("keeps raw <button> → Button advisory without treating the directory as import-ready", () => {
|
|
2995
3177
|
const ix = new FactIndex();
|
|
2996
3178
|
ix.addMany(
|
|
2997
3179
|
compileGlobalGovernanceFacts({
|
|
@@ -3027,14 +3209,12 @@ describe("components/prefer-library", () => {
|
|
|
3027
3209
|
rawValue: "button",
|
|
3028
3210
|
suggestedComponent: "Button",
|
|
3029
3211
|
suggestedImportSourceKind: "directory",
|
|
3212
|
+
canonicalDirectory: "src/fragments/ui/components",
|
|
3213
|
+
importReady: false,
|
|
3214
|
+
advisory: true,
|
|
3030
3215
|
},
|
|
3031
3216
|
});
|
|
3032
|
-
expect(findings[0].fix).
|
|
3033
|
-
kind: "replaceComponent",
|
|
3034
|
-
from: "button",
|
|
3035
|
-
to: "Button",
|
|
3036
|
-
deterministic: false,
|
|
3037
|
-
});
|
|
3217
|
+
expect(findings[0].fix).toBeUndefined();
|
|
3038
3218
|
});
|
|
3039
3219
|
|
|
3040
3220
|
it("flags a raw element by canonical vocab id when display name diverges (Modal → Dialog)", () => {
|
|
@@ -20,6 +20,7 @@ import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
|
|
|
20
20
|
import {
|
|
21
21
|
detectRawColor,
|
|
22
22
|
emitFix,
|
|
23
|
+
hoppedValue,
|
|
23
24
|
indexComponentByNodeId,
|
|
24
25
|
isExemptColor,
|
|
25
26
|
readUsageNode,
|
|
@@ -51,8 +52,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
51
52
|
if (isExemptColor(color, policy.except)) continue;
|
|
52
53
|
|
|
53
54
|
const resolution = resolveColorToken(decl.property, color, colorTokens);
|
|
55
|
+
const hopped = hoppedValue(decl);
|
|
54
56
|
const fixEmission =
|
|
55
|
-
resolution?.kind === "exact"
|
|
57
|
+
resolution?.kind === "exact" && !hopped
|
|
56
58
|
? buildTokenFix(decl.property, decl.value, color, resolution, ix)
|
|
57
59
|
: undefined;
|
|
58
60
|
const evidenceIds = resolution
|
|
@@ -64,7 +66,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
64
66
|
ruleId: RULE_ID,
|
|
65
67
|
ruleVersion: RULE_VERSION,
|
|
66
68
|
severity: policy.severity,
|
|
67
|
-
message:
|
|
69
|
+
message:
|
|
70
|
+
colorMessage(color, `\`${decl.property}\``, resolution, preferLabel) +
|
|
71
|
+
(hopped?.suffix ?? ""),
|
|
68
72
|
location: decl.location,
|
|
69
73
|
evidence: ix.evidence(evidenceIds),
|
|
70
74
|
fingerprintIdentity: {
|
|
@@ -84,6 +88,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
84
88
|
suggestedToken: resolution?.token.name,
|
|
85
89
|
...(resolution ? { tokenMatch: resolution.kind } : {}),
|
|
86
90
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
91
|
+
...(hopped?.attributes ?? {}),
|
|
87
92
|
},
|
|
88
93
|
})
|
|
89
94
|
);
|
|
@@ -138,8 +143,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
138
143
|
|
|
139
144
|
const componentEvidenceId = componentByNode.get(node.id)?.id;
|
|
140
145
|
const resolution = resolveColorToken(inline.property, color, colorTokens);
|
|
146
|
+
const hopped = hoppedValue(inline);
|
|
141
147
|
const fixEmission =
|
|
142
|
-
resolution?.kind === "exact"
|
|
148
|
+
resolution?.kind === "exact" && !hopped
|
|
143
149
|
? buildTokenFix(inline.property, inline.value, color, resolution, ix)
|
|
144
150
|
: undefined;
|
|
145
151
|
const baseEvidence = componentEvidenceId
|
|
@@ -152,7 +158,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
152
158
|
ruleId: RULE_ID,
|
|
153
159
|
ruleVersion: RULE_VERSION,
|
|
154
160
|
severity: policy.severity,
|
|
155
|
-
message:
|
|
161
|
+
message:
|
|
162
|
+
colorMessage(color, `inline \`${inline.property}\``, resolution, preferLabel) +
|
|
163
|
+
(hopped?.suffix ?? ""),
|
|
156
164
|
location: node.location,
|
|
157
165
|
evidence: ix.evidence(evidenceIds),
|
|
158
166
|
fingerprintIdentity: {
|
|
@@ -172,6 +180,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
|
|
|
172
180
|
suggestedToken: resolution?.token.name,
|
|
173
181
|
...(resolution ? { tokenMatch: resolution.kind } : {}),
|
|
174
182
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
183
|
+
...(hopped?.attributes ?? {}),
|
|
175
184
|
},
|
|
176
185
|
})
|
|
177
186
|
);
|
|
@@ -34,6 +34,7 @@ import { localTokenCandidates } from "./token-candidates.js";
|
|
|
34
34
|
import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
|
|
35
35
|
import {
|
|
36
36
|
emitFix,
|
|
37
|
+
hoppedValue,
|
|
37
38
|
indexComponentByNodeId,
|
|
38
39
|
parseLengthValue,
|
|
39
40
|
readUsageNode,
|
|
@@ -83,14 +84,17 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
|
|
|
83
84
|
if (!resolution) continue;
|
|
84
85
|
const tokenIds = resolutionTokenIds(resolution);
|
|
85
86
|
const evidenceIds = tokenIds.length ? [decl.id, policy.id, ...tokenIds] : [decl.id, policy.id];
|
|
86
|
-
const
|
|
87
|
+
const hopped = hoppedValue(decl);
|
|
88
|
+
const fixEmission = hopped ? undefined : dimensionFix(decl.property, resolution, ix);
|
|
87
89
|
|
|
88
90
|
findings.push(
|
|
89
91
|
makeFinding({
|
|
90
92
|
ruleId: RULE_ID,
|
|
91
93
|
ruleVersion: RULE_VERSION,
|
|
92
94
|
severity: policy.severity,
|
|
93
|
-
message:
|
|
95
|
+
message:
|
|
96
|
+
dimensionMessage(decl.value, decl.property, preferLabel, resolution, false) +
|
|
97
|
+
(hopped?.suffix ?? ""),
|
|
94
98
|
location: decl.location,
|
|
95
99
|
evidence: ix.evidence(evidenceIds),
|
|
96
100
|
fingerprintIdentity: {
|
|
@@ -108,6 +112,7 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
|
|
|
108
112
|
source: "css",
|
|
109
113
|
suggestedToken: resolutionSuggestedToken(resolution),
|
|
110
114
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
115
|
+
...(hopped?.attributes ?? {}),
|
|
111
116
|
},
|
|
112
117
|
})
|
|
113
118
|
);
|
|
@@ -131,14 +136,17 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
|
|
|
131
136
|
: [node.id, inline.id, policy.id];
|
|
132
137
|
const tokenIds = resolutionTokenIds(resolution);
|
|
133
138
|
const evidenceIds = tokenIds.length ? [...baseEvidence, ...tokenIds] : baseEvidence;
|
|
134
|
-
const
|
|
139
|
+
const hopped = hoppedValue(inline);
|
|
140
|
+
const fixEmission = hopped ? undefined : dimensionFix(inline.property, resolution, ix);
|
|
135
141
|
|
|
136
142
|
findings.push(
|
|
137
143
|
makeFinding({
|
|
138
144
|
ruleId: RULE_ID,
|
|
139
145
|
ruleVersion: RULE_VERSION,
|
|
140
146
|
severity: policy.severity,
|
|
141
|
-
message:
|
|
147
|
+
message:
|
|
148
|
+
dimensionMessage(inline.value, inline.property, preferLabel, resolution, true) +
|
|
149
|
+
(hopped?.suffix ?? ""),
|
|
142
150
|
location: node.location,
|
|
143
151
|
evidence: ix.evidence(evidenceIds),
|
|
144
152
|
fingerprintIdentity: {
|
|
@@ -156,6 +164,7 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
|
|
|
156
164
|
source: "jsx",
|
|
157
165
|
suggestedToken: resolutionSuggestedToken(resolution),
|
|
158
166
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
167
|
+
...(hopped?.attributes ?? {}),
|
|
159
168
|
},
|
|
160
169
|
})
|
|
161
170
|
);
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import type { Finding } from "./types.js";
|
|
46
46
|
import {
|
|
47
47
|
emitFix,
|
|
48
|
+
hoppedValue,
|
|
48
49
|
indexComponentByNodeId,
|
|
49
50
|
readUsageNode,
|
|
50
51
|
tokenSymbolsInText,
|
|
@@ -111,13 +112,15 @@ function checkDeclaration(
|
|
|
111
112
|
tokens: lookupFor(scale),
|
|
112
113
|
});
|
|
113
114
|
if (!checked) return null;
|
|
114
|
-
const
|
|
115
|
+
const hopped = hoppedValue(decl);
|
|
116
|
+
const fixEmission = hopped ? undefined : buildSpacingFix(decl.property, checked, ix);
|
|
115
117
|
|
|
116
118
|
return makeFinding({
|
|
117
119
|
ruleId: RULE_ID,
|
|
118
120
|
ruleVersion: RULE_VERSION,
|
|
119
121
|
severity: policy.severity,
|
|
120
|
-
message:
|
|
122
|
+
message:
|
|
123
|
+
spacingMessage(decl.property, decl.value, allowed, scale, checked) + (hopped?.suffix ?? ""),
|
|
121
124
|
location: decl.location,
|
|
122
125
|
evidence: ix.evidence([decl.id, policy.id, scale.id]),
|
|
123
126
|
fingerprintIdentity: {
|
|
@@ -144,6 +147,7 @@ function checkDeclaration(
|
|
|
144
147
|
assumedEmBasePx: checked.assumedEmBasePx,
|
|
145
148
|
source: "css",
|
|
146
149
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
150
|
+
...(hopped?.attributes ?? {}),
|
|
147
151
|
},
|
|
148
152
|
});
|
|
149
153
|
}
|
|
@@ -171,7 +175,8 @@ function checkInlineStyle(
|
|
|
171
175
|
bareNumberFix: inline.valueKind === "number",
|
|
172
176
|
});
|
|
173
177
|
if (!checked) return null;
|
|
174
|
-
const
|
|
178
|
+
const hopped = hoppedValue(inline);
|
|
179
|
+
const fixEmission = hopped ? undefined : buildSpacingFix(inline.property, checked, ix);
|
|
175
180
|
|
|
176
181
|
const node = readUsageNode(ix, inline.nodeId);
|
|
177
182
|
if (!node) return null;
|
|
@@ -185,7 +190,9 @@ function checkInlineStyle(
|
|
|
185
190
|
ruleId: RULE_ID,
|
|
186
191
|
ruleVersion: RULE_VERSION,
|
|
187
192
|
severity: policy.severity,
|
|
188
|
-
message:
|
|
193
|
+
message:
|
|
194
|
+
spacingMessage(inline.property, inline.value, allowed, scale, checked) +
|
|
195
|
+
(hopped?.suffix ?? ""),
|
|
189
196
|
location: node.location,
|
|
190
197
|
evidence: ix.evidence(evidenceIds),
|
|
191
198
|
fingerprintIdentity: {
|
|
@@ -212,6 +219,7 @@ function checkInlineStyle(
|
|
|
212
219
|
assumedEmBasePx: checked.assumedEmBasePx,
|
|
213
220
|
source: "jsx",
|
|
214
221
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
222
|
+
...(hopped?.attributes ?? {}),
|
|
215
223
|
},
|
|
216
224
|
});
|
|
217
225
|
}
|
|
@@ -31,6 +31,7 @@ import type { Finding } from "./types.js";
|
|
|
31
31
|
import {
|
|
32
32
|
indexComponentByNodeId,
|
|
33
33
|
emitFix,
|
|
34
|
+
hoppedValue,
|
|
34
35
|
matchesScale,
|
|
35
36
|
nearestSignedScaleValue,
|
|
36
37
|
normalizeLengthForScale,
|
|
@@ -61,13 +62,16 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
61
62
|
if (decl.property.toLowerCase() !== "font-size") continue;
|
|
62
63
|
const checked = checkFontSize(decl.value, allowed, scale, tokens);
|
|
63
64
|
if (!checked) continue;
|
|
64
|
-
const
|
|
65
|
+
const hopped = hoppedValue(decl);
|
|
66
|
+
const fixEmission = hopped ? undefined : buildFix(decl.property, checked, ix);
|
|
65
67
|
findings.push(
|
|
66
68
|
makeFinding({
|
|
67
69
|
ruleId: RULE_ID,
|
|
68
70
|
ruleVersion: RULE_VERSION,
|
|
69
71
|
severity: policy.severity,
|
|
70
|
-
message:
|
|
72
|
+
message:
|
|
73
|
+
typographyMessage(decl.property, decl.value, allowed, scale.unit, checked) +
|
|
74
|
+
(hopped?.suffix ?? ""),
|
|
71
75
|
location: decl.location,
|
|
72
76
|
evidence: ix.evidence([decl.id, policy.id, scale.id]),
|
|
73
77
|
fingerprintIdentity: {
|
|
@@ -88,6 +92,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
88
92
|
matchedToken: checked.matchedToken,
|
|
89
93
|
source: "css",
|
|
90
94
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
95
|
+
...(hopped?.attributes ?? {}),
|
|
91
96
|
},
|
|
92
97
|
})
|
|
93
98
|
);
|
|
@@ -105,7 +110,8 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
105
110
|
inline.valueKind === "number"
|
|
106
111
|
);
|
|
107
112
|
if (!checked) continue;
|
|
108
|
-
const
|
|
113
|
+
const hopped = hoppedValue(inline);
|
|
114
|
+
const fixEmission = hopped ? undefined : buildFix(inline.property, checked, ix);
|
|
109
115
|
const node = readUsageNode(ix, inline.nodeId);
|
|
110
116
|
if (!node) continue;
|
|
111
117
|
const componentEvidenceId = componentByNode.get(node.id)?.id;
|
|
@@ -117,7 +123,9 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
117
123
|
ruleId: RULE_ID,
|
|
118
124
|
ruleVersion: RULE_VERSION,
|
|
119
125
|
severity: policy.severity,
|
|
120
|
-
message:
|
|
126
|
+
message:
|
|
127
|
+
typographyMessage(inline.property, inline.value, allowed, scale.unit, checked) +
|
|
128
|
+
(hopped?.suffix ?? ""),
|
|
121
129
|
location: node.location,
|
|
122
130
|
evidence: ix.evidence(evidenceIds),
|
|
123
131
|
fingerprintIdentity: {
|
|
@@ -138,6 +146,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
138
146
|
matchedToken: checked.matchedToken,
|
|
139
147
|
source: "jsx",
|
|
140
148
|
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
149
|
+
...(hopped?.attributes ?? {}),
|
|
141
150
|
},
|
|
142
151
|
})
|
|
143
152
|
);
|
package/src/rules/tiers.ts
CHANGED
|
@@ -51,6 +51,7 @@ export const RULE_TIER: Readonly<Record<string, RuleTier>> = {
|
|
|
51
51
|
"tokens/require-dual-fallback": "hygiene",
|
|
52
52
|
"theme/no-theme-coupled-literal": "hygiene",
|
|
53
53
|
"a11y/required-accessible-name": "hygiene",
|
|
54
|
+
"a11y/standard": "hygiene",
|
|
54
55
|
// Composition constraints are authored contracts, but not part of the
|
|
55
56
|
// two-choice headline contract; they self-activate when patterns are declared
|
|
56
57
|
// (the compiler injects their configs), so off-by-default here is harmless.
|
package/src/rules/utils.ts
CHANGED
|
@@ -232,6 +232,45 @@ export function readStyleDeclaration(ix: FactIndex, id: FactId): StyleDeclaratio
|
|
|
232
232
|
return fact && fact.kind === "style_declaration" ? fact : undefined;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/** What a raw-value rule needs to report a value that arrived through a hop. */
|
|
236
|
+
export interface HoppedValue {
|
|
237
|
+
/** Appended to the message so the use site names the declaration a reader
|
|
238
|
+
* cannot see from the finding's own line. */
|
|
239
|
+
suffix: string;
|
|
240
|
+
/** Merged into the finding's attributes for JSON/SARIF consumers. */
|
|
241
|
+
attributes: {
|
|
242
|
+
valueFrom: "const-binding";
|
|
243
|
+
declaredAs: string;
|
|
244
|
+
declaredAt: string;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Shared reading of `valueFrom` provenance for the raw-value style rules
|
|
250
|
+
* (color, spacing, dimensions, typography). One helper so a const-resolved
|
|
251
|
+
* value reports identically everywhere instead of becoming a color special
|
|
252
|
+
* case.
|
|
253
|
+
*
|
|
254
|
+
* Callers must also drop their fix when this returns a value: the authored text
|
|
255
|
+
* at the finding's location is an identifier, so replacing "the value" there
|
|
256
|
+
* would rewrite code the user never wrote.
|
|
257
|
+
*/
|
|
258
|
+
export function hoppedValue(fact: {
|
|
259
|
+
valueFrom?: { kind: "const-binding"; name: string; location: { file: string; line: number } };
|
|
260
|
+
}): HoppedValue | undefined {
|
|
261
|
+
const from = fact.valueFrom;
|
|
262
|
+
if (!from) return undefined;
|
|
263
|
+
const declaredAt = `${from.location.file}:${from.location.line}`;
|
|
264
|
+
return {
|
|
265
|
+
suffix: ` Value comes from \`${from.name}\`, declared at ${declaredAt}.`,
|
|
266
|
+
attributes: {
|
|
267
|
+
valueFrom: "const-binding",
|
|
268
|
+
declaredAs: from.name,
|
|
269
|
+
declaredAt,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
235
274
|
/**
|
|
236
275
|
* The set of CSS custom-property names a token vocabulary defines, normalized to
|
|
237
276
|
* `--`-prefixed form. Shared by the token-vocabulary rules
|