@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
package/dist/schemas/index.js
CHANGED
package/dist/test-utils.d.ts
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/constants.ts
CHANGED
|
@@ -30,6 +30,9 @@ export const BRAND = {
|
|
|
30
30
|
/** CLI command name (e.g., "fragments") */
|
|
31
31
|
cliCommand: "fragments",
|
|
32
32
|
|
|
33
|
+
/** Full user-facing CLI invocation for copy printed to end users (e.g., "npx @usefragments/cli") */
|
|
34
|
+
cliInvocation: "npx @usefragments/cli",
|
|
35
|
+
|
|
33
36
|
/** Directory for storing fragments, registry, and cache */
|
|
34
37
|
dataDir: ".fragments",
|
|
35
38
|
|
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
|
|
|
@@ -618,7 +620,7 @@ export function makeUsagePropResolvedFact(input: {
|
|
|
618
620
|
export function makeUsageInlineStyleFact(input: {
|
|
619
621
|
nodeId: FactId;
|
|
620
622
|
property: string;
|
|
621
|
-
valueKind: "static" | "number" | "css-variable";
|
|
623
|
+
valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
|
|
622
624
|
value: string;
|
|
623
625
|
}): UsageInlineStyleFact {
|
|
624
626
|
return {
|
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 {
|
|
@@ -308,7 +310,20 @@ export interface UsageInlineStyleFact extends BaseFact {
|
|
|
308
310
|
kind: "usage_inline_style";
|
|
309
311
|
nodeId: FactId;
|
|
310
312
|
property: string;
|
|
311
|
-
|
|
313
|
+
/**
|
|
314
|
+
* How the extractor resolved the value:
|
|
315
|
+
* - `static` — a string literal (`"#f00"`, `"12px"`).
|
|
316
|
+
* - `number` — a numeric literal (React treats it as `px`).
|
|
317
|
+
* - `css-variable` — a `var(--token)` reference; rules treat it as a token.
|
|
318
|
+
* - `dynamic-raw` — a value constructed at runtime whose *static* fragments
|
|
319
|
+
* unambiguously begin a raw color literal (`` `#${red}` ``,
|
|
320
|
+
* `` `rgb(${r},${g},${b})` ``, `"#" + hex`). `value` carries a reconstructed
|
|
321
|
+
* skeleton with each interpolation rendered as `‹expr›`. Detect-only: a rule
|
|
322
|
+
* may flag it as a raw color but must NEVER attach a deterministic fix
|
|
323
|
+
* (the value is partly runtime). Only color-shaped dynamic values ever
|
|
324
|
+
* surface as facts — a non-color dynamic value stays opaque.
|
|
325
|
+
*/
|
|
326
|
+
valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
|
|
312
327
|
value: string;
|
|
313
328
|
}
|
|
314
329
|
|
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
|
+
});
|