@usefragments/core 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/{chunk-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
  2. package/dist/chunk-57QDBEHQ.js.map +1 -0
  3. package/dist/{chunk-DBVTSUMT.js → chunk-CBSNXFOD.js} +20 -6
  4. package/dist/chunk-CBSNXFOD.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-DYSirwJu.d.ts → governance-DxdJV6lx.d.ts} +10 -0
  10. package/dist/index-0lmh0Lbo.d.ts +6981 -0
  11. package/dist/index.d.ts +65 -29
  12. package/dist/index.js +397 -127
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +18 -18
  16. package/dist/schemas/index.d.ts +3 -3377
  17. package/dist/schemas/index.js +1 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +75 -3
  21. package/src/agent-format.ts +28 -6
  22. package/src/constants.ts +3 -0
  23. package/src/evidence.ts +27 -0
  24. package/src/facts/builders.ts +3 -1
  25. package/src/facts/fact-index.ts +22 -2
  26. package/src/facts/facts.test.ts +1 -1
  27. package/src/facts/types.ts +16 -1
  28. package/src/index.ts +4 -0
  29. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  30. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  31. package/src/rules/components-prefer-library.test.ts +148 -0
  32. package/src/rules/components-prefer-library.ts +52 -1
  33. package/src/rules/emit-gate.test.ts +16 -0
  34. package/src/rules/emit-gate.ts +6 -3
  35. package/src/rules/finding.ts +3 -0
  36. package/src/rules/rules.test.ts +2 -1
  37. package/src/rules/spacing-resolution.ts +5 -8
  38. package/src/rules/styles-no-raw-color.test.ts +213 -0
  39. package/src/rules/styles-no-raw-color.ts +157 -23
  40. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  41. package/src/rules/styles-no-raw-spacing.ts +33 -11
  42. package/src/rules/styles-no-raw-typography.ts +37 -15
  43. package/src/rules/taxonomy.test.ts +13 -0
  44. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  45. package/src/rules/utils.ts +51 -0
  46. package/src/schema.ts +6 -2
  47. package/src/schemas/index.ts +81 -3
  48. package/src/token-types.ts +18 -0
  49. package/src/tokens/__tests__/source-names.test.ts +42 -0
  50. package/src/tokens/categories.ts +54 -24
  51. package/src/tokens/design-token-parser.test.ts +24 -5
  52. package/src/tokens/design-token-parser.ts +40 -8
  53. package/src/tokens/scss.test.ts +94 -0
  54. package/src/tokens/scss.ts +11 -4
  55. package/src/types.ts +11 -0
  56. package/dist/chunk-BQQTMGLA.js.map +0 -1
  57. package/dist/chunk-DBVTSUMT.js.map +0 -1
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ asComponentId,
5
+ compileGlobalGovernanceFacts,
6
+ FactIndex,
7
+ makeUsageComponentFact,
8
+ makeUsageImportFact,
9
+ makeUsageNodeFact,
10
+ ruleComponentsPreferLibrary,
11
+ } from "../index.js";
12
+
13
+ // #9e — import-path identity for a DIRECTORY canonical source. A directory
14
+ // source (e.g. `src/components/ui`) declares its canonical component NAMES via an
15
+ // include list. The rule must:
16
+ // (a) leave a genuine `<Button>` alone once the scan resolver proved its import
17
+ // resolves INSIDE the directory (a `usage_component` with a `<dir>#Name` id);
18
+ // (b) flag a local impostor `<Button>` imported from OUTSIDE the directory,
19
+ // redirecting the import at the canonical directory path (advisory / warn);
20
+ // (c) never flag a third-party or aliased same-name import (collision guard).
21
+ describe("components/prefer-library — #9e directory import-path identity", () => {
22
+ const CANONICAL_DIR = "src/components/ui";
23
+
24
+ function directoryIndex() {
25
+ const ix = new FactIndex();
26
+ ix.addMany(
27
+ compileGlobalGovernanceFacts({
28
+ rules: {
29
+ "components/prefer-library": {
30
+ enabled: true,
31
+ severity: "warning",
32
+ options: {
33
+ canonicalSources: [
34
+ { kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ })
40
+ );
41
+ return ix;
42
+ }
43
+
44
+ function addButtonUsage(
45
+ ix: FactIndex,
46
+ input: { local?: string; imported?: string; source: string }
47
+ ) {
48
+ const node = makeUsageNodeFact({
49
+ file: "src/pages/Home.tsx",
50
+ nodePath: "0:0",
51
+ element: "Button",
52
+ location: { file: "src/pages/Home.tsx", line: 5, column: 4 },
53
+ });
54
+ ix.add(node);
55
+ ix.add(
56
+ makeUsageImportFact({
57
+ file: "src/pages/Home.tsx",
58
+ local: input.local ?? "Button",
59
+ imported: input.imported ?? "Button",
60
+ source: input.source,
61
+ location: { file: "src/pages/Home.tsx", line: 1, column: 0 },
62
+ })
63
+ );
64
+ return node;
65
+ }
66
+
67
+ it("does not flag a Button whose import the scan resolver proved resolves inside the canonical directory", () => {
68
+ const ix = directoryIndex();
69
+ // The genuine primitive, imported through a `@/` alias into the canonical dir.
70
+ const node = addButtonUsage(ix, { source: "@/components/ui/Button" });
71
+ // The extractor's resolveComponentId seam emitted this canonical id because
72
+ // the module resolved INSIDE `src/components/ui`.
73
+ ix.add(
74
+ makeUsageComponentFact({
75
+ nodeId: node.id,
76
+ componentId: asComponentId(`${CANONICAL_DIR}#Button`),
77
+ })
78
+ );
79
+
80
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
81
+ });
82
+
83
+ it("flags a local impostor Button imported from outside the canonical directory and redirects to the canonical path", () => {
84
+ const ix = directoryIndex();
85
+ // No usage_component fact: the impostor's import resolved OUTSIDE the dir.
86
+ addButtonUsage(ix, { source: "../features/FakeButton" });
87
+
88
+ const findings = ruleComponentsPreferLibrary(ix);
89
+ expect(findings).toHaveLength(1);
90
+ expect(findings[0]).toMatchObject({
91
+ ruleId: "components/prefer-library",
92
+ code: "FUI1004",
93
+ fix: {
94
+ kind: "replaceImport",
95
+ from: "../features/FakeButton",
96
+ to: CANONICAL_DIR,
97
+ deterministic: false,
98
+ },
99
+ attributes: {
100
+ suggestedComponent: "Button",
101
+ suggestedImport: CANONICAL_DIR,
102
+ advisory: true,
103
+ contractProof: "missing",
104
+ },
105
+ });
106
+ // Advisory only — never a hard error/block.
107
+ expect(findings[0].level).toBe("warn");
108
+ });
109
+
110
+ it("does not flag a same-name Button imported from a third-party package (collision guard)", () => {
111
+ const ix = directoryIndex();
112
+ addButtonUsage(ix, { source: "@tanstack/react-router" });
113
+
114
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
115
+ });
116
+
117
+ it("does not flag an aliased same-name import whose bound export differs (collision guard)", () => {
118
+ const ix = directoryIndex();
119
+ // `import { Foo as Button } from "../x"` — the local JSX name collides, but
120
+ // the imported binding is not the canonical `Button`, so it is not a redirect
121
+ // candidate.
122
+ addButtonUsage(ix, { local: "Button", imported: "Foo", source: "../x" });
123
+
124
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
125
+ });
126
+
127
+ it("does not blanket-flag local imports for a directory source without an include list (#224 guard)", () => {
128
+ const ix = new FactIndex();
129
+ ix.addMany(
130
+ compileGlobalGovernanceFacts({
131
+ rules: {
132
+ "components/prefer-library": {
133
+ enabled: true,
134
+ severity: "warning",
135
+ options: {
136
+ // No include list → no curated canonical NAME vocabulary, so there
137
+ // is no basis to redirect an arbitrary local component import.
138
+ canonicalSources: [{ kind: "directory", path: CANONICAL_DIR }],
139
+ },
140
+ },
141
+ },
142
+ })
143
+ );
144
+ addButtonUsage(ix, { source: "../features/FakeButton" });
145
+
146
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
147
+ });
148
+ });
@@ -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,20 @@ 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);
265
273
  const findings: Finding[] = [];
266
274
  const seenImportFixes = new Set<string>();
267
275
 
268
276
  for (const node of ix.byKind("usage_node")) {
269
277
  if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
270
278
  if (node.element.includes(".")) continue;
279
+ if (canonicalDirectoryResolvedNodes.has(node.id)) continue;
271
280
  const props = propsByNode.get(node.id) ?? [];
272
281
  const textChildren = textByNode.get(node.id) ?? [];
273
282
  const importsForFile = importsByFileAndLocal.get(node.file);
@@ -1065,7 +1074,49 @@ function sourceLabel(source: CanonicalSource): string {
1065
1074
  function canonicalImportPath(source: CanonicalSource): string | undefined {
1066
1075
  if (source.kind === "npm") return source.specifier;
1067
1076
  if (source.kind === "registry") return source.importPath;
1068
- return undefined;
1077
+ // #9e — a directory canonical source *does* have a redirect target: its own
1078
+ // directory path. Returning it lets the import-identity branch point a local
1079
+ // impostor (`<Button>` from `../features/FakeButton`) back at the canonical
1080
+ // directory instead of `continue`-ing to zero findings. Gate on an explicit
1081
+ // include list: without curated NAMES, `sourceIncludesComponent` matches every
1082
+ // PascalCase import, so a redirect target here would blanket-flag them all
1083
+ // (#224). Include-less directory sources keep the old no-target behavior.
1084
+ return source.include?.length ? source.path : undefined;
1085
+ }
1086
+
1087
+ /**
1088
+ * #9e — index the `usage_node` ids whose import a Node-side module resolver
1089
+ * proved resolves INSIDE a directory canonical source. The scan orchestrator's
1090
+ * `resolveComponentId` seam emits a `usage_component` fact for such imports with
1091
+ * a `<canonicalDirPath>#<Name>` component id; this recovers those node ids so the
1092
+ * rule can treat the genuine local canonical primitive as already-compliant.
1093
+ */
1094
+ function indexCanonicalDirectoryResolvedNodes(
1095
+ ix: FactIndex,
1096
+ sources: readonly CanonicalSource[]
1097
+ ): Set<FactId> {
1098
+ const out = new Set<FactId>();
1099
+ const directoryPaths = sources
1100
+ .filter((source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1101
+ source.kind === "directory"
1102
+ )
1103
+ .map((source) => normalizePath(source.path));
1104
+ if (directoryPaths.length === 0) return out;
1105
+ for (const fact of ix.byKind("usage_component")) {
1106
+ const module = componentIdModule(fact.componentId);
1107
+ if (module === undefined) continue;
1108
+ const normalizedModule = normalizePath(module);
1109
+ if (directoryPaths.some((path) => isPathInSource(normalizedModule, path))) {
1110
+ out.add(fact.nodeId);
1111
+ }
1112
+ }
1113
+ return out;
1114
+ }
1115
+
1116
+ /** Extract the module portion (`before#`) of a `module#Export` component id. */
1117
+ function componentIdModule(componentId: string): string | undefined {
1118
+ const hash = componentId.indexOf("#");
1119
+ return hash > 0 ? componentId.slice(0, hash) : undefined;
1069
1120
  }
1070
1121
 
1071
1122
  function isCanonicalSourceImplementationFile(
@@ -35,6 +35,22 @@ describe("isDenyEligible", () => {
35
35
  expect(isDenyEligible(warn, true)).toBe(true);
36
36
  });
37
37
 
38
+ it.each(["none", "runtime_advisory", "provenance_bound"] as const)(
39
+ "never gates or denies %s evidence",
40
+ (evidenceGrade) => {
41
+ const lowEvidence = { ...base, evidenceGrade } as unknown as Finding;
42
+ expect(isDenyEligible(lowEvidence, false)).toBe(false);
43
+ }
44
+ );
45
+
46
+ it.each(["source_backed", "receipt_backed"] as const)(
47
+ "allows %s evidence to reach the remaining deny checks",
48
+ (evidenceGrade) => {
49
+ const proven = { ...base, evidenceGrade } as unknown as Finding;
50
+ expect(isDenyEligible(proven, false)).toBe(true);
51
+ }
52
+ );
53
+
38
54
  it("does not deny an explicitly non-deterministic token heuristic", () => {
39
55
  const heuristic = {
40
56
  ...base,
@@ -10,6 +10,7 @@
10
10
  * `fix` shape — no Node APIs, no config off disk, no clock. The CLI hook
11
11
  * re-exports `isDenyEligible` from here.
12
12
  */
13
+ import { canBlock } from "../evidence.js";
13
14
  import { severityLevel } from "../severity.js";
14
15
  import type { Finding } from "./types.js";
15
16
 
@@ -30,11 +31,13 @@ export const BLOCKING_RULE_ALLOWLIST: ReadonlySet<string> = new Set([
30
31
  ]);
31
32
 
32
33
  /**
33
- * Mirrors `scanWithFacts`'s own CI exit logic: a finding gates CI when it is
34
- * error-severity, or warn-severity under `ci.failOnWarnings`. Gating on exactly
35
- * this set is what makes a local/emit deny imply a CI failure.
34
+ * A finding gates CI only when its evidence is source-backed or stronger, it is
35
+ * not explicitly advisory, and its severity is configured to gate. Missing
36
+ * grades are treated as source-backed for compatibility with older reports.
36
37
  */
37
38
  export function gatesCi(finding: Finding, failOnWarnings: boolean): boolean {
39
+ if (!canBlock(finding.evidenceGrade ?? "source_backed")) return false;
40
+ if (finding.attributes?.advisory === true) return false;
38
41
  const level = severityLevel(finding.severity);
39
42
  return level === "error" || (failOnWarnings && level === "warn");
40
43
  }
@@ -15,6 +15,7 @@ import { canonicalJson, hash64Hex } from "../facts/ids.js";
15
15
  import { byRuleId } from "../codes/index.js";
16
16
  import { normalizeFinding, normalizeSeverity } from "../schemas/index.js";
17
17
  import type { Severity } from "../schemas/index.js";
18
+ import type { EvidenceGrade } from "../evidence.js";
18
19
 
19
20
  import type { Finding, FindingFix } from "./types.js";
20
21
 
@@ -25,6 +26,7 @@ export interface MakeFindingInput {
25
26
  message: string;
26
27
  location: FactLocation;
27
28
  evidence: FactEvidence[];
29
+ evidenceGrade?: EvidenceGrade;
28
30
  fingerprintIdentity: Record<string, unknown>;
29
31
  fix?: FindingFix;
30
32
  attributes?: Record<string, unknown>;
@@ -48,6 +50,7 @@ export function makeFinding(input: MakeFindingInput): Finding {
48
50
  fingerprint,
49
51
  location: input.location,
50
52
  evidence: input.evidence,
53
+ evidenceGrade: input.evidenceGrade ?? "source_backed",
51
54
  fix: input.fix,
52
55
  attributes: input.attributes,
53
56
  });
@@ -1509,9 +1509,10 @@ describe("tokens/require-dual-fallback", () => {
1509
1509
  );
1510
1510
  ix.addMany([
1511
1511
  makeTokenDefinitionFact({
1512
- name: "--fui-color-brand",
1512
+ name: "$fui-color-brand",
1513
1513
  value: "#123456",
1514
1514
  category: "color",
1515
+ referenceFormat: "scss-var",
1515
1516
  }),
1516
1517
  makeStyleDeclarationFact({
1517
1518
  file: "src/button.scss",
@@ -1,7 +1,6 @@
1
1
  import type { TokenDefinitionFact } from "../facts/index.js";
2
2
 
3
3
  import {
4
- isApplicableTokenReference,
5
4
  matchesScale,
6
5
  nearestSignedScaleValue,
7
6
  normalizeLengthForScale,
@@ -75,7 +74,7 @@ export function resolveSpacingValue(input: {
75
74
  let reliableSuggestion = true;
76
75
  // Deterministic only when every violating part is a value-preserving,
77
76
  // verbatim-resolvable token swap.
78
- let allApplicableValuePreserving = true;
77
+ let allValuePreserving = true;
79
78
 
80
79
  for (const part of parts) {
81
80
  const normalized = normalizeLengthForScale(part.parsed, input.scale);
@@ -93,9 +92,7 @@ export function resolveSpacingValue(input: {
93
92
  // On-grid literal that equals a token: value-preserving swap.
94
93
  anyViolation = true;
95
94
  const reference = tokenReference(exactToken.name);
96
- const applicable =
97
- isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
98
- if (!applicable) allApplicableValuePreserving = false;
95
+ if (!normalized.deterministicFix) allValuePreserving = false;
99
96
  suggestedParts.push(reference);
100
97
  if (!firstViolation) {
101
98
  firstViolation = {
@@ -103,7 +100,7 @@ export function resolveSpacingValue(input: {
103
100
  suggestedValue: reference,
104
101
  suggestedToken: exactToken.name,
105
102
  fixEmittable: true,
106
- deterministicFix: applicable,
103
+ deterministicFix: normalized.deterministicFix,
107
104
  reason: "token-equivalent",
108
105
  matchedToken: exactToken.name,
109
106
  assumedRootFontSizePx: normalized.assumedRootFontSizePx,
@@ -117,7 +114,7 @@ export function resolveSpacingValue(input: {
117
114
  // one exists. Snapping changes rendered value, so this is not deterministic.
118
115
  anyViolation = true;
119
116
  anyOffScale = true;
120
- allApplicableValuePreserving = false;
117
+ allValuePreserving = false;
121
118
 
122
119
  const nearest = nearestSignedScaleValue(normalized.value, input.allowed);
123
120
  const snapsToZero = nearest === 0 && normalized.value !== 0;
@@ -158,7 +155,7 @@ export function resolveSpacingValue(input: {
158
155
  const joined = suggestedParts.join(" ");
159
156
  const changes = joined !== input.raw.trim().replace(/\s+/g, " ");
160
157
  const fixEmittable = canFixAll && reliableSuggestion && changes;
161
- const deterministic = fixEmittable && !anyOffScale && allApplicableValuePreserving;
158
+ const deterministic = fixEmittable && !anyOffScale && allValuePreserving;
162
159
 
163
160
  return {
164
161
  ...firstViolation,
@@ -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
+ });