@usefragments/core 1.0.0 → 1.3.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-R3X7UBHU.js → chunk-57QDBEHQ.js} +101 -6
- package/dist/chunk-57QDBEHQ.js.map +1 -0
- package/dist/{chunk-243QYRUF.js → chunk-MLRDNSSA.js} +88 -47
- package/dist/chunk-MLRDNSSA.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- 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-BOa3KyiJ.d.ts → governance-DYSirwJu.d.ts} +22 -1
- package/dist/index-h_yWj15D.d.ts +6981 -0
- package/dist/index.d.ts +113 -97
- package/dist/index.js +387 -113
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +3 -2584
- package/dist/schemas/index.js +9 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +144 -2
- package/src/agent-format.ts +34 -7
- package/src/codes/__tests__/codes.test.ts +57 -7
- package/src/codes/codes.ts +44 -42
- package/src/conform-prove.ts +4 -8
- package/src/contract/index.ts +3 -0
- package/src/contract/preimage.test.ts +50 -0
- package/src/contract/preimage.ts +56 -0
- package/src/evidence.ts +27 -0
- package/src/facts/builders.ts +4 -0
- package/src/facts/fact-index.ts +25 -1
- package/src/facts/facts.test.ts +49 -0
- package/src/facts/types.ts +3 -0
- package/src/governance-integrity.test.ts +2 -0
- package/src/governance-integrity.ts +7 -1
- package/src/index.ts +13 -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.ts +165 -7
- package/src/rules/emit-gate.test.ts +40 -2
- package/src/rules/emit-gate.ts +21 -10
- package/src/rules/finding.ts +3 -0
- package/src/rules/fix-availability.ts +33 -0
- package/src/rules/rules.test.ts +180 -24
- package/src/rules/spacing-resolution.ts +5 -8
- package/src/rules/styles-no-raw-color.ts +32 -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 +19 -3
- package/src/rules/tiers.ts +1 -1
- package/src/rules/tokens-require-dual-fallback.ts +54 -17
- package/src/rules/utils.ts +51 -0
- package/src/schemas/index.ts +106 -4
- package/src/token-types.ts +6 -0
- package/src/tokens/__tests__/source-names.test.ts +42 -0
- package/src/tokens/design-token-parser.test.ts +24 -5
- package/src/tokens/design-token-parser.ts +40 -8
- package/dist/chunk-243QYRUF.js.map +0 -1
- package/dist/chunk-R3X7UBHU.js.map +0 -1
|
@@ -42,7 +42,13 @@ import {
|
|
|
42
42
|
type SpacingTokenLookup,
|
|
43
43
|
} from "./spacing-resolution.js";
|
|
44
44
|
import type { Finding } from "./types.js";
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
emitFix,
|
|
47
|
+
indexComponentByNodeId,
|
|
48
|
+
readUsageNode,
|
|
49
|
+
tokenSymbolsInText,
|
|
50
|
+
type FixEmission,
|
|
51
|
+
} from "./utils.js";
|
|
46
52
|
|
|
47
53
|
export const RULE_ID = "styles/no-raw-spacing";
|
|
48
54
|
export const RULE_VERSION = "1";
|
|
@@ -104,6 +110,7 @@ function checkDeclaration(
|
|
|
104
110
|
tokens: lookupFor(scale),
|
|
105
111
|
});
|
|
106
112
|
if (!checked) return null;
|
|
113
|
+
const fixEmission = buildSpacingFix(decl.property, checked, ix);
|
|
107
114
|
|
|
108
115
|
return makeFinding({
|
|
109
116
|
ruleId: RULE_ID,
|
|
@@ -120,7 +127,7 @@ function checkDeclaration(
|
|
|
120
127
|
property: decl.property,
|
|
121
128
|
value: decl.value,
|
|
122
129
|
},
|
|
123
|
-
fix:
|
|
130
|
+
fix: fixEmission?.fix,
|
|
124
131
|
attributes: {
|
|
125
132
|
property: decl.property,
|
|
126
133
|
rawValue: decl.value,
|
|
@@ -135,6 +142,7 @@ function checkDeclaration(
|
|
|
135
142
|
assumedRootFontSizePx: checked.assumedRootFontSizePx,
|
|
136
143
|
assumedEmBasePx: checked.assumedEmBasePx,
|
|
137
144
|
source: "css",
|
|
145
|
+
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
138
146
|
},
|
|
139
147
|
});
|
|
140
148
|
}
|
|
@@ -162,6 +170,7 @@ function checkInlineStyle(
|
|
|
162
170
|
bareNumberFix: inline.valueKind === "number",
|
|
163
171
|
});
|
|
164
172
|
if (!checked) return null;
|
|
173
|
+
const fixEmission = buildSpacingFix(inline.property, checked, ix);
|
|
165
174
|
|
|
166
175
|
const node = readUsageNode(ix, inline.nodeId);
|
|
167
176
|
if (!node) return null;
|
|
@@ -186,7 +195,7 @@ function checkInlineStyle(
|
|
|
186
195
|
property: inline.property,
|
|
187
196
|
value: inline.value,
|
|
188
197
|
},
|
|
189
|
-
fix:
|
|
198
|
+
fix: fixEmission?.fix,
|
|
190
199
|
attributes: {
|
|
191
200
|
property: inline.property,
|
|
192
201
|
rawValue: inline.value,
|
|
@@ -201,19 +210,32 @@ function checkInlineStyle(
|
|
|
201
210
|
assumedRootFontSizePx: checked.assumedRootFontSizePx,
|
|
202
211
|
assumedEmBasePx: checked.assumedEmBasePx,
|
|
203
212
|
source: "jsx",
|
|
213
|
+
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
204
214
|
},
|
|
205
215
|
});
|
|
206
216
|
}
|
|
207
217
|
|
|
208
|
-
function buildSpacingFix(
|
|
218
|
+
function buildSpacingFix(
|
|
219
|
+
property: string,
|
|
220
|
+
checked: CheckedSpacingValue,
|
|
221
|
+
ix: FactIndex
|
|
222
|
+
): FixEmission | undefined {
|
|
209
223
|
if (!checked.fixEmittable || checked.suggestedValue === undefined) return undefined;
|
|
210
|
-
return
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
224
|
+
return emitFix(
|
|
225
|
+
{
|
|
226
|
+
kind: "replaceStyleValue" as const,
|
|
227
|
+
title: `Replace ${property} with ${checked.suggestedValue}`,
|
|
228
|
+
property,
|
|
229
|
+
value: checked.suggestedValue,
|
|
230
|
+
deterministic: true,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
symbols: tokenSymbolsInText(checked.suggestedValue),
|
|
234
|
+
editKind: "replaceStyleValue",
|
|
235
|
+
valuePreserving: checked.deterministicFix,
|
|
236
|
+
},
|
|
237
|
+
ix
|
|
238
|
+
);
|
|
217
239
|
}
|
|
218
240
|
|
|
219
241
|
function spacingMessage(
|
|
@@ -29,13 +29,15 @@ import { makeFinding } from "./finding.js";
|
|
|
29
29
|
import type { Finding } from "./types.js";
|
|
30
30
|
import {
|
|
31
31
|
indexComponentByNodeId,
|
|
32
|
-
|
|
32
|
+
emitFix,
|
|
33
33
|
matchesScale,
|
|
34
34
|
nearestSignedScaleValue,
|
|
35
35
|
normalizeLengthForScale,
|
|
36
36
|
parseLengthValue,
|
|
37
37
|
readUsageNode,
|
|
38
38
|
tokenReference,
|
|
39
|
+
tokenSymbolsInText,
|
|
40
|
+
type FixEmission,
|
|
39
41
|
} from "./utils.js";
|
|
40
42
|
|
|
41
43
|
export const RULE_ID = "styles/no-raw-typography";
|
|
@@ -58,6 +60,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
58
60
|
if (decl.property.toLowerCase() !== "font-size") continue;
|
|
59
61
|
const checked = checkFontSize(decl.value, allowed, scale, tokens);
|
|
60
62
|
if (!checked) continue;
|
|
63
|
+
const fixEmission = buildFix(decl.property, checked, ix);
|
|
61
64
|
findings.push(
|
|
62
65
|
makeFinding({
|
|
63
66
|
ruleId: RULE_ID,
|
|
@@ -74,7 +77,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
74
77
|
property: decl.property,
|
|
75
78
|
value: decl.value,
|
|
76
79
|
},
|
|
77
|
-
fix:
|
|
80
|
+
fix: fixEmission?.fix,
|
|
78
81
|
attributes: {
|
|
79
82
|
property: decl.property,
|
|
80
83
|
rawValue: decl.value,
|
|
@@ -83,6 +86,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
83
86
|
suggestedValue: checked.suggestedValue,
|
|
84
87
|
matchedToken: checked.matchedToken,
|
|
85
88
|
source: "css",
|
|
89
|
+
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
86
90
|
},
|
|
87
91
|
})
|
|
88
92
|
);
|
|
@@ -92,8 +96,15 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
92
96
|
for (const inline of ix.byKind("usage_inline_style")) {
|
|
93
97
|
if (inline.valueKind === "css-variable") continue;
|
|
94
98
|
if (!FONT_SIZE_PROPERTIES.has(inline.property.toLowerCase())) continue;
|
|
95
|
-
const checked = checkFontSize(
|
|
99
|
+
const checked = checkFontSize(
|
|
100
|
+
inline.value,
|
|
101
|
+
allowed,
|
|
102
|
+
scale,
|
|
103
|
+
tokens,
|
|
104
|
+
inline.valueKind === "number"
|
|
105
|
+
);
|
|
96
106
|
if (!checked) continue;
|
|
107
|
+
const fixEmission = buildFix(inline.property, checked, ix);
|
|
97
108
|
const node = readUsageNode(ix, inline.nodeId);
|
|
98
109
|
if (!node) continue;
|
|
99
110
|
const componentEvidenceId = componentByNode.get(node.id)?.id;
|
|
@@ -116,7 +127,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
116
127
|
property: inline.property,
|
|
117
128
|
value: inline.value,
|
|
118
129
|
},
|
|
119
|
-
fix:
|
|
130
|
+
fix: fixEmission?.fix,
|
|
120
131
|
attributes: {
|
|
121
132
|
property: inline.property,
|
|
122
133
|
rawValue: inline.value,
|
|
@@ -125,6 +136,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
|
|
|
125
136
|
suggestedValue: checked.suggestedValue,
|
|
126
137
|
matchedToken: checked.matchedToken,
|
|
127
138
|
source: "jsx",
|
|
139
|
+
...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
|
|
128
140
|
},
|
|
129
141
|
})
|
|
130
142
|
);
|
|
@@ -175,12 +187,10 @@ function checkFontSize(
|
|
|
175
187
|
|
|
176
188
|
if (matchesScale(normalized.value, allowed)) {
|
|
177
189
|
if (!exactToken) return null;
|
|
178
|
-
const applicable =
|
|
179
|
-
isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
|
|
180
190
|
return {
|
|
181
191
|
suggestedValue: tokenReference(exactToken.name),
|
|
182
192
|
fixEmittable: true,
|
|
183
|
-
deterministicFix:
|
|
193
|
+
deterministicFix: normalized.deterministicFix,
|
|
184
194
|
reason: "token-equivalent",
|
|
185
195
|
matchedToken: exactToken.name,
|
|
186
196
|
};
|
|
@@ -206,15 +216,27 @@ function checkFontSize(
|
|
|
206
216
|
};
|
|
207
217
|
}
|
|
208
218
|
|
|
209
|
-
function buildFix(
|
|
219
|
+
function buildFix(
|
|
220
|
+
property: string,
|
|
221
|
+
checked: CheckedFontSize,
|
|
222
|
+
ix: FactIndex
|
|
223
|
+
): FixEmission | undefined {
|
|
210
224
|
if (!checked.fixEmittable || checked.suggestedValue === undefined) return undefined;
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
return emitFix(
|
|
226
|
+
{
|
|
227
|
+
kind: "replaceStyleValue" as const,
|
|
228
|
+
title: `Replace ${property} with ${checked.suggestedValue}`,
|
|
229
|
+
property,
|
|
230
|
+
value: checked.suggestedValue,
|
|
231
|
+
deterministic: true,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
symbols: tokenSymbolsInText(checked.suggestedValue),
|
|
235
|
+
editKind: "replaceStyleValue",
|
|
236
|
+
valuePreserving: checked.deterministicFix,
|
|
237
|
+
},
|
|
238
|
+
ix
|
|
239
|
+
);
|
|
218
240
|
}
|
|
219
241
|
|
|
220
242
|
function typographyMessage(
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
makeContractTokenFact,
|
|
13
13
|
makeStyleCssVarsMustBeDefinedFact,
|
|
14
14
|
makeStyleDeclarationFact,
|
|
15
|
+
makeTokenDefinitionFact,
|
|
15
16
|
makeUsageNodeFact,
|
|
16
17
|
makeUsageTextChildFact,
|
|
17
18
|
RULE_TIER,
|
|
@@ -36,6 +37,7 @@ import type { Fact } from "../index.js";
|
|
|
36
37
|
function rawValueFixture(): Fact[] {
|
|
37
38
|
const file = "src/Widget.module.scss";
|
|
38
39
|
return [
|
|
40
|
+
dualFallbackToken(),
|
|
39
41
|
makeStyleDeclarationFact({
|
|
40
42
|
file,
|
|
41
43
|
selector: ".a",
|
|
@@ -64,6 +66,15 @@ function rawValueFixture(): Fact[] {
|
|
|
64
66
|
];
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
function dualFallbackToken(): Fact {
|
|
70
|
+
return makeTokenDefinitionFact({
|
|
71
|
+
name: "$fui-surface-secondary",
|
|
72
|
+
value: "#f1f5f9",
|
|
73
|
+
category: "color",
|
|
74
|
+
referenceFormat: "scss-var",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
function index(facts: Fact[]): FactIndex {
|
|
68
79
|
const ix = new FactIndex();
|
|
69
80
|
ix.addMany(facts);
|
|
@@ -138,12 +149,14 @@ describe("§2 tokens/require-dual-fallback is Fragments-internal", () => {
|
|
|
138
149
|
it("customer preset = 0, fragments preset = 1", () => {
|
|
139
150
|
const customer = index([
|
|
140
151
|
...compileGlobalGovernanceFacts({ rules: customerDefaultRuleStates() }),
|
|
152
|
+
dualFallbackToken(),
|
|
141
153
|
decl,
|
|
142
154
|
]);
|
|
143
155
|
expect(runRules(customer)).toHaveLength(0);
|
|
144
156
|
|
|
145
157
|
const fragments = index([
|
|
146
158
|
...compileGlobalGovernanceFacts({ rules: fragmentsPresetRuleStates() }),
|
|
159
|
+
dualFallbackToken(),
|
|
147
160
|
decl,
|
|
148
161
|
]);
|
|
149
162
|
const fragmentsFindings = runRules(fragments);
|
|
@@ -217,18 +230,21 @@ describe("§3 contract authored", () => {
|
|
|
217
230
|
expect(ruleIds.every(isContractTierRule)).toBe(true);
|
|
218
231
|
});
|
|
219
232
|
|
|
220
|
-
it("ranks canonical-component bypass first
|
|
233
|
+
it("ranks canonical-component bypass first without claiming an unsafe missing-import fix", () => {
|
|
221
234
|
const findings = runRules(withButtonText(contractIndex()));
|
|
222
235
|
const ranked = findings.map((f) => f.ruleId).sort(compareByVocabularyRank);
|
|
223
236
|
expect(ranked).toEqual(["components/prefer-library", "tokens/css-vars-must-be-defined"]);
|
|
224
237
|
|
|
225
238
|
const canonical = findings.find((f) => f.ruleId === "components/prefer-library");
|
|
226
|
-
//
|
|
239
|
+
// The direction is explicit, but this fixture has no Button binding. A
|
|
240
|
+
// generic source patcher cannot safely rename the element without also
|
|
241
|
+
// importing Button, so the replacement stays visible but is not advertised
|
|
242
|
+
// as deterministic.
|
|
227
243
|
expect(canonical?.fix).toMatchObject({
|
|
228
244
|
kind: "replaceComponent",
|
|
229
245
|
from: "button",
|
|
230
246
|
to: "Button",
|
|
231
|
-
deterministic:
|
|
247
|
+
deterministic: false,
|
|
232
248
|
});
|
|
233
249
|
expect(canonical?.attributes).toMatchObject({
|
|
234
250
|
rawValue: "button",
|
package/src/rules/tiers.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type RuleTier = "contract" | "hygiene";
|
|
|
27
27
|
*/
|
|
28
28
|
export const RULE_TIER: Readonly<Record<string, RuleTier>> = {
|
|
29
29
|
// ---- Tier A — contract vocabulary -------------------------------------
|
|
30
|
-
"components/prefer-library": "contract", // canonical-component bypass (
|
|
30
|
+
"components/prefer-library": "contract", // canonical-component bypass (FUI1004)
|
|
31
31
|
"tokens/css-vars-must-be-defined": "contract", // token drift (FUI2015)
|
|
32
32
|
|
|
33
33
|
// ---- Tier B — generic hygiene -----------------------------------------
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { FactIndex } from "../facts/index.js";
|
|
1
|
+
import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
|
|
2
2
|
|
|
3
3
|
import { makeFinding } from "./finding.js";
|
|
4
4
|
import type { Finding } from "./types.js";
|
|
5
|
-
import {
|
|
5
|
+
import { emitFix } from "./utils.js";
|
|
6
6
|
|
|
7
7
|
export const RULE_ID = "tokens/require-dual-fallback";
|
|
8
8
|
export const RULE_VERSION = "1";
|
|
@@ -22,7 +22,7 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
|
|
|
22
22
|
const policy = ix.policy.ruleConfig(RULE_ID);
|
|
23
23
|
if (!policy?.enabled) return [];
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const tokensByCssVariable = indexTokensByCssVariable(ix.tokens.list());
|
|
26
26
|
const findings: Finding[] = [];
|
|
27
27
|
|
|
28
28
|
for (const decl of ix.byKind("style_declaration")) {
|
|
@@ -33,21 +33,43 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
|
|
|
33
33
|
const tokenName = match[1];
|
|
34
34
|
const hasFallback = match[2] !== undefined;
|
|
35
35
|
if (hasFallback) continue;
|
|
36
|
-
|
|
36
|
+
const token = tokensByCssVariable.get(tokenName);
|
|
37
|
+
if (!token) continue;
|
|
37
38
|
|
|
38
39
|
const rawValue = match[0];
|
|
39
40
|
const scssVar = tokenName.replace(/^--/, "$");
|
|
40
|
-
const
|
|
41
|
-
const nextValue =
|
|
41
|
+
const hasScssVariable = token.referenceFormat === "scss-var";
|
|
42
|
+
const nextValue = hasScssVariable
|
|
43
|
+
? decl.value.replace(rawValue, `var(${tokenName}, ${scssVar})`)
|
|
44
|
+
: undefined;
|
|
45
|
+
const fixEmission = nextValue
|
|
46
|
+
? emitFix(
|
|
47
|
+
{
|
|
48
|
+
kind: "replaceStyleValue",
|
|
49
|
+
title: `Add fallback for ${tokenName}`,
|
|
50
|
+
property: decl.property,
|
|
51
|
+
value: nextValue,
|
|
52
|
+
deterministic: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
symbols: [tokenName, scssVar],
|
|
56
|
+
editKind: "replaceStyleValue",
|
|
57
|
+
valuePreserving: true,
|
|
58
|
+
},
|
|
59
|
+
ix
|
|
60
|
+
)
|
|
61
|
+
: undefined;
|
|
42
62
|
|
|
43
63
|
findings.push(
|
|
44
64
|
makeFinding({
|
|
45
65
|
ruleId: RULE_ID,
|
|
46
66
|
ruleVersion: RULE_VERSION,
|
|
47
67
|
severity: policy.severity ?? "warn",
|
|
48
|
-
message:
|
|
68
|
+
message: hasScssVariable
|
|
69
|
+
? `var(${tokenName}) is missing an SCSS fallback. Use var(${tokenName}, ${scssVar}).`
|
|
70
|
+
: `var(${tokenName}) is missing a fallback. No matching SCSS variable was found in the token source; add a fallback manually or define ${scssVar}.`,
|
|
49
71
|
location: decl.location,
|
|
50
|
-
evidence: ix.evidence([decl.id, policy.id]),
|
|
72
|
+
evidence: ix.evidence([decl.id, token.id, policy.id]),
|
|
51
73
|
fingerprintIdentity: {
|
|
52
74
|
file: decl.file,
|
|
53
75
|
selector: decl.selector,
|
|
@@ -55,24 +77,39 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
|
|
|
55
77
|
property: decl.property,
|
|
56
78
|
tokenName,
|
|
57
79
|
},
|
|
58
|
-
fix:
|
|
59
|
-
kind: "replaceStyleValue",
|
|
60
|
-
title: `Add fallback for ${tokenName}`,
|
|
61
|
-
property: decl.property,
|
|
62
|
-
value: nextValue,
|
|
63
|
-
deterministic: true,
|
|
64
|
-
},
|
|
80
|
+
fix: fixEmission?.fix,
|
|
65
81
|
attributes: {
|
|
66
82
|
property: decl.property,
|
|
67
83
|
rawValue: decl.value,
|
|
68
84
|
tokenName,
|
|
69
|
-
suggestedValue: nextValue,
|
|
85
|
+
...(nextValue ? { suggestedValue: nextValue } : {}),
|
|
86
|
+
...(fixEmission?.downgradeReason
|
|
87
|
+
? { downgradeReason: fixEmission.downgradeReason }
|
|
88
|
+
: {}),
|
|
70
89
|
source: "css",
|
|
71
90
|
},
|
|
72
|
-
})
|
|
91
|
+
})
|
|
73
92
|
);
|
|
74
93
|
}
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
return findings;
|
|
78
97
|
}
|
|
98
|
+
|
|
99
|
+
function indexTokensByCssVariable(
|
|
100
|
+
tokens: readonly TokenDefinitionFact[]
|
|
101
|
+
): Map<string, TokenDefinitionFact> {
|
|
102
|
+
const indexed = new Map<string, TokenDefinitionFact>();
|
|
103
|
+
for (const token of tokens) {
|
|
104
|
+
const cssVariable = token.name.startsWith("--")
|
|
105
|
+
? token.name
|
|
106
|
+
: token.name.startsWith("$")
|
|
107
|
+
? `--${token.name.slice(1)}`
|
|
108
|
+
: `--${token.name}`;
|
|
109
|
+
const existing = indexed.get(cssVariable);
|
|
110
|
+
if (!existing || token.referenceFormat === "scss-var") {
|
|
111
|
+
indexed.set(cssVariable, token);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return indexed;
|
|
115
|
+
}
|
package/src/rules/utils.ts
CHANGED
|
@@ -18,6 +18,57 @@ import type {
|
|
|
18
18
|
UsagePropResolvedFact,
|
|
19
19
|
UsageTextChildFact,
|
|
20
20
|
} from "../facts/index.js";
|
|
21
|
+
import type { FindingFix } from "./types.js";
|
|
22
|
+
|
|
23
|
+
export interface FixProof {
|
|
24
|
+
symbols: readonly string[];
|
|
25
|
+
editKind: FindingFix["kind"];
|
|
26
|
+
valuePreserving: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FixEmission<F extends FindingFix = FindingFix> {
|
|
30
|
+
fix: F;
|
|
31
|
+
downgradeReason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Gate token-substitution fixes on explicit proof. Callers attach any returned
|
|
36
|
+
* downgradeReason to the finding attributes so machine consumers can explain
|
|
37
|
+
* why a suggestion was not auto-applicable.
|
|
38
|
+
*/
|
|
39
|
+
export function emitFix<F extends FindingFix>(
|
|
40
|
+
fix: F,
|
|
41
|
+
proof: FixProof,
|
|
42
|
+
ix: FactIndex
|
|
43
|
+
): FixEmission<F> {
|
|
44
|
+
const reasons: string[] = [];
|
|
45
|
+
if (proof.editKind !== fix.kind) reasons.push("edit kind does not match proof");
|
|
46
|
+
if (!proof.valuePreserving) reasons.push("value-preservation proof missing");
|
|
47
|
+
if (proof.symbols.length === 0) reasons.push("token-symbol proof missing");
|
|
48
|
+
|
|
49
|
+
const missingSymbols = proof.symbols.filter((symbol) => !ix.tokens.symbolExists(symbol));
|
|
50
|
+
if (missingSymbols.length > 0) {
|
|
51
|
+
reasons.push(`absent token symbol: ${missingSymbols.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const inapplicableSymbols = proof.symbols.filter((symbol) => {
|
|
55
|
+
const definition = ix.tokens.definitionForSymbol(symbol);
|
|
56
|
+
return definition !== undefined && !isApplicableTokenReference(definition.referenceFormat);
|
|
57
|
+
});
|
|
58
|
+
if (inapplicableSymbols.length > 0) {
|
|
59
|
+
reasons.push(`token reference is not directly applicable: ${inapplicableSymbols.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
fix: { ...fix, deterministic: reasons.length === 0 },
|
|
64
|
+
...(reasons.length > 0 ? { downgradeReason: reasons.join("; ") } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extract authored token symbols from a replacement string, preserving order. */
|
|
69
|
+
export function tokenSymbolsInText(value: string): string[] {
|
|
70
|
+
return [...new Set(value.match(/\$[A-Za-z0-9_-]+|--[A-Za-z0-9_-]+/g) ?? [])];
|
|
71
|
+
}
|
|
21
72
|
|
|
22
73
|
export interface LengthScale {
|
|
23
74
|
unit: "px" | "rem";
|
package/src/schemas/index.ts
CHANGED
|
@@ -8,6 +8,15 @@ import {
|
|
|
8
8
|
severitySchema,
|
|
9
9
|
} from "../severity.js";
|
|
10
10
|
import type { LegacySeverityLevel, Severity } from "../severity.js";
|
|
11
|
+
import type { EvidenceGrade } from "../evidence.js";
|
|
12
|
+
|
|
13
|
+
const evidenceGradeSchema: z.ZodType<EvidenceGrade> = z.enum([
|
|
14
|
+
"none",
|
|
15
|
+
"runtime_advisory",
|
|
16
|
+
"provenance_bound",
|
|
17
|
+
"source_backed",
|
|
18
|
+
"receipt_backed",
|
|
19
|
+
]);
|
|
11
20
|
|
|
12
21
|
export { legacySeverityLevelSchema, severityLevelSchema, severitySchema };
|
|
13
22
|
export type { LegacySeverityLevel, Severity, SeverityLevel } from "../severity.js";
|
|
@@ -108,6 +117,7 @@ export const findingSchema = z.object({
|
|
|
108
117
|
fingerprint: z.string(),
|
|
109
118
|
location: factLocationSchema,
|
|
110
119
|
evidence: z.array(factEvidenceSchema).min(1),
|
|
120
|
+
evidenceGrade: evidenceGradeSchema.optional(),
|
|
111
121
|
fix: findingFixSchema.optional(),
|
|
112
122
|
attributes: z.record(z.unknown()).optional(),
|
|
113
123
|
/**
|
|
@@ -224,15 +234,51 @@ export const agentFindingSchema = z.object({
|
|
|
224
234
|
severity: severitySchema,
|
|
225
235
|
level: severityLevelSchema,
|
|
226
236
|
message: z.string(),
|
|
237
|
+
evidenceGrade: evidenceGradeSchema.optional(),
|
|
227
238
|
evidence: z.array(factEvidenceSchema),
|
|
228
239
|
plan: agentPlanSchema,
|
|
229
240
|
});
|
|
230
241
|
|
|
231
|
-
export const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
export const AGENT_FORMAT_SCHEMA_VERSION = "typestyle.agent.v2" as const;
|
|
243
|
+
|
|
244
|
+
export const agentIntegritySchema = z.object({
|
|
245
|
+
rulesLoaded: z.number().int().nonnegative(),
|
|
246
|
+
status: z.enum(["healthy", "degraded", "inert"]),
|
|
247
|
+
healthy: z.boolean(),
|
|
248
|
+
inert: z.boolean(),
|
|
249
|
+
reasons: z.array(z.string()),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const agentRunProvenanceSchema = z.object({
|
|
253
|
+
runId: z.string().regex(/^run_[a-f0-9]{12}$/),
|
|
254
|
+
fcid: z.string().min(1),
|
|
255
|
+
baselineId: z.string().min(1).optional(),
|
|
256
|
+
scope: z.object({
|
|
257
|
+
method: z.enum([
|
|
258
|
+
"three-dot",
|
|
259
|
+
"tree-diff",
|
|
260
|
+
"status",
|
|
261
|
+
"fallback-full",
|
|
262
|
+
"explicit",
|
|
263
|
+
"full",
|
|
264
|
+
]),
|
|
265
|
+
gitOk: z.boolean(),
|
|
266
|
+
}),
|
|
267
|
+
engineVersion: z.string().min(1),
|
|
268
|
+
scopeHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
269
|
+
inputFilesHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const agentRunVerdictSchema = z.object({
|
|
273
|
+
exitCode: z.union([z.literal(0), z.literal(1)]),
|
|
274
|
+
reasons: z.array(z.string()),
|
|
275
|
+
gatingFindingCount: z.number().int().nonnegative(),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const agentFormatBaseSchema = z.object({
|
|
279
|
+
schemaVersion: z.literal(AGENT_FORMAT_SCHEMA_VERSION),
|
|
235
280
|
summary: z.string(),
|
|
281
|
+
integrity: agentIntegritySchema,
|
|
236
282
|
fix_first: z.array(agentFindingSchema),
|
|
237
283
|
remaining: z.array(agentFindingSchema),
|
|
238
284
|
suppression_template: z.object({
|
|
@@ -247,9 +293,64 @@ export const agentFormatSchema = z.object({
|
|
|
247
293
|
}),
|
|
248
294
|
findings: z.array(findingSchema).optional(),
|
|
249
295
|
suppressions: z.array(suppressionDirectiveSchema).optional(),
|
|
296
|
+
provenance: agentRunProvenanceSchema.optional(),
|
|
297
|
+
verdict: agentRunVerdictSchema.optional(),
|
|
250
298
|
});
|
|
299
|
+
|
|
300
|
+
export const agentFormatSchema = z.union([
|
|
301
|
+
agentFormatBaseSchema.extend({
|
|
302
|
+
passed: z.boolean(),
|
|
303
|
+
score: z.number(),
|
|
304
|
+
status: z.never().optional(),
|
|
305
|
+
reasons: z.never().optional(),
|
|
306
|
+
integrity: agentIntegritySchema.extend({
|
|
307
|
+
rulesLoaded: z.number().int().positive(),
|
|
308
|
+
}),
|
|
309
|
+
}),
|
|
310
|
+
agentFormatBaseSchema.extend({
|
|
311
|
+
passed: z.literal(false),
|
|
312
|
+
score: z.never().optional(),
|
|
313
|
+
status: z.literal("indeterminate"),
|
|
314
|
+
reasons: z.tuple([z.literal("zero_rules_loaded")]),
|
|
315
|
+
integrity: agentIntegritySchema.extend({
|
|
316
|
+
rulesLoaded: z.literal(0),
|
|
317
|
+
}),
|
|
318
|
+
}),
|
|
319
|
+
// A finalized Run can explicitly allow inert governance. Preserve the
|
|
320
|
+
// indeterminate integrity truth while making `passed` match that Run's sole
|
|
321
|
+
// verdict; requiring Run pins prevents generic zero-rule envelopes from
|
|
322
|
+
// accidentally becoming green.
|
|
323
|
+
agentFormatBaseSchema.extend({
|
|
324
|
+
passed: z.literal(true),
|
|
325
|
+
score: z.never().optional(),
|
|
326
|
+
status: z.literal("indeterminate"),
|
|
327
|
+
reasons: z.tuple([z.literal("zero_rules_loaded")]),
|
|
328
|
+
integrity: agentIntegritySchema.extend({
|
|
329
|
+
rulesLoaded: z.literal(0),
|
|
330
|
+
}),
|
|
331
|
+
provenance: agentRunProvenanceSchema,
|
|
332
|
+
verdict: agentRunVerdictSchema.extend({ exitCode: z.literal(0) }),
|
|
333
|
+
}),
|
|
334
|
+
]);
|
|
251
335
|
export type AgentFormat = z.infer<typeof agentFormatSchema>;
|
|
252
336
|
|
|
337
|
+
export const agentErrorEnvelopeSchema = z.object({
|
|
338
|
+
schemaVersion: z.literal(AGENT_FORMAT_SCHEMA_VERSION),
|
|
339
|
+
passed: z.literal(false),
|
|
340
|
+
error: z.object({
|
|
341
|
+
code: z.string().min(1),
|
|
342
|
+
message: z.string().min(1),
|
|
343
|
+
}),
|
|
344
|
+
provenance: agentRunProvenanceSchema.optional(),
|
|
345
|
+
verdict: agentRunVerdictSchema.optional(),
|
|
346
|
+
cloudReport: z.enum(["reported", "failed", "skipped"]).optional(),
|
|
347
|
+
notices: z.array(z.string()).optional(),
|
|
348
|
+
});
|
|
349
|
+
export type AgentErrorEnvelope = z.infer<typeof agentErrorEnvelopeSchema>;
|
|
350
|
+
|
|
351
|
+
export const agentOutputSchema = z.union([agentFormatSchema, agentErrorEnvelopeSchema]);
|
|
352
|
+
export type AgentOutput = z.infer<typeof agentOutputSchema>;
|
|
353
|
+
|
|
253
354
|
export function normalizeSeverity(severity: Severity | LegacySeverityLevel): Severity {
|
|
254
355
|
return severitySchema.safeParse(severity).success
|
|
255
356
|
? (severity as Severity)
|
|
@@ -261,6 +362,7 @@ export function normalizeFinding(
|
|
|
261
362
|
): Finding {
|
|
262
363
|
return {
|
|
263
364
|
...input,
|
|
365
|
+
evidenceGrade: input.evidenceGrade ?? "source_backed",
|
|
264
366
|
level: input.level ?? severityLevel(input.severity),
|
|
265
367
|
};
|
|
266
368
|
}
|
package/src/token-types.ts
CHANGED
|
@@ -59,6 +59,12 @@ export interface DesignToken {
|
|
|
59
59
|
/** Source file where this token was defined */
|
|
60
60
|
sourceFile: string;
|
|
61
61
|
|
|
62
|
+
/** Authored reference form, when the parser can prove it from the source. */
|
|
63
|
+
referenceFormat?: "css-var" | "scss-var" | "scss-map" | "dtcg";
|
|
64
|
+
|
|
65
|
+
/** Authored spellings preserved before normalization (for example `$x` and `--x`). */
|
|
66
|
+
sourceNames?: string[];
|
|
67
|
+
|
|
62
68
|
/** Line number in source file */
|
|
63
69
|
lineNumber?: number;
|
|
64
70
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { FactIndex, makeTokenDefinitionFact } from "../../facts/index.js";
|
|
7
|
+
import { parseDesignTokenContent } from "../design-token-parser.js";
|
|
8
|
+
|
|
9
|
+
const tokenFile = fileURLToPath(
|
|
10
|
+
new URL("../../../../../libs/ui/src/tokens/_variables.scss", import.meta.url)
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
describe("SCSS source-name provenance", () => {
|
|
14
|
+
it("preserves both authored spellings through parser, facts, and rule-time lookup", () => {
|
|
15
|
+
const parsed = parseDesignTokenContent(readFileSync(tokenFile, "utf8"), {
|
|
16
|
+
filePath: tokenFile,
|
|
17
|
+
});
|
|
18
|
+
const token = parsed.tokens.find((candidate) => candidate.name === "--fui-space-2");
|
|
19
|
+
|
|
20
|
+
expect(token?.sourceNames).toEqual(expect.arrayContaining(["--fui-space-2", "$fui-space-2"]));
|
|
21
|
+
|
|
22
|
+
const ix = new FactIndex();
|
|
23
|
+
ix.addMany(
|
|
24
|
+
parsed.tokens.map((candidate) =>
|
|
25
|
+
makeTokenDefinitionFact({
|
|
26
|
+
name: candidate.name,
|
|
27
|
+
value: candidate.resolvedValue,
|
|
28
|
+
category: candidate.category === "spacing" ? "spacing" : "other",
|
|
29
|
+
referenceFormat: candidate.referenceFormat,
|
|
30
|
+
sourceNames: candidate.sourceNames,
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(ix.tokens.symbolExists("$fui-space-2")).toBe(true);
|
|
36
|
+
expect(ix.tokens.symbolExists("--fui-space-2")).toBe(true);
|
|
37
|
+
expect(ix.tokens.symbolExists("$fui-space-99")).toBe(false);
|
|
38
|
+
expect(ix.tokens.definitionForSymbol("$fui-space-2")?.sourceNames).toEqual(
|
|
39
|
+
expect.arrayContaining(["--fui-space-2", "$fui-space-2"])
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|