@usefragments/core 1.3.0 → 1.5.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-MLRDNSSA.js → chunk-HXGE3O2F.js} +39 -1
- package/dist/chunk-HXGE3O2F.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-DYSirwJu.d.ts → governance-BLsyk56o.d.ts} +21 -0
- package/dist/index.d.ts +324 -82
- package/dist/index.js +507 -120
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/codes/codes.ts +9 -0
- package/src/conform.ts +5 -0
- package/src/constants.ts +3 -0
- package/src/facts/builders.ts +46 -2
- package/src/facts/facts.test.ts +30 -0
- package/src/facts/index.ts +6 -0
- package/src/facts/types.ts +75 -2
- package/src/governance.test.ts +26 -0
- package/src/identity/classify.test.ts +185 -0
- package/src/identity/classify.ts +257 -0
- package/src/identity/component-key.test.ts +32 -0
- package/src/identity/component-key.ts +35 -0
- package/src/identity/usage.ts +39 -0
- package/src/index.ts +25 -0
- package/src/rules/components-prefer-library.test.ts +196 -0
- package/src/rules/components-prefer-library.ts +68 -1
- package/src/rules/components-shadow-component.test.ts +145 -0
- package/src/rules/components-shadow-component.ts +81 -0
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +7 -0
- package/src/rules/styles-no-raw-color.test.ts +213 -0
- package/src/rules/styles-no-raw-color.ts +125 -0
- package/src/rules/taxonomy.test.ts +16 -1
- package/src/rules/tiers.ts +7 -5
- package/src/schema.ts +21 -2
- package/src/token-types.ts +12 -0
- package/src/tokens/categories.ts +54 -24
- package/src/tokens/scss.test.ts +94 -0
- package/src/tokens/scss.ts +11 -4
- package/src/types.ts +23 -0
- package/dist/chunk-MLRDNSSA.js.map +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CanonicalSource, GovernanceSeverity } from "../governance.js";
|
|
2
2
|
import type {
|
|
3
|
+
FactId,
|
|
3
4
|
FactIndex,
|
|
4
5
|
UsageImportFact,
|
|
5
6
|
UsageNodeFact,
|
|
@@ -262,12 +263,22 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
262
263
|
const textByNode = indexTextChildrenByNodeId(ix);
|
|
263
264
|
const classTokensByNode = indexClassTokensByNode(ix);
|
|
264
265
|
const reimplTargets = canonicalReimplTargets(sources, mappings);
|
|
266
|
+
// #9e — nodes whose local import a Node-side module resolver already proved
|
|
267
|
+
// resolves INSIDE a directory canonical source (the extractor emitted a
|
|
268
|
+
// `usage_component` whose id points at that directory). These are the genuine
|
|
269
|
+
// canonical primitives imported by a relative/`@/` path; a browser-safe string
|
|
270
|
+
// comparison of the raw specifier could never recognize them, so the resolved
|
|
271
|
+
// fact is the identity of record. Never flag them as import impostors.
|
|
272
|
+
const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
|
|
273
|
+
const shadowRenderRootNodeIds = indexShadowRenderRootNodeIds(ix);
|
|
265
274
|
const findings: Finding[] = [];
|
|
266
275
|
const seenImportFixes = new Set<string>();
|
|
267
276
|
|
|
268
277
|
for (const node of ix.byKind("usage_node")) {
|
|
269
278
|
if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
|
|
270
279
|
if (node.element.includes(".")) continue;
|
|
280
|
+
if (canonicalDirectoryResolvedNodes.has(node.id)) continue;
|
|
281
|
+
if (shadowRenderRootNodeIds.has(node.id)) continue;
|
|
271
282
|
const props = propsByNode.get(node.id) ?? [];
|
|
272
283
|
const textChildren = textByNode.get(node.id) ?? [];
|
|
273
284
|
const importsForFile = importsByFileAndLocal.get(node.file);
|
|
@@ -594,6 +605,20 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
594
605
|
return findings;
|
|
595
606
|
}
|
|
596
607
|
|
|
608
|
+
function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
|
|
609
|
+
const identities = ix.byKind("component_identity");
|
|
610
|
+
if (identities.length === 0) return new Set();
|
|
611
|
+
|
|
612
|
+
const nodeIds = new Set<FactId>();
|
|
613
|
+
for (const identity of identities) {
|
|
614
|
+
if (identity.state !== "shadow") continue;
|
|
615
|
+
for (const evidenceId of identity.evidence) {
|
|
616
|
+
if (ix.get(evidenceId)?.kind === "usage_node") nodeIds.add(evidenceId);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return nodeIds;
|
|
620
|
+
}
|
|
621
|
+
|
|
597
622
|
interface BuiltInRawHtmlMatch {
|
|
598
623
|
canonical: string;
|
|
599
624
|
tier: RawHtmlPrecisionTier;
|
|
@@ -1065,7 +1090,49 @@ function sourceLabel(source: CanonicalSource): string {
|
|
|
1065
1090
|
function canonicalImportPath(source: CanonicalSource): string | undefined {
|
|
1066
1091
|
if (source.kind === "npm") return source.specifier;
|
|
1067
1092
|
if (source.kind === "registry") return source.importPath;
|
|
1068
|
-
|
|
1093
|
+
// #9e — a directory canonical source *does* have a redirect target: its own
|
|
1094
|
+
// directory path. Returning it lets the import-identity branch point a local
|
|
1095
|
+
// impostor (`<Button>` from `../features/FakeButton`) back at the canonical
|
|
1096
|
+
// directory instead of `continue`-ing to zero findings. Gate on an explicit
|
|
1097
|
+
// include list: without curated NAMES, `sourceIncludesComponent` matches every
|
|
1098
|
+
// PascalCase import, so a redirect target here would blanket-flag them all
|
|
1099
|
+
// (#224). Include-less directory sources keep the old no-target behavior.
|
|
1100
|
+
return source.include?.length ? source.path : undefined;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* #9e — index the `usage_node` ids whose import a Node-side module resolver
|
|
1105
|
+
* proved resolves INSIDE a directory canonical source. The scan orchestrator's
|
|
1106
|
+
* `resolveComponentId` seam emits a `usage_component` fact for such imports with
|
|
1107
|
+
* a `<canonicalDirPath>#<Name>` component id; this recovers those node ids so the
|
|
1108
|
+
* rule can treat the genuine local canonical primitive as already-compliant.
|
|
1109
|
+
*/
|
|
1110
|
+
function indexCanonicalDirectoryResolvedNodes(
|
|
1111
|
+
ix: FactIndex,
|
|
1112
|
+
sources: readonly CanonicalSource[]
|
|
1113
|
+
): Set<FactId> {
|
|
1114
|
+
const out = new Set<FactId>();
|
|
1115
|
+
const directoryPaths = sources
|
|
1116
|
+
.filter((source): source is Extract<CanonicalSource, { kind: "directory" }> =>
|
|
1117
|
+
source.kind === "directory"
|
|
1118
|
+
)
|
|
1119
|
+
.map((source) => normalizePath(source.path));
|
|
1120
|
+
if (directoryPaths.length === 0) return out;
|
|
1121
|
+
for (const fact of ix.byKind("usage_component")) {
|
|
1122
|
+
const module = componentIdModule(fact.componentId);
|
|
1123
|
+
if (module === undefined) continue;
|
|
1124
|
+
const normalizedModule = normalizePath(module);
|
|
1125
|
+
if (directoryPaths.some((path) => isPathInSource(normalizedModule, path))) {
|
|
1126
|
+
out.add(fact.nodeId);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Extract the module portion (`before#`) of a `module#Export` component id. */
|
|
1133
|
+
function componentIdModule(componentId: string): string | undefined {
|
|
1134
|
+
const hash = componentId.indexOf("#");
|
|
1135
|
+
return hash > 0 ? componentId.slice(0, hash) : undefined;
|
|
1069
1136
|
}
|
|
1070
1137
|
|
|
1071
1138
|
function isCanonicalSourceImplementationFile(
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BLOCKING_RULE_ALLOWLIST,
|
|
5
|
+
compileGlobalGovernanceFacts,
|
|
6
|
+
FactIndex,
|
|
7
|
+
isDenyEligible,
|
|
8
|
+
makeComponentDefinitionFact,
|
|
9
|
+
makeComponentIdentityFact,
|
|
10
|
+
makeUsageComponentFact,
|
|
11
|
+
makeUsageNodeFact,
|
|
12
|
+
ruleComponentsShadowComponent,
|
|
13
|
+
} from "../index.js";
|
|
14
|
+
|
|
15
|
+
describe("components/shadow-component", () => {
|
|
16
|
+
it("emits one definition finding with usage count, blast radius, and a stable fingerprint", () => {
|
|
17
|
+
const first = shadowIndex({ definitionLine: 8 });
|
|
18
|
+
const moved = shadowIndex({ definitionLine: 80 });
|
|
19
|
+
|
|
20
|
+
const finding = ruleComponentsShadowComponent(first)[0];
|
|
21
|
+
const movedFinding = ruleComponentsShadowComponent(moved)[0];
|
|
22
|
+
|
|
23
|
+
expect(finding).toMatchObject({
|
|
24
|
+
ruleId: "components/shadow-component",
|
|
25
|
+
level: "warn",
|
|
26
|
+
location: { file: "src/components/MyButton.tsx", line: 8, column: 2 },
|
|
27
|
+
attributes: {
|
|
28
|
+
canonicalTarget: "Button",
|
|
29
|
+
usageCount: 3,
|
|
30
|
+
blastRadius: 2,
|
|
31
|
+
confidence: "confirmed",
|
|
32
|
+
state: "shadow",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(finding?.attributes).not.toHaveProperty("advisory");
|
|
36
|
+
expect(finding?.fingerprint).toBe(movedFinding?.fingerprint);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps review-tier variants permanently advisory at info severity", () => {
|
|
40
|
+
const ix = shadowIndex({ state: "variant", confidence: "review", severity: "error" });
|
|
41
|
+
|
|
42
|
+
const finding = ruleComponentsShadowComponent(ix)[0];
|
|
43
|
+
|
|
44
|
+
expect(finding).toMatchObject({
|
|
45
|
+
severity: "minor",
|
|
46
|
+
level: "warn",
|
|
47
|
+
attributes: {
|
|
48
|
+
canonicalTarget: "Button",
|
|
49
|
+
confidence: "review",
|
|
50
|
+
state: "variant",
|
|
51
|
+
advisory: true,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(isDenyEligible(finding!, true)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("honors an error severity for a confirmed shadow but never enters the write deny-set", () => {
|
|
58
|
+
const finding = ruleComponentsShadowComponent(shadowIndex({ severity: "error" }))[0];
|
|
59
|
+
|
|
60
|
+
expect(finding?.level).toBe("error");
|
|
61
|
+
expect(finding?.attributes).not.toHaveProperty("advisory");
|
|
62
|
+
expect(BLOCKING_RULE_ALLOWLIST.has("components/shadow-component")).toBe(false);
|
|
63
|
+
expect(isDenyEligible(finding!, false)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("throws rather than emitting an evidence-free finding", () => {
|
|
67
|
+
const ix = shadowIndex();
|
|
68
|
+
const identity = ix.byKind("component_identity")[0]!;
|
|
69
|
+
const empty = new FactIndex();
|
|
70
|
+
empty.addMany(
|
|
71
|
+
ix
|
|
72
|
+
.all()
|
|
73
|
+
.filter((fact) => fact.id !== identity.id)
|
|
74
|
+
);
|
|
75
|
+
empty.add(makeComponentIdentityFact({ ...identity, evidence: [] }));
|
|
76
|
+
|
|
77
|
+
expect(() => ruleComponentsShadowComponent(empty)).toThrow(
|
|
78
|
+
"findings must carry at least one evidence fact"
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function shadowIndex(
|
|
84
|
+
options: {
|
|
85
|
+
state?: "shadow" | "variant";
|
|
86
|
+
confidence?: "confirmed" | "review";
|
|
87
|
+
severity?: "warn" | "error";
|
|
88
|
+
definitionLine?: number;
|
|
89
|
+
} = {}
|
|
90
|
+
): FactIndex {
|
|
91
|
+
const componentKey = "src/components/MyButton.tsx#MyButton";
|
|
92
|
+
const ix = new FactIndex();
|
|
93
|
+
ix.addMany(
|
|
94
|
+
compileGlobalGovernanceFacts({
|
|
95
|
+
rules: {
|
|
96
|
+
"components/shadow-component": {
|
|
97
|
+
enabled: true,
|
|
98
|
+
severity: options.severity ?? "warn",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
const definition = makeComponentDefinitionFact({
|
|
104
|
+
file: "src/components/MyButton.tsx",
|
|
105
|
+
exportName: "MyButton",
|
|
106
|
+
componentKey,
|
|
107
|
+
renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
|
|
108
|
+
propSurface: ["children", "disabled"],
|
|
109
|
+
});
|
|
110
|
+
const renderRoot = makeUsageNodeFact({
|
|
111
|
+
file: definition.file,
|
|
112
|
+
nodePath: "0:0",
|
|
113
|
+
element: "button",
|
|
114
|
+
interactive: true,
|
|
115
|
+
location: {
|
|
116
|
+
file: definition.file,
|
|
117
|
+
line: options.definitionLine ?? 8,
|
|
118
|
+
column: 2,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
ix.addMany([definition, renderRoot]);
|
|
122
|
+
ix.add(
|
|
123
|
+
makeComponentIdentityFact({
|
|
124
|
+
componentKey,
|
|
125
|
+
state: options.state ?? "shadow",
|
|
126
|
+
confidence: options.confidence ?? "confirmed",
|
|
127
|
+
canonicalTarget: "Button",
|
|
128
|
+
evidence: [definition.id, renderRoot.id],
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
for (const [index, file] of ["src/App.tsx", "src/App.tsx", "src/Checkout.tsx"].entries()) {
|
|
133
|
+
const node = makeUsageNodeFact({
|
|
134
|
+
file,
|
|
135
|
+
nodePath: `0:${index}`,
|
|
136
|
+
element: "MyButton",
|
|
137
|
+
location: { file, line: index + 1, column: 0 },
|
|
138
|
+
});
|
|
139
|
+
ix.addMany([
|
|
140
|
+
node,
|
|
141
|
+
makeUsageComponentFact({ nodeId: node.id, componentId: definition.componentId }),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
return ix;
|
|
145
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentDefinitionFact,
|
|
3
|
+
ComponentIdentityFact,
|
|
4
|
+
FactIndex,
|
|
5
|
+
FactLocation,
|
|
6
|
+
} from "../facts/index.js";
|
|
7
|
+
import { indexComponentIdentityUsage } from "../identity/usage.js";
|
|
8
|
+
|
|
9
|
+
import { makeFinding } from "./finding.js";
|
|
10
|
+
import type { Finding } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const RULE_ID = "components/shadow-component";
|
|
13
|
+
export const RULE_VERSION = "1";
|
|
14
|
+
|
|
15
|
+
export function ruleComponentsShadowComponent(ix: FactIndex): Finding[] {
|
|
16
|
+
const policy = ix.policy.ruleConfig(RULE_ID);
|
|
17
|
+
if (!policy?.enabled) return [];
|
|
18
|
+
|
|
19
|
+
const definitions = new Map(
|
|
20
|
+
ix.byKind("component_definition").map((definition) => [definition.componentKey, definition])
|
|
21
|
+
);
|
|
22
|
+
const usageByComponent = indexComponentIdentityUsage(ix);
|
|
23
|
+
const findings: Finding[] = [];
|
|
24
|
+
|
|
25
|
+
for (const identity of ix.byKind("component_identity")) {
|
|
26
|
+
const reviewVariant = identity.state === "variant" && identity.confidence === "review";
|
|
27
|
+
if (identity.state !== "shadow" && !reviewVariant) continue;
|
|
28
|
+
|
|
29
|
+
const definition = definitions.get(identity.componentKey);
|
|
30
|
+
if (!definition) continue;
|
|
31
|
+
const usage = usageByComponent.get(identity.componentKey);
|
|
32
|
+
const usageCount = usage?.usageCount ?? 0;
|
|
33
|
+
const blastRadius = usage?.blastRadius ?? 0;
|
|
34
|
+
|
|
35
|
+
findings.push(
|
|
36
|
+
makeFinding({
|
|
37
|
+
ruleId: RULE_ID,
|
|
38
|
+
ruleVersion: RULE_VERSION,
|
|
39
|
+
severity: reviewVariant ? "info" : (policy.severity ?? "warn"),
|
|
40
|
+
message: reviewVariant
|
|
41
|
+
? `${definition.exportName} wraps ${identity.canonicalTarget ?? "a canonical component"}; sanction it as a variant or consolidate its usages.`
|
|
42
|
+
: `${definition.exportName} shadows ${identity.canonicalTarget ?? "a canonical component"} across ${usageCount} usage${usageCount === 1 ? "" : "s"}.`,
|
|
43
|
+
location: definitionLocation(ix, definition, identity),
|
|
44
|
+
evidence: ix.evidence(identity.evidence),
|
|
45
|
+
fingerprintIdentity: {
|
|
46
|
+
componentKey: identity.componentKey,
|
|
47
|
+
canonicalTarget: identity.canonicalTarget,
|
|
48
|
+
},
|
|
49
|
+
attributes: {
|
|
50
|
+
canonicalTarget: identity.canonicalTarget,
|
|
51
|
+
usageCount,
|
|
52
|
+
blastRadius,
|
|
53
|
+
confidence: identity.confidence,
|
|
54
|
+
state: identity.state,
|
|
55
|
+
...(reviewVariant ? { advisory: true } : {}),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function definitionLocation(
|
|
65
|
+
ix: FactIndex,
|
|
66
|
+
definition: ComponentDefinitionFact,
|
|
67
|
+
identity: ComponentIdentityFact
|
|
68
|
+
): FactLocation {
|
|
69
|
+
const evidence = ix.evidence(identity.evidence).map(({ fact }) => fact);
|
|
70
|
+
const inDefinitionFile = evidence.find(
|
|
71
|
+
(fact) => "location" in fact && fact.location?.file === definition.file
|
|
72
|
+
);
|
|
73
|
+
if (inDefinitionFile && "location" in inDefinitionFile && inDefinitionFile.location) {
|
|
74
|
+
return inDefinitionFile.location;
|
|
75
|
+
}
|
|
76
|
+
const sourceLocated = evidence.find((fact) => "location" in fact && fact.location);
|
|
77
|
+
if (sourceLocated && "location" in sourceLocated && sourceLocated.location) {
|
|
78
|
+
return sourceLocated.location;
|
|
79
|
+
}
|
|
80
|
+
return { file: definition.file, line: 1, column: 0 };
|
|
81
|
+
}
|
|
@@ -12,6 +12,7 @@ export const RULE_FIX_AVAILABLE = {
|
|
|
12
12
|
"imports/preferred-path": true,
|
|
13
13
|
"components/preferred-component": true,
|
|
14
14
|
"components/prefer-library": true,
|
|
15
|
+
"components/shadow-component": false,
|
|
15
16
|
"styles/no-raw-color": true,
|
|
16
17
|
"styles/no-raw-dimensions": true,
|
|
17
18
|
"styles/no-raw-spacing": true,
|
package/src/rules/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { FactIndex } from "../facts/index.js";
|
|
|
11
11
|
|
|
12
12
|
import { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
|
|
13
13
|
import { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
|
|
14
|
+
import { ruleComponentsShadowComponent } from "./components-shadow-component.js";
|
|
14
15
|
import { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
|
|
15
16
|
import { ruleComponentsUnknownProp } from "./components-unknown-prop.js";
|
|
16
17
|
import { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
|
|
@@ -71,6 +72,11 @@ export const RULES: readonly Rule[] = [
|
|
|
71
72
|
version: "1",
|
|
72
73
|
run: ruleComponentsPreferLibrary,
|
|
73
74
|
},
|
|
75
|
+
{
|
|
76
|
+
id: "components/shadow-component",
|
|
77
|
+
version: "1",
|
|
78
|
+
run: ruleComponentsShadowComponent,
|
|
79
|
+
},
|
|
74
80
|
{
|
|
75
81
|
id: "styles/no-raw-color",
|
|
76
82
|
version: "1",
|
|
@@ -189,6 +195,7 @@ export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./emit-gate.js
|
|
|
189
195
|
export { ruleComponentsUnknownProp } from "./components-unknown-prop.js";
|
|
190
196
|
export { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
|
|
191
197
|
export { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
|
|
198
|
+
export { ruleComponentsShadowComponent } from "./components-shadow-component.js";
|
|
192
199
|
export { rulePropsInvalidValue } from "./props-invalid-value.js";
|
|
193
200
|
export { ruleJsxPreferredImportPath } from "./jsx-preferred-import-path.js";
|
|
194
201
|
export { ruleJsxPreferredComponent } from "./jsx-preferred-component.js";
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileGlobalGovernanceFacts,
|
|
5
|
+
FactIndex,
|
|
6
|
+
g,
|
|
7
|
+
makeTokenDefinitionFact,
|
|
8
|
+
makeUsageInlineStyleFact,
|
|
9
|
+
makeUsageNodeFact,
|
|
10
|
+
makeUsagePropResolvedFact,
|
|
11
|
+
ruleStylesNoRawColor,
|
|
12
|
+
} from "../index.js";
|
|
13
|
+
import type { FactId } from "../index.js";
|
|
14
|
+
|
|
15
|
+
const FILE = "src/icon.tsx";
|
|
16
|
+
|
|
17
|
+
function indexWithColorPolicy(severity: "error" | "warn" | "info" = "error"): FactIndex {
|
|
18
|
+
const ix = new FactIndex();
|
|
19
|
+
ix.addMany(
|
|
20
|
+
compileGlobalGovernanceFacts({
|
|
21
|
+
styles: [g.styles.rawColors().forbid({ except: ["transparent"], prefer: "token", severity })],
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
return ix;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function addNode(ix: FactIndex, element: string, nodePath = "0:0"): FactId {
|
|
28
|
+
const node = makeUsageNodeFact({
|
|
29
|
+
file: FILE,
|
|
30
|
+
nodePath,
|
|
31
|
+
element,
|
|
32
|
+
location: { file: FILE, line: 4, column: 6 },
|
|
33
|
+
});
|
|
34
|
+
ix.add(node);
|
|
35
|
+
return node.id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// #9d — SVG paint attributes (fill/stroke/…)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
describe("styles/no-raw-color — #9d SVG paint attributes", () => {
|
|
43
|
+
it("flags a raw hex on a fill attribute", () => {
|
|
44
|
+
const ix = indexWithColorPolicy();
|
|
45
|
+
const nodeId = addNode(ix, "circle");
|
|
46
|
+
ix.add(
|
|
47
|
+
makeUsagePropResolvedFact({
|
|
48
|
+
nodeId,
|
|
49
|
+
prop: "fill",
|
|
50
|
+
resolution: "static",
|
|
51
|
+
value: "#00ff00",
|
|
52
|
+
location: { file: FILE, line: 4, column: 14 },
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
57
|
+
expect(findings).toHaveLength(1);
|
|
58
|
+
expect(findings[0].attributes).toMatchObject({
|
|
59
|
+
property: "fill",
|
|
60
|
+
color: "#00ff00",
|
|
61
|
+
source: "jsx",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("broadened SVG detection ships advisory (warn) even under an error policy", () => {
|
|
66
|
+
const ix = indexWithColorPolicy("error");
|
|
67
|
+
const nodeId = addNode(ix, "path");
|
|
68
|
+
ix.add(
|
|
69
|
+
makeUsagePropResolvedFact({ nodeId, prop: "stroke", resolution: "static", value: "#123456" })
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
73
|
+
expect(findings).toHaveLength(1);
|
|
74
|
+
expect(findings[0].severity).toBe("moderate"); // capped warn (not "serious")
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("emits a deterministic token fix when stroke matches an accent token exactly", () => {
|
|
78
|
+
const ix = indexWithColorPolicy();
|
|
79
|
+
ix.add(
|
|
80
|
+
makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#7c3aed", category: "color" })
|
|
81
|
+
);
|
|
82
|
+
const nodeId = addNode(ix, "path");
|
|
83
|
+
ix.add(
|
|
84
|
+
makeUsagePropResolvedFact({ nodeId, prop: "stroke", resolution: "static", value: "#7c3aed" })
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
88
|
+
expect(findings).toHaveLength(1);
|
|
89
|
+
expect(findings[0].fix).toBeDefined();
|
|
90
|
+
expect(findings[0].fix).toMatchObject({ kind: "replaceStyleValue", deterministic: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps React camelCase stopColor to the paint allowlist", () => {
|
|
94
|
+
const ix = indexWithColorPolicy();
|
|
95
|
+
const nodeId = addNode(ix, "stop");
|
|
96
|
+
ix.add(
|
|
97
|
+
makeUsagePropResolvedFact({
|
|
98
|
+
nodeId,
|
|
99
|
+
prop: "stopColor",
|
|
100
|
+
resolution: "static",
|
|
101
|
+
value: "#abcdef",
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
106
|
+
expect(findings).toHaveLength(1);
|
|
107
|
+
expect(findings[0].attributes).toMatchObject({ property: "stopColor" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not fire for fill='none' or fill='currentColor'", () => {
|
|
111
|
+
const ix = indexWithColorPolicy();
|
|
112
|
+
const nodeId = addNode(ix, "circle");
|
|
113
|
+
ix.add(
|
|
114
|
+
makeUsagePropResolvedFact({ nodeId, prop: "fill", resolution: "static", value: "none" })
|
|
115
|
+
);
|
|
116
|
+
ix.add(
|
|
117
|
+
makeUsagePropResolvedFact({
|
|
118
|
+
nodeId,
|
|
119
|
+
prop: "stroke",
|
|
120
|
+
resolution: "static",
|
|
121
|
+
value: "currentColor",
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not fire for a dynamic fill prop", () => {
|
|
129
|
+
const ix = indexWithColorPolicy();
|
|
130
|
+
const nodeId = addNode(ix, "circle");
|
|
131
|
+
ix.add(makeUsagePropResolvedFact({ nodeId, prop: "fill", resolution: "dynamic" }));
|
|
132
|
+
|
|
133
|
+
expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does not fire for a non-paint attribute carrying a hex-shaped string (documented data-URI limit)", () => {
|
|
137
|
+
const ix = indexWithColorPolicy();
|
|
138
|
+
const nodeId = addNode(ix, "img");
|
|
139
|
+
// A data-URI color is intentionally out of scope — parsing colors out of
|
|
140
|
+
// encoded url() strings is high-noise and low-value for this release.
|
|
141
|
+
ix.add(
|
|
142
|
+
makeUsagePropResolvedFact({
|
|
143
|
+
nodeId,
|
|
144
|
+
prop: "src",
|
|
145
|
+
resolution: "static",
|
|
146
|
+
value: 'data:image/svg+xml,<svg fill="%2300ff00" />',
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// #11b — runtime-constructed (dynamic-raw) colors
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("styles/no-raw-color — #11b dynamic-raw colors", () => {
|
|
159
|
+
it("flags a dynamic-raw inline color with no fix object", () => {
|
|
160
|
+
const ix = indexWithColorPolicy();
|
|
161
|
+
const nodeId = addNode(ix, "div");
|
|
162
|
+
ix.add(
|
|
163
|
+
makeUsageInlineStyleFact({
|
|
164
|
+
nodeId,
|
|
165
|
+
property: "background",
|
|
166
|
+
valueKind: "dynamic-raw",
|
|
167
|
+
value: "#‹expr›",
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
172
|
+
expect(findings).toHaveLength(1);
|
|
173
|
+
expect(findings[0].fix).toBeUndefined();
|
|
174
|
+
expect(findings[0].attributes).toMatchObject({
|
|
175
|
+
property: "background",
|
|
176
|
+
dynamic: true,
|
|
177
|
+
source: "jsx",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("caps the dynamic-raw finding to advisory (warn) under an error policy", () => {
|
|
182
|
+
const ix = indexWithColorPolicy("error");
|
|
183
|
+
const nodeId = addNode(ix, "div");
|
|
184
|
+
ix.add(
|
|
185
|
+
makeUsageInlineStyleFact({
|
|
186
|
+
nodeId,
|
|
187
|
+
property: "color",
|
|
188
|
+
valueKind: "dynamic-raw",
|
|
189
|
+
value: "rgb(‹expr›,‹expr›,‹expr›)",
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
194
|
+
expect(findings).toHaveLength(1);
|
|
195
|
+
expect(findings[0].severity).toBe("moderate"); // capped warn (not "serious")
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps a distinct fingerprint source from static inline colors", () => {
|
|
199
|
+
const ix = indexWithColorPolicy();
|
|
200
|
+
const nodeId = addNode(ix, "div");
|
|
201
|
+
ix.add(
|
|
202
|
+
makeUsageInlineStyleFact({
|
|
203
|
+
nodeId,
|
|
204
|
+
property: "background",
|
|
205
|
+
valueKind: "dynamic-raw",
|
|
206
|
+
value: "#‹expr›",
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const findings = ruleStylesNoRawColor(ix);
|
|
211
|
+
expect(findings[0].fingerprint).toEqual(expect.any(String));
|
|
212
|
+
});
|
|
213
|
+
});
|