@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.
Files changed (57) 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-CBSNXFOD.js} +20 -6
  4. package/dist/chunk-CBSNXFOD.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-DYSirwJu.d.ts → governance-DxdJV6lx.d.ts} +10 -0
  10. package/dist/index-0lmh0Lbo.d.ts +6981 -0
  11. package/dist/index.d.ts +65 -29
  12. package/dist/index.js +397 -127
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +18 -18
  16. package/dist/schemas/index.d.ts +3 -3377
  17. package/dist/schemas/index.js +1 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +75 -3
  21. package/src/agent-format.ts +28 -6
  22. package/src/constants.ts +3 -0
  23. package/src/evidence.ts +27 -0
  24. package/src/facts/builders.ts +3 -1
  25. package/src/facts/fact-index.ts +22 -2
  26. package/src/facts/facts.test.ts +1 -1
  27. package/src/facts/types.ts +16 -1
  28. package/src/index.ts +4 -0
  29. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  30. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  31. package/src/rules/components-prefer-library.test.ts +148 -0
  32. package/src/rules/components-prefer-library.ts +52 -1
  33. package/src/rules/emit-gate.test.ts +16 -0
  34. package/src/rules/emit-gate.ts +6 -3
  35. package/src/rules/finding.ts +3 -0
  36. package/src/rules/rules.test.ts +2 -1
  37. package/src/rules/spacing-resolution.ts +5 -8
  38. package/src/rules/styles-no-raw-color.test.ts +213 -0
  39. package/src/rules/styles-no-raw-color.ts +157 -23
  40. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  41. package/src/rules/styles-no-raw-spacing.ts +33 -11
  42. package/src/rules/styles-no-raw-typography.ts +37 -15
  43. package/src/rules/taxonomy.test.ts +13 -0
  44. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  45. package/src/rules/utils.ts +51 -0
  46. package/src/schema.ts +6 -2
  47. package/src/schemas/index.ts +81 -3
  48. package/src/token-types.ts +18 -0
  49. package/src/tokens/__tests__/source-names.test.ts +42 -0
  50. package/src/tokens/categories.ts +54 -24
  51. package/src/tokens/design-token-parser.test.ts +24 -5
  52. package/src/tokens/design-token-parser.ts +40 -8
  53. package/src/tokens/scss.test.ts +94 -0
  54. package/src/tokens/scss.ts +11 -4
  55. package/src/types.ts +11 -0
  56. package/dist/chunk-BQQTMGLA.js.map +0 -1
  57. 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,
@@ -1,4 +1,4 @@
1
- import { C as CompiledBlock, b as CompiledFragment } from './governance-DYSirwJu.js';
1
+ import { C as CompiledBlock, b as CompiledFragment } from './governance-DxdJV6lx.js';
2
2
  import 'zod';
3
3
  import 'react';
4
4
  import './topology/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "1.2.0",
3
+ "version": "1.4.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
 
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
 
@@ -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
 
@@ -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 {
@@ -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 {
@@ -308,7 +310,20 @@ export interface UsageInlineStyleFact extends BaseFact {
308
310
  kind: "usage_inline_style";
309
311
  nodeId: FactId;
310
312
  property: string;
311
- valueKind: "static" | "number" | "css-variable";
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
+ });