@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.
Files changed (44) hide show
  1. package/dist/{chunk-MLRDNSSA.js → chunk-HXGE3O2F.js} +39 -1
  2. package/dist/chunk-HXGE3O2F.js.map +1 -0
  3. package/dist/codes/index.d.ts +1 -1
  4. package/dist/codes/index.js +1 -1
  5. package/dist/compiled-types/index.d.ts +1 -1
  6. package/dist/generate/index.d.ts +1 -1
  7. package/dist/{governance-DYSirwJu.d.ts → governance-BLsyk56o.d.ts} +21 -0
  8. package/dist/index.d.ts +324 -82
  9. package/dist/index.js +507 -120
  10. package/dist/index.js.map +1 -1
  11. package/dist/react-types.d.ts +1 -1
  12. package/dist/test-utils.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/codes/codes.ts +9 -0
  15. package/src/conform.ts +5 -0
  16. package/src/constants.ts +3 -0
  17. package/src/facts/builders.ts +46 -2
  18. package/src/facts/facts.test.ts +30 -0
  19. package/src/facts/index.ts +6 -0
  20. package/src/facts/types.ts +75 -2
  21. package/src/governance.test.ts +26 -0
  22. package/src/identity/classify.test.ts +185 -0
  23. package/src/identity/classify.ts +257 -0
  24. package/src/identity/component-key.test.ts +32 -0
  25. package/src/identity/component-key.ts +35 -0
  26. package/src/identity/usage.ts +39 -0
  27. package/src/index.ts +25 -0
  28. package/src/rules/components-prefer-library.test.ts +196 -0
  29. package/src/rules/components-prefer-library.ts +68 -1
  30. package/src/rules/components-shadow-component.test.ts +145 -0
  31. package/src/rules/components-shadow-component.ts +81 -0
  32. package/src/rules/fix-availability.ts +1 -0
  33. package/src/rules/index.ts +7 -0
  34. package/src/rules/styles-no-raw-color.test.ts +213 -0
  35. package/src/rules/styles-no-raw-color.ts +125 -0
  36. package/src/rules/taxonomy.test.ts +16 -1
  37. package/src/rules/tiers.ts +7 -5
  38. package/src/schema.ts +21 -2
  39. package/src/token-types.ts +12 -0
  40. package/src/tokens/categories.ts +54 -24
  41. package/src/tokens/scss.test.ts +94 -0
  42. package/src/tokens/scss.ts +11 -4
  43. package/src/types.ts +23 -0
  44. 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
- return undefined;
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,
@@ -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
+ });