@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.
Files changed (43) hide show
  1. package/dist/{chunk-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
  2. package/dist/chunk-57QDBEHQ.js.map +1 -0
  3. package/dist/{chunk-DBVTSUMT.js → chunk-MLRDNSSA.js} +20 -6
  4. package/dist/chunk-MLRDNSSA.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +2 -2
  7. package/dist/index-h_yWj15D.d.ts +6981 -0
  8. package/dist/index.d.ts +106 -92
  9. package/dist/index.js +235 -98
  10. package/dist/index.js.map +1 -1
  11. package/dist/registry.d.ts +36 -36
  12. package/dist/schemas/index.d.ts +3 -3377
  13. package/dist/schemas/index.js +1 -1
  14. package/package.json +1 -1
  15. package/src/agent-format.test.ts +75 -3
  16. package/src/agent-format.ts +28 -6
  17. package/src/evidence.ts +27 -0
  18. package/src/facts/builders.ts +2 -0
  19. package/src/facts/fact-index.ts +22 -2
  20. package/src/facts/facts.test.ts +1 -1
  21. package/src/facts/types.ts +2 -0
  22. package/src/index.ts +4 -0
  23. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  24. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  25. package/src/rules/emit-gate.test.ts +16 -0
  26. package/src/rules/emit-gate.ts +6 -3
  27. package/src/rules/finding.ts +3 -0
  28. package/src/rules/rules.test.ts +2 -1
  29. package/src/rules/spacing-resolution.ts +5 -8
  30. package/src/rules/styles-no-raw-color.ts +32 -23
  31. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  32. package/src/rules/styles-no-raw-spacing.ts +33 -11
  33. package/src/rules/styles-no-raw-typography.ts +37 -15
  34. package/src/rules/taxonomy.test.ts +13 -0
  35. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  36. package/src/rules/utils.ts +51 -0
  37. package/src/schemas/index.ts +81 -3
  38. package/src/token-types.ts +6 -0
  39. package/src/tokens/__tests__/source-names.test.ts +42 -0
  40. package/src/tokens/design-token-parser.test.ts +24 -5
  41. package/src/tokens/design-token-parser.ts +40 -8
  42. package/dist/chunk-BQQTMGLA.js.map +0 -1
  43. package/dist/chunk-DBVTSUMT.js.map +0 -1
@@ -24,7 +24,7 @@ import {
24
24
  suppressionDirectiveSchema,
25
25
  validatorResultSchema,
26
26
  violationSchema
27
- } from "../chunk-BQQTMGLA.js";
27
+ } from "../chunk-57QDBEHQ.js";
28
28
  import {
29
29
  legacySeverityLevelSchema,
30
30
  severityLevelSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
@@ -136,7 +136,7 @@ describe("buildAgentFormat", () => {
136
136
  });
137
137
  });
138
138
 
139
- it("fails an inert zero-rule scan with score zero and a machine-readable reason", () => {
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
- score: 0,
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.summary).toContain("no_enforceable_rules");
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
  });
@@ -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
- return {
45
+ const shared = {
42
46
  schemaVersion: AGENT_FORMAT_SCHEMA_VERSION,
43
- passed: !integrityFailed && (input.passed ?? true) && !hasErrors,
44
- score,
45
- summary: integrityFailed
46
- ? `failed with score ${score}; ${input.integrity.reasons.join("; ") || "governance integrity is unhealthy"}`
47
- : agentSummary(input.findings.length, input.filesScanned, score),
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
 
@@ -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
+ }
@@ -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
 
@@ -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 first fact, skipping ${describeFactForConflict(fact)}`
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 [];
@@ -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);
@@ -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,
@@ -10,6 +10,7 @@
10
10
  * `fix` shape — no Node APIs, no config off disk, no clock. The CLI hook
11
11
  * re-exports `isDenyEligible` from here.
12
12
  */
13
+ import { canBlock } from "../evidence.js";
13
14
  import { severityLevel } from "../severity.js";
14
15
  import type { Finding } from "./types.js";
15
16
 
@@ -30,11 +31,13 @@ export const BLOCKING_RULE_ALLOWLIST: ReadonlySet<string> = new Set([
30
31
  ]);
31
32
 
32
33
  /**
33
- * Mirrors `scanWithFacts`'s own CI exit logic: a finding gates CI when it is
34
- * error-severity, or warn-severity under `ci.failOnWarnings`. Gating on exactly
35
- * this set is what makes a local/emit deny imply a CI failure.
34
+ * A finding gates CI only when its evidence is source-backed or stronger, it is
35
+ * not explicitly advisory, and its severity is configured to gate. Missing
36
+ * grades are treated as source-backed for compatibility with older reports.
36
37
  */
37
38
  export function gatesCi(finding: Finding, failOnWarnings: boolean): boolean {
39
+ if (!canBlock(finding.evidenceGrade ?? "source_backed")) return false;
40
+ if (finding.attributes?.advisory === true) return false;
38
41
  const level = severityLevel(finding.severity);
39
42
  return level === "error" || (failOnWarnings && level === "warn");
40
43
  }
@@ -15,6 +15,7 @@ import { canonicalJson, hash64Hex } from "../facts/ids.js";
15
15
  import { byRuleId } from "../codes/index.js";
16
16
  import { normalizeFinding, normalizeSeverity } from "../schemas/index.js";
17
17
  import type { Severity } from "../schemas/index.js";
18
+ import type { EvidenceGrade } from "../evidence.js";
18
19
 
19
20
  import type { Finding, FindingFix } from "./types.js";
20
21
 
@@ -25,6 +26,7 @@ export interface MakeFindingInput {
25
26
  message: string;
26
27
  location: FactLocation;
27
28
  evidence: FactEvidence[];
29
+ evidenceGrade?: EvidenceGrade;
28
30
  fingerprintIdentity: Record<string, unknown>;
29
31
  fix?: FindingFix;
30
32
  attributes?: Record<string, unknown>;
@@ -48,6 +50,7 @@ export function makeFinding(input: MakeFindingInput): Finding {
48
50
  fingerprint,
49
51
  location: input.location,
50
52
  evidence: input.evidence,
53
+ evidenceGrade: input.evidenceGrade ?? "source_backed",
51
54
  fix: input.fix,
52
55
  attributes: input.attributes,
53
56
  });
@@ -1509,9 +1509,10 @@ describe("tokens/require-dual-fallback", () => {
1509
1509
  );
1510
1510
  ix.addMany([
1511
1511
  makeTokenDefinitionFact({
1512
- name: "--fui-color-brand",
1512
+ name: "$fui-color-brand",
1513
1513
  value: "#123456",
1514
1514
  category: "color",
1515
+ referenceFormat: "scss-var",
1515
1516
  }),
1516
1517
  makeStyleDeclarationFact({
1517
1518
  file: "src/button.scss",