@usefragments/core 1.0.0 → 1.3.0

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