@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.
- package/dist/{chunk-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
- package/dist/chunk-57QDBEHQ.js.map +1 -0
- package/dist/{chunk-DBVTSUMT.js → chunk-CBSNXFOD.js} +20 -6
- package/dist/chunk-CBSNXFOD.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +2 -2
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-DYSirwJu.d.ts → governance-DxdJV6lx.d.ts} +10 -0
- package/dist/index-0lmh0Lbo.d.ts +6981 -0
- package/dist/index.d.ts +65 -29
- package/dist/index.js +397 -127
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +18 -18
- package/dist/schemas/index.d.ts +3 -3377
- package/dist/schemas/index.js +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +75 -3
- package/src/agent-format.ts +28 -6
- package/src/constants.ts +3 -0
- package/src/evidence.ts +27 -0
- package/src/facts/builders.ts +3 -1
- package/src/facts/fact-index.ts +22 -2
- package/src/facts/facts.test.ts +1 -1
- package/src/facts/types.ts +16 -1
- package/src/index.ts +4 -0
- package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
- package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
- package/src/rules/components-prefer-library.test.ts +148 -0
- package/src/rules/components-prefer-library.ts +52 -1
- package/src/rules/emit-gate.test.ts +16 -0
- package/src/rules/emit-gate.ts +6 -3
- package/src/rules/finding.ts +3 -0
- package/src/rules/rules.test.ts +2 -1
- package/src/rules/spacing-resolution.ts +5 -8
- package/src/rules/styles-no-raw-color.test.ts +213 -0
- package/src/rules/styles-no-raw-color.ts +157 -23
- package/src/rules/styles-no-raw-dimensions.ts +31 -12
- package/src/rules/styles-no-raw-spacing.ts +33 -11
- package/src/rules/styles-no-raw-typography.ts +37 -15
- package/src/rules/taxonomy.test.ts +13 -0
- package/src/rules/tokens-require-dual-fallback.ts +54 -17
- package/src/rules/utils.ts +51 -0
- package/src/schema.ts +6 -2
- package/src/schemas/index.ts +81 -3
- package/src/token-types.ts +18 -0
- package/src/tokens/__tests__/source-names.test.ts +42 -0
- package/src/tokens/categories.ts +54 -24
- package/src/tokens/design-token-parser.test.ts +24 -5
- package/src/tokens/design-token-parser.ts +40 -8
- package/src/tokens/scss.test.ts +94 -0
- package/src/tokens/scss.ts +11 -4
- package/src/types.ts +11 -0
- package/dist/chunk-BQQTMGLA.js.map +0 -1
- 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
|
-
|
|
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,
|
package/src/rules/emit-gate.ts
CHANGED
|
@@ -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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
}
|
package/src/rules/finding.ts
CHANGED
|
@@ -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
|
});
|
package/src/rules/rules.test.ts
CHANGED
|
@@ -1509,9 +1509,10 @@ describe("tokens/require-dual-fallback", () => {
|
|
|
1509
1509
|
);
|
|
1510
1510
|
ix.addMany([
|
|
1511
1511
|
makeTokenDefinitionFact({
|
|
1512
|
-
name: "
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 &&
|
|
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
|
+
});
|