@usefragments/core 1.2.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-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
- package/dist/chunk-57QDBEHQ.js.map +1 -0
- package/dist/{chunk-DBVTSUMT.js → chunk-MLRDNSSA.js} +20 -6
- package/dist/chunk-MLRDNSSA.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +2 -2
- package/dist/index-h_yWj15D.d.ts +6981 -0
- package/dist/index.d.ts +106 -92
- package/dist/index.js +235 -98
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +3 -3377
- package/dist/schemas/index.js +1 -1
- package/package.json +1 -1
- package/src/agent-format.test.ts +75 -3
- package/src/agent-format.ts +28 -6
- package/src/evidence.ts +27 -0
- package/src/facts/builders.ts +2 -0
- package/src/facts/fact-index.ts +22 -2
- package/src/facts/facts.test.ts +1 -1
- package/src/facts/types.ts +2 -0
- 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/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.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 +13 -0
- package/src/rules/tokens-require-dual-fallback.ts +54 -17
- package/src/rules/utils.ts +51 -0
- package/src/schemas/index.ts +81 -3
- 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-BQQTMGLA.js.map +0 -1
- package/dist/chunk-DBVTSUMT.js.map +0 -1
package/dist/schemas/index.js
CHANGED
package/package.json
CHANGED
package/src/agent-format.test.ts
CHANGED
|
@@ -136,7 +136,7 @@ describe("buildAgentFormat", () => {
|
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
it("
|
|
139
|
+
it("marks an inert zero-rule scan indeterminate without inventing a score", () => {
|
|
140
140
|
const agentFormat = buildAgentFormat({
|
|
141
141
|
filesScanned: 1,
|
|
142
142
|
findings: [],
|
|
@@ -151,7 +151,8 @@ describe("buildAgentFormat", () => {
|
|
|
151
151
|
|
|
152
152
|
expect(agentFormat).toMatchObject({
|
|
153
153
|
passed: false,
|
|
154
|
-
|
|
154
|
+
status: "indeterminate",
|
|
155
|
+
reasons: ["zero_rules_loaded"],
|
|
155
156
|
integrity: {
|
|
156
157
|
rulesLoaded: 0,
|
|
157
158
|
healthy: false,
|
|
@@ -159,10 +160,57 @@ describe("buildAgentFormat", () => {
|
|
|
159
160
|
reasons: ["no_enforceable_rules"],
|
|
160
161
|
},
|
|
161
162
|
});
|
|
162
|
-
expect(agentFormat
|
|
163
|
+
expect(agentFormat).not.toHaveProperty("score");
|
|
164
|
+
expect(agentFormat.summary).toContain("zero_rules_loaded");
|
|
163
165
|
expect(() => agentFormatSchema.parse(agentFormat)).not.toThrow();
|
|
164
166
|
});
|
|
165
167
|
|
|
168
|
+
it("rejects invalid determinate and indeterminate field combinations", () => {
|
|
169
|
+
const determinate = buildAgentFormat({
|
|
170
|
+
filesScanned: 1,
|
|
171
|
+
findings: [],
|
|
172
|
+
integrity: HEALTHY_INTEGRITY,
|
|
173
|
+
});
|
|
174
|
+
const indeterminate = buildAgentFormat({
|
|
175
|
+
filesScanned: 1,
|
|
176
|
+
findings: [],
|
|
177
|
+
integrity: {
|
|
178
|
+
rulesLoaded: 0,
|
|
179
|
+
status: "inert",
|
|
180
|
+
healthy: false,
|
|
181
|
+
inert: true,
|
|
182
|
+
reasons: ["no_enforceable_rules"],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(agentFormatSchema.safeParse({ ...determinate, score: undefined }).success).toBe(false);
|
|
187
|
+
expect(
|
|
188
|
+
agentFormatSchema.safeParse({
|
|
189
|
+
...determinate,
|
|
190
|
+
status: "indeterminate",
|
|
191
|
+
reasons: ["zero_rules_loaded"],
|
|
192
|
+
}).success
|
|
193
|
+
).toBe(false);
|
|
194
|
+
expect(agentFormatSchema.safeParse({ ...determinate, reasons: [] }).success).toBe(false);
|
|
195
|
+
expect(agentFormatSchema.safeParse({ ...indeterminate, passed: true }).success).toBe(false);
|
|
196
|
+
expect(agentFormatSchema.safeParse({ ...indeterminate, score: 0 }).success).toBe(false);
|
|
197
|
+
expect(
|
|
198
|
+
agentFormatSchema.safeParse({ ...indeterminate, reasons: ["no_enforceable_rules"] }).success
|
|
199
|
+
).toBe(false);
|
|
200
|
+
expect(
|
|
201
|
+
agentFormatSchema.safeParse({
|
|
202
|
+
...determinate,
|
|
203
|
+
integrity: { ...determinate.integrity, rulesLoaded: 0 },
|
|
204
|
+
}).success
|
|
205
|
+
).toBe(false);
|
|
206
|
+
expect(
|
|
207
|
+
agentFormatSchema.safeParse({
|
|
208
|
+
...indeterminate,
|
|
209
|
+
integrity: { ...indeterminate.integrity, rulesLoaded: 1 },
|
|
210
|
+
}).success
|
|
211
|
+
).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
166
214
|
it("validates the structured error envelope against the shared agent output schema", () => {
|
|
167
215
|
expect(() =>
|
|
168
216
|
agentOutputSchema.parse({
|
|
@@ -172,4 +220,28 @@ describe("buildAgentFormat", () => {
|
|
|
172
220
|
})
|
|
173
221
|
).not.toThrow();
|
|
174
222
|
});
|
|
223
|
+
|
|
224
|
+
it("preserves additive Run provenance and verdict through typed parsing", () => {
|
|
225
|
+
const agentFormat = buildAgentFormat({
|
|
226
|
+
filesScanned: 1,
|
|
227
|
+
findings: [],
|
|
228
|
+
integrity: HEALTHY_INTEGRITY,
|
|
229
|
+
provenance: {
|
|
230
|
+
runId: "run_0123456789ab",
|
|
231
|
+
fcid: "a".repeat(64),
|
|
232
|
+
scope: { method: "full", gitOk: true },
|
|
233
|
+
engineVersion: `sha256:${"b".repeat(64)}`,
|
|
234
|
+
scopeHash: "c".repeat(64),
|
|
235
|
+
inputFilesHash: "d".repeat(64),
|
|
236
|
+
},
|
|
237
|
+
verdict: { exitCode: 0, reasons: [], gatingFindingCount: 0 },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const parsed = agentOutputSchema.parse(agentFormat);
|
|
241
|
+
|
|
242
|
+
expect(parsed).toMatchObject({
|
|
243
|
+
provenance: { runId: "run_0123456789ab", scope: { method: "full", gitOk: true } },
|
|
244
|
+
verdict: { exitCode: 0, reasons: [], gatingFindingCount: 0 },
|
|
245
|
+
});
|
|
246
|
+
});
|
|
175
247
|
});
|
package/src/agent-format.ts
CHANGED
|
@@ -14,9 +14,12 @@ export interface BuildAgentFormatInput {
|
|
|
14
14
|
maxFindings?: number;
|
|
15
15
|
includeEvidence?: boolean;
|
|
16
16
|
includeFindings?: boolean;
|
|
17
|
+
provenance?: NonNullable<AgentFormat["provenance"]>;
|
|
18
|
+
verdict?: NonNullable<AgentFormat["verdict"]>;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export function buildAgentFormat(input: BuildAgentFormatInput): AgentFormat {
|
|
22
|
+
const zeroRulesLoaded = input.integrity.rulesLoaded === 0;
|
|
20
23
|
const integrityFailed = !input.integrity.healthy || input.integrity.inert;
|
|
21
24
|
const score = integrityFailed ? 0 : scoreFindings(input.findings);
|
|
22
25
|
const visibleFindings = uniqueFindings(input.findings).slice(0, input.maxFindings ?? 50);
|
|
@@ -29,6 +32,7 @@ export function buildAgentFormat(input: BuildAgentFormatInput): AgentFormat {
|
|
|
29
32
|
severity: finding.severity,
|
|
30
33
|
level: severityLevel(finding.severity),
|
|
31
34
|
message: finding.message,
|
|
35
|
+
evidenceGrade: finding.evidenceGrade,
|
|
32
36
|
evidence: input.includeEvidence ? finding.evidence : [],
|
|
33
37
|
plan: planForFinding(finding),
|
|
34
38
|
}));
|
|
@@ -38,13 +42,13 @@ export function buildAgentFormat(input: BuildAgentFormatInput): AgentFormat {
|
|
|
38
42
|
// `passed:false`.
|
|
39
43
|
const hasErrors = input.findings.some((finding) => severityLevel(finding.severity) === "error");
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
const shared = {
|
|
42
46
|
schemaVersion: AGENT_FORMAT_SCHEMA_VERSION,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
summary: zeroRulesLoaded
|
|
48
|
+
? "indeterminate; zero_rules_loaded"
|
|
49
|
+
: integrityFailed
|
|
50
|
+
? `failed with score ${score}; ${input.integrity.reasons.join("; ") || "governance integrity is unhealthy"}`
|
|
51
|
+
: agentSummary(input.findings.length, input.filesScanned, score),
|
|
48
52
|
integrity: input.integrity,
|
|
49
53
|
fix_first: agentFindings.filter((finding) => finding.plan.confidence >= 0.8),
|
|
50
54
|
remaining: agentFindings.filter((finding) => finding.plan.confidence < 0.8),
|
|
@@ -70,6 +74,24 @@ export function buildAgentFormat(input: BuildAgentFormatInput): AgentFormat {
|
|
|
70
74
|
})),
|
|
71
75
|
}
|
|
72
76
|
: {}),
|
|
77
|
+
...(input.provenance ? { provenance: input.provenance } : {}),
|
|
78
|
+
...(input.verdict ? { verdict: input.verdict } : {}),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (zeroRulesLoaded) {
|
|
82
|
+
return {
|
|
83
|
+
...shared,
|
|
84
|
+
integrity: { ...input.integrity, rulesLoaded: 0 },
|
|
85
|
+
passed: false,
|
|
86
|
+
status: "indeterminate",
|
|
87
|
+
reasons: ["zero_rules_loaded"],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...shared,
|
|
93
|
+
passed: !integrityFailed && (input.passed ?? true) && !hasErrors,
|
|
94
|
+
score,
|
|
73
95
|
};
|
|
74
96
|
}
|
|
75
97
|
|
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Stable evidence grades shared by scanners, renderers, Inspect, and Cloud. */
|
|
2
|
+
export type EvidenceGrade =
|
|
3
|
+
| "none"
|
|
4
|
+
| "runtime_advisory"
|
|
5
|
+
| "provenance_bound"
|
|
6
|
+
| "source_backed"
|
|
7
|
+
| "receipt_backed";
|
|
8
|
+
|
|
9
|
+
export const EVIDENCE_ORDER = [
|
|
10
|
+
"none",
|
|
11
|
+
"runtime_advisory",
|
|
12
|
+
"provenance_bound",
|
|
13
|
+
"source_backed",
|
|
14
|
+
"receipt_backed",
|
|
15
|
+
] as const satisfies readonly EvidenceGrade[];
|
|
16
|
+
|
|
17
|
+
/** Only source-backed or receipt-backed claims may block a write or CI gate. */
|
|
18
|
+
export function canBlock(grade: EvidenceGrade): boolean {
|
|
19
|
+
return EVIDENCE_ORDER.indexOf(grade) >= EVIDENCE_ORDER.indexOf("source_backed");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type InspectClaimTier = "advisory" | "local_only" | "team_ci" | "receipt";
|
|
23
|
+
|
|
24
|
+
export interface InspectClaim {
|
|
25
|
+
tier: InspectClaimTier;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
package/src/facts/builders.ts
CHANGED
|
@@ -464,6 +464,7 @@ export function makeTokenDefinitionFact(input: {
|
|
|
464
464
|
location?: TokenDefinitionFact["location"];
|
|
465
465
|
category?: TokenDefinitionFact["category"];
|
|
466
466
|
referenceFormat?: TokenDefinitionFact["referenceFormat"];
|
|
467
|
+
sourceNames?: TokenDefinitionFact["sourceNames"];
|
|
467
468
|
}): TokenDefinitionFact {
|
|
468
469
|
return {
|
|
469
470
|
id: factId("token_definition", { name: input.name }),
|
|
@@ -473,6 +474,7 @@ export function makeTokenDefinitionFact(input: {
|
|
|
473
474
|
...(input.location ? { location: input.location } : {}),
|
|
474
475
|
category: input.category,
|
|
475
476
|
referenceFormat: input.referenceFormat,
|
|
477
|
+
...(input.sourceNames?.length ? { sourceNames: [...input.sourceNames] } : {}),
|
|
476
478
|
};
|
|
477
479
|
}
|
|
478
480
|
|
package/src/facts/fact-index.ts
CHANGED
|
@@ -59,7 +59,7 @@ function logicalComponentId(componentId: ComponentId): ComponentId {
|
|
|
59
59
|
|
|
60
60
|
function logicalFactForComparison(fact: Fact): Record<string, unknown> {
|
|
61
61
|
if (fact.kind === "token_definition") {
|
|
62
|
-
const { location: _location, ...logicalFact } = fact;
|
|
62
|
+
const { location: _location, sourceNames: _sourceNames, ...logicalFact } = fact;
|
|
63
63
|
return logicalFact;
|
|
64
64
|
}
|
|
65
65
|
if (fact.kind === "jsx_import_path_preferred") {
|
|
@@ -113,8 +113,12 @@ export class FactIndex {
|
|
|
113
113
|
private readonly facts = new Map<FactId, Fact>();
|
|
114
114
|
private readonly idsByKind = new Map<FactKind, Set<FactId>>();
|
|
115
115
|
private readonly idsByComponent = new Map<ComponentId, Set<FactId>>();
|
|
116
|
+
private readonly tokenBySymbol = new Map<string, TokenDefinitionFact>();
|
|
116
117
|
|
|
117
118
|
add(fact: Fact): void {
|
|
119
|
+
if (fact.kind === "token_definition") {
|
|
120
|
+
this.indexTokenSymbols(fact);
|
|
121
|
+
}
|
|
118
122
|
const existing = this.facts.get(fact.id);
|
|
119
123
|
if (existing) {
|
|
120
124
|
if (
|
|
@@ -122,7 +126,7 @@ export class FactIndex {
|
|
|
122
126
|
canonicalJson(logicalFactForComparison(fact))
|
|
123
127
|
) {
|
|
124
128
|
console.warn(
|
|
125
|
-
`FactIndex: conflicting facts for id ${fact.id} — keeping
|
|
129
|
+
`FactIndex: conflicting facts for id ${fact.id} — keeping ${describeFactForConflict(existing)}, skipping ${describeFactForConflict(fact)}`
|
|
126
130
|
);
|
|
127
131
|
}
|
|
128
132
|
return;
|
|
@@ -346,12 +350,28 @@ export class FactIndex {
|
|
|
346
350
|
list: (): TokenDefinitionFact[] => this.byKind("token_definition"),
|
|
347
351
|
byCategory: (category: TokenDefinitionFact["category"]): TokenDefinitionFact[] =>
|
|
348
352
|
this.byKind("token_definition").filter((t) => t.category === category),
|
|
353
|
+
symbolExists: (name: string): boolean => this.tokenBySymbol.has(name),
|
|
354
|
+
definitionForSymbol: (name: string): TokenDefinitionFact | undefined =>
|
|
355
|
+
this.tokenBySymbol.get(name),
|
|
349
356
|
};
|
|
350
357
|
|
|
351
358
|
// ---------------------------------------------------------------------
|
|
352
359
|
// Internals
|
|
353
360
|
// ---------------------------------------------------------------------
|
|
354
361
|
|
|
362
|
+
private indexTokenSymbols(fact: TokenDefinitionFact): void {
|
|
363
|
+
const legacyScssAlias =
|
|
364
|
+
fact.referenceFormat === "scss-var" && fact.name.startsWith("--")
|
|
365
|
+
? `$${fact.name.slice(2)}`
|
|
366
|
+
: fact.referenceFormat === "scss-var" && fact.name.startsWith("$")
|
|
367
|
+
? `--${fact.name.slice(1)}`
|
|
368
|
+
: undefined;
|
|
369
|
+
for (const symbol of [fact.name, ...(fact.sourceNames ?? []), legacyScssAlias]) {
|
|
370
|
+
if (!symbol) continue;
|
|
371
|
+
if (!this.tokenBySymbol.has(symbol)) this.tokenBySymbol.set(symbol, fact);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
355
375
|
private byComponentOfKind<F extends Fact>(componentId: ComponentId, kind: FactKind): F[] {
|
|
356
376
|
const ids = this.idsByComponent.get(logicalComponentId(componentId));
|
|
357
377
|
if (!ids) return [];
|
package/src/facts/facts.test.ts
CHANGED
|
@@ -485,7 +485,7 @@ describe("FactIndex — query layer", () => {
|
|
|
485
485
|
|
|
486
486
|
expect(warn).toHaveBeenCalledWith(
|
|
487
487
|
expect.stringMatching(
|
|
488
|
-
/conflicting facts.*kind=token_definition location=tokens\/theme\.css:4:1/
|
|
488
|
+
/conflicting facts.*keeping kind=token_definition location=tokens\/base\.css:2:1, skipping kind=token_definition location=tokens\/theme\.css:4:1/
|
|
489
489
|
)
|
|
490
490
|
);
|
|
491
491
|
expect(ix.get(first.id)).toEqual(first);
|
package/src/facts/types.ts
CHANGED
|
@@ -217,6 +217,8 @@ export interface TokenDefinitionFact extends BaseFact {
|
|
|
217
217
|
* Absent on legacy/hand-built facts, which are treated as safe.
|
|
218
218
|
*/
|
|
219
219
|
referenceFormat?: "css-var" | "scss-var" | "scss-map" | "dtcg";
|
|
220
|
+
/** Original authored spellings retained before parser normalization. */
|
|
221
|
+
sourceNames?: string[];
|
|
220
222
|
}
|
|
221
223
|
|
|
222
224
|
export interface TailwindPaletteAllowFact extends BaseFact {
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
export { BRAND, DEFAULTS } from "./constants.js";
|
|
8
8
|
export type { Brand, Defaults } from "./constants.js";
|
|
9
9
|
|
|
10
|
+
// Evidence vocabulary (wire-stable, browser-safe, shared with Inspect).
|
|
11
|
+
export { EVIDENCE_ORDER, canBlock } from "./evidence.js";
|
|
12
|
+
export type { EvidenceGrade, InspectClaim, InspectClaimTier } from "./evidence.js";
|
|
13
|
+
|
|
10
14
|
// Conform engine contract (shared across extract engine, mcp tool, surfaces)
|
|
11
15
|
export type {
|
|
12
16
|
ConformChange,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { FactIndex } from "../../facts/fact-index.js";
|
|
4
|
+
import {
|
|
5
|
+
makeGovernanceRuleConfigFact,
|
|
6
|
+
makeScaleFact,
|
|
7
|
+
makeScaleValueFact,
|
|
8
|
+
makeStyleDeclarationFact,
|
|
9
|
+
makeStyleFontSizeScaleFact,
|
|
10
|
+
makeStylePropertyScaleFact,
|
|
11
|
+
makeStyleRawColorForbiddenFact,
|
|
12
|
+
makeStyleRawDimensionForbiddenFact,
|
|
13
|
+
makeTokenDefinitionFact,
|
|
14
|
+
} from "../../facts/builders.js";
|
|
15
|
+
import type { Finding, FindingFix } from "../types.js";
|
|
16
|
+
import { RULES } from "../index.js";
|
|
17
|
+
import { tokenSymbolsInText } from "../utils.js";
|
|
18
|
+
|
|
19
|
+
describe("proof-carrying fix invariant", () => {
|
|
20
|
+
it("runs every rule and downgrades every token fix when its registry is poisoned", () => {
|
|
21
|
+
const ix = buildFixtureCorpus();
|
|
22
|
+
const fullRegistryFindings = runEveryRule(ix);
|
|
23
|
+
const fullRegistryFixes = tokenFixes(fullRegistryFindings);
|
|
24
|
+
|
|
25
|
+
expect(fullRegistryFindings.every((finding) => finding.evidenceGrade === "source_backed")).toBe(
|
|
26
|
+
true
|
|
27
|
+
);
|
|
28
|
+
expect(fullRegistryFixes).toHaveLength(5);
|
|
29
|
+
expect(fullRegistryFixes.every(({ fix }) => fix.deterministic === true)).toBe(true);
|
|
30
|
+
|
|
31
|
+
ix.tokens.symbolExists = () => false;
|
|
32
|
+
const poisonedFindings = runEveryRule(ix);
|
|
33
|
+
const poisonedFixes = tokenFixes(poisonedFindings);
|
|
34
|
+
|
|
35
|
+
expect(poisonedFixes).toHaveLength(fullRegistryFixes.length);
|
|
36
|
+
expect(
|
|
37
|
+
poisonedFixes.map(({ finding, fix }) => ({
|
|
38
|
+
ruleId: finding.ruleId,
|
|
39
|
+
fix: withoutDeterminism(fix),
|
|
40
|
+
}))
|
|
41
|
+
).toEqual(
|
|
42
|
+
fullRegistryFixes.map(({ finding, fix }) => ({
|
|
43
|
+
ruleId: finding.ruleId,
|
|
44
|
+
fix: withoutDeterminism(fix),
|
|
45
|
+
}))
|
|
46
|
+
);
|
|
47
|
+
expect(poisonedFixes.filter(({ fix }) => fix.deterministic === true)).toEqual([]);
|
|
48
|
+
expect(
|
|
49
|
+
poisonedFixes.every(
|
|
50
|
+
({ finding }) =>
|
|
51
|
+
typeof finding.attributes?.downgradeReason === "string" &&
|
|
52
|
+
finding.attributes.downgradeReason.includes("absent token symbol")
|
|
53
|
+
)
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("downgrades an exact dimension match whose token has no directly applicable form", () => {
|
|
58
|
+
const ix = buildFixtureCorpus("dtcg");
|
|
59
|
+
const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
|
|
60
|
+
?.run(ix)
|
|
61
|
+
.find((finding) => finding.fix);
|
|
62
|
+
|
|
63
|
+
expect(dimension?.fix?.deterministic).toBe(false);
|
|
64
|
+
expect(dimension?.attributes?.downgradeReason).toContain(
|
|
65
|
+
"token reference is not directly applicable"
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function runEveryRule(ix: FactIndex): Finding[] {
|
|
71
|
+
const visited: string[] = [];
|
|
72
|
+
const findings = RULES.flatMap((rule) => {
|
|
73
|
+
visited.push(rule.id);
|
|
74
|
+
return rule.run(ix);
|
|
75
|
+
});
|
|
76
|
+
expect(visited).toEqual(RULES.map((rule) => rule.id));
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function tokenFixes(findings: readonly Finding[]) {
|
|
81
|
+
return findings.flatMap((finding) => {
|
|
82
|
+
const fix = finding.fix;
|
|
83
|
+
if (!fix) return [];
|
|
84
|
+
const symbols = tokenSymbolsInText(replacementText(fix));
|
|
85
|
+
return symbols.length > 0 ? [{ finding, fix, symbols }] : [];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function replacementText(fix: FindingFix): string {
|
|
90
|
+
if (fix.kind === "replaceStyleValue") return fix.value;
|
|
91
|
+
if (fix.kind === "replaceImport" || fix.kind === "replaceComponent") return fix.to;
|
|
92
|
+
if (fix.kind === "replaceClassToken") return fix.to ?? "";
|
|
93
|
+
return typeof fix.value === "string" ? fix.value : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function withoutDeterminism(fix: FindingFix): Omit<FindingFix, "deterministic"> {
|
|
97
|
+
const { deterministic: _deterministic, ...rest } = fix;
|
|
98
|
+
return rest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildFixtureCorpus(radiusReferenceFormat: "css-var" | "dtcg" = "css-var"): FactIndex {
|
|
102
|
+
const ix = new FactIndex();
|
|
103
|
+
ix.addMany([
|
|
104
|
+
makeTokenDefinitionFact({
|
|
105
|
+
name: "--proof-color",
|
|
106
|
+
value: "#123456",
|
|
107
|
+
category: "color",
|
|
108
|
+
referenceFormat: "css-var",
|
|
109
|
+
sourceNames: ["--proof-color"],
|
|
110
|
+
}),
|
|
111
|
+
makeTokenDefinitionFact({
|
|
112
|
+
name: "--proof-space",
|
|
113
|
+
value: "8px",
|
|
114
|
+
category: "spacing",
|
|
115
|
+
referenceFormat: "css-var",
|
|
116
|
+
sourceNames: ["--proof-space"],
|
|
117
|
+
}),
|
|
118
|
+
makeTokenDefinitionFact({
|
|
119
|
+
name: "--proof-radius",
|
|
120
|
+
value: "4px",
|
|
121
|
+
category: "radius",
|
|
122
|
+
referenceFormat: radiusReferenceFormat,
|
|
123
|
+
sourceNames: ["--proof-radius"],
|
|
124
|
+
}),
|
|
125
|
+
makeTokenDefinitionFact({
|
|
126
|
+
name: "--proof-font",
|
|
127
|
+
value: "16px",
|
|
128
|
+
category: "typography",
|
|
129
|
+
referenceFormat: "css-var",
|
|
130
|
+
sourceNames: ["--proof-font"],
|
|
131
|
+
}),
|
|
132
|
+
makeTokenDefinitionFact({
|
|
133
|
+
name: "--proof-dual",
|
|
134
|
+
value: "8px",
|
|
135
|
+
category: "spacing",
|
|
136
|
+
referenceFormat: "scss-var",
|
|
137
|
+
sourceNames: ["--proof-dual", "$proof-dual"],
|
|
138
|
+
}),
|
|
139
|
+
makeStyleRawColorForbiddenFact({ except: [], prefer: "token", severity: "error" }),
|
|
140
|
+
makeStyleRawDimensionForbiddenFact({
|
|
141
|
+
appliesTo: ["border-radius"],
|
|
142
|
+
prefer: "token",
|
|
143
|
+
severity: "error",
|
|
144
|
+
}),
|
|
145
|
+
makeScaleFact({ name: "space", unit: "px" }),
|
|
146
|
+
makeScaleValueFact({ scale: "space", value: 8 }),
|
|
147
|
+
makeStylePropertyScaleFact({ property: "margin", scale: "space", severity: "error" }),
|
|
148
|
+
makeScaleFact({ name: "type", unit: "px" }),
|
|
149
|
+
makeScaleValueFact({ scale: "type", value: 16 }),
|
|
150
|
+
makeStyleFontSizeScaleFact({ scale: "type", severity: "error" }),
|
|
151
|
+
makeGovernanceRuleConfigFact({
|
|
152
|
+
ruleId: "tokens/require-dual-fallback",
|
|
153
|
+
enabled: true,
|
|
154
|
+
severity: "error",
|
|
155
|
+
}),
|
|
156
|
+
declaration("color", "#123456", 1),
|
|
157
|
+
declaration("border-radius", "4px", 2),
|
|
158
|
+
declaration("margin", "8px", 3),
|
|
159
|
+
declaration("font-size", "16px", 4),
|
|
160
|
+
declaration("gap", "var(--proof-dual)", 5),
|
|
161
|
+
]);
|
|
162
|
+
return ix;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function declaration(property: string, value: string, line: number) {
|
|
166
|
+
return makeStyleDeclarationFact({
|
|
167
|
+
file: "fixture.scss",
|
|
168
|
+
selector: ".fixture",
|
|
169
|
+
declarationPath: `.fixture/${property}`,
|
|
170
|
+
property,
|
|
171
|
+
value,
|
|
172
|
+
location: { file: "fixture.scss", line, column: 1 },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileGlobalGovernanceFacts,
|
|
5
|
+
FactIndex,
|
|
6
|
+
makeContractTokenFact,
|
|
7
|
+
makeStyleCssVarsMustBeDefinedFact,
|
|
8
|
+
makeStyleDeclarationFact,
|
|
9
|
+
makeTokenDefinitionFact,
|
|
10
|
+
} from "../../index.js";
|
|
11
|
+
import { ruleTokensCssVarsMustBeDefined } from "../tokens-css-vars-must-be-defined.js";
|
|
12
|
+
import { ruleTokensRequireDualFallback } from "../tokens-require-dual-fallback.js";
|
|
13
|
+
|
|
14
|
+
function declaration(value: string) {
|
|
15
|
+
return makeStyleDeclarationFact({
|
|
16
|
+
file: "src/app.scss",
|
|
17
|
+
selector: ".app",
|
|
18
|
+
declarationPath: "0",
|
|
19
|
+
property: "padding",
|
|
20
|
+
value,
|
|
21
|
+
location: { file: "src/app.scss", line: 1, column: 0 },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function index(...facts: Parameters<FactIndex["addMany"]>[0][]): FactIndex {
|
|
26
|
+
const ix = new FactIndex();
|
|
27
|
+
ix.addMany(
|
|
28
|
+
compileGlobalGovernanceFacts({
|
|
29
|
+
rules: { "tokens/require-dual-fallback": { enabled: true, severity: "error" } },
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
for (const group of facts) ix.addMany(group);
|
|
33
|
+
return ix;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("tokens/require-dual-fallback trust floor", () => {
|
|
37
|
+
it("stays silent for an unregistered token and never invents its Sass symbol (§1)", () => {
|
|
38
|
+
const ix = index([
|
|
39
|
+
makeStyleCssVarsMustBeDefinedFact({ severity: "warn" }),
|
|
40
|
+
makeContractTokenFact({ name: "--fui-space-2" }),
|
|
41
|
+
declaration("var(--fui-space-9)"),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(ruleTokensRequireDualFallback(ix)).toEqual([]);
|
|
45
|
+
const undefinedFindings = ruleTokensCssVarsMustBeDefined(ix);
|
|
46
|
+
expect(undefinedFindings).toHaveLength(1);
|
|
47
|
+
expect(JSON.stringify(undefinedFindings)).not.toContain("$fui-space-9");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits a deterministic fix only for a registered Sass variable and rescans clean (§2)", () => {
|
|
51
|
+
const token = makeTokenDefinitionFact({
|
|
52
|
+
name: "$fui-space-2",
|
|
53
|
+
value: "8px",
|
|
54
|
+
category: "spacing",
|
|
55
|
+
referenceFormat: "scss-var",
|
|
56
|
+
});
|
|
57
|
+
const ix = index([token, declaration("var(--fui-space-2)")]);
|
|
58
|
+
|
|
59
|
+
expect(ruleTokensRequireDualFallback(ix)).toMatchObject([
|
|
60
|
+
{
|
|
61
|
+
fix: {
|
|
62
|
+
kind: "replaceStyleValue",
|
|
63
|
+
value: "var(--fui-space-2, $fui-space-2)",
|
|
64
|
+
deterministic: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const rescanned = index([token, declaration("var(--fui-space-2, $fui-space-2)")]);
|
|
70
|
+
expect(ruleTokensRequireDualFallback(rescanned)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps the finding but emits no fix for a CSS-only token (§2)", () => {
|
|
74
|
+
const ix = index([
|
|
75
|
+
makeTokenDefinitionFact({
|
|
76
|
+
name: "--fui-color-accent",
|
|
77
|
+
value: "#2563eb",
|
|
78
|
+
category: "color",
|
|
79
|
+
referenceFormat: "css-var",
|
|
80
|
+
}),
|
|
81
|
+
declaration("var(--fui-color-accent)"),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const findings = ruleTokensRequireDualFallback(ix);
|
|
85
|
+
expect(findings).toHaveLength(1);
|
|
86
|
+
expect(findings[0].fix).toBeUndefined();
|
|
87
|
+
expect(findings[0].message).toContain("No matching SCSS variable was found");
|
|
88
|
+
expect(JSON.stringify(findings[0])).not.toContain("$fui-color-accent)");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -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",
|