@usefragments/core 1.7.0 → 1.8.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 (35) hide show
  1. package/dist/chunk-MZ4SW3TP.js +375 -0
  2. package/dist/chunk-MZ4SW3TP.js.map +1 -0
  3. package/dist/{chunk-WNMWKUYG.js → chunk-UUREQ4HD.js} +318 -6
  4. package/dist/chunk-UUREQ4HD.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +1 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/governance-telemetry.d.ts +316 -0
  10. package/dist/governance-telemetry.js +11 -0
  11. package/dist/governance-telemetry.js.map +1 -0
  12. package/dist/{index-hZAlYCli.d.ts → index-Cxk3SOQP.d.ts} +1005 -1005
  13. package/dist/index.d.ts +529 -477
  14. package/dist/index.js +78 -37
  15. package/dist/index.js.map +1 -1
  16. package/dist/react-types.d.ts +1 -1
  17. package/dist/registry.d.ts +146 -146
  18. package/dist/schemas/index.d.ts +1 -1
  19. package/dist/schemas/index.js +3 -1
  20. package/dist/test-utils.d.ts +1 -1
  21. package/package.json +5 -1
  22. package/src/component-contract.ts +12 -0
  23. package/src/contract-parser.ts +2 -0
  24. package/src/governance-telemetry.test.ts +18 -0
  25. package/src/governance-telemetry.ts +398 -0
  26. package/src/index.ts +17 -0
  27. package/src/rules/index.ts +5 -1
  28. package/src/rules/tokens-css-vars-must-be-defined.test.ts +66 -1
  29. package/src/rules/tokens-css-vars-must-be-defined.ts +88 -16
  30. package/src/schemas/index.ts +4 -0
  31. package/src/schemas/normalize-finding.test.ts +50 -0
  32. package/dist/chunk-WNMWKUYG.js.map +0 -1
  33. package/dist/chunk-ZHS52OT4.js +0 -317
  34. package/dist/chunk-ZHS52OT4.js.map +0 -1
  35. package/dist/{governance-D9KtH-vg.d.ts → governance-eEzCyfes.d.ts} +154 -154
@@ -39,9 +39,22 @@ const CSS_VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/gi;
39
39
  * for projects without a contract. Hardcoded values are handled by the unchanged
40
40
  * `styles/no-raw-color` rule; this rule only judges custom-property references.
41
41
  */
42
- export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
43
- const policy = ix.policy.cssVarsMustBeDefined();
44
- if (!policy) return [];
42
+ interface ContractVocabularyContext {
43
+ vocabulary: ReadonlySet<string>;
44
+ prefixes: ReadonlySet<string>;
45
+ hasFlatTokens: boolean;
46
+ locallyDefinedCustomProperties: ReadonlySet<string>;
47
+ }
48
+
49
+ /**
50
+ * The judgment context the rule scans with, or `undefined` while the rule is
51
+ * inert (no `style.cssVars.mustBeDefined` policy fact, or no authored contract
52
+ * vocabulary). Shared with `contractTokenReferenceCensus` so the health
53
+ * metric's denominator and this rule's findings are computed over the same
54
+ * population, by the same shape gate.
55
+ */
56
+ function contractVocabularyContext(ix: FactIndex): ContractVocabularyContext | undefined {
57
+ if (!ix.policy.cssVarsMustBeDefined()) return undefined;
45
58
 
46
59
  // The contract vocabulary travels on its OWN fact channel (contract_token),
47
60
  // injected by the CLI from the user's token source files — kept separate from
@@ -49,21 +62,47 @@ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
49
62
  const vocabulary = new Set(ix.byKind("contract_token").map((token) => token.name));
50
63
  // The truly-unauthored case (no contract token facts) is the only hard return —
51
64
  // it preserves the §11 inert-without-contract regression lock.
52
- if (vocabulary.size === 0) return [];
65
+ if (vocabulary.size === 0) return undefined;
53
66
 
54
67
  // A flat/single-segment vocabulary (`--accent`, `--bg`) yields no prefix family,
55
68
  // but the rule must STILL enforce it (#22/#25/#31/#36) — we no longer hard-return
56
69
  // on an empty family set. `hasFlatTokens` lets single-segment refs through the
57
70
  // shaped gate whenever the vocabulary itself declares single-segment tokens,
58
71
  // which also enforces the flat half of a mixed vocabulary symmetrically (#18).
59
- const prefixes = contractPrefixFamilies(vocabulary);
60
- const hasFlatTokens = [...vocabulary].some(isSingleSegmentVarName);
61
- const locallyDefinedCustomProperties = new Set(
62
- ix
63
- .byKind("style_declaration")
64
- .filter((decl) => decl.property.startsWith("--"))
65
- .map((decl) => decl.property)
72
+ return {
73
+ vocabulary,
74
+ prefixes: contractPrefixFamilies(vocabulary),
75
+ hasFlatTokens: [...vocabulary].some(isSingleSegmentVarName),
76
+ locallyDefinedCustomProperties: new Set(
77
+ ix
78
+ .byKind("style_declaration")
79
+ .filter((decl) => decl.property.startsWith("--"))
80
+ .map((decl) => decl.property)
81
+ ),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Whether a primary-position `var()` reference is judged against the contract:
87
+ * either it IS a vocabulary token (clean) or it is shaped like one (drift).
88
+ * Foreign vendor custom properties (`--radix-*`, `--swiper-*`) are inspected
89
+ * but never judged, so they stay outside both counts.
90
+ */
91
+ function isJudgedReference(tokenName: string, context: ContractVocabularyContext): boolean {
92
+ return (
93
+ context.vocabulary.has(tokenName) ||
94
+ sharesContractPrefix(tokenName, context.prefixes) ||
95
+ (context.hasFlatTokens && isSingleSegmentVarName(tokenName)) ||
96
+ context.locallyDefinedCustomProperties.has(tokenName)
66
97
  );
98
+ }
99
+
100
+ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
101
+ const policy = ix.policy.cssVarsMustBeDefined();
102
+ if (!policy) return [];
103
+ const context = contractVocabularyContext(ix);
104
+ if (!context) return [];
105
+ const { vocabulary } = context;
67
106
 
68
107
  const findings: Finding[] = [];
69
108
 
@@ -83,11 +122,7 @@ export function ruleTokensCssVarsMustBeDefined(ix: FactIndex): Finding[] {
83
122
  CSS_VAR_REFERENCE.lastIndex = skipToCloseParen(decl.value, match.index + 3);
84
123
 
85
124
  if (vocabulary.has(tokenName)) continue;
86
- const shaped =
87
- sharesContractPrefix(tokenName, prefixes) ||
88
- (hasFlatTokens && isSingleSegmentVarName(tokenName)) ||
89
- locallyDefinedCustomProperties.has(tokenName);
90
- if (!shaped) continue;
125
+ if (!isJudgedReference(tokenName, context)) continue;
91
126
  if (seen.has(tokenName)) continue;
92
127
  seen.add(tokenName);
93
128
 
@@ -136,6 +171,43 @@ function isSingleSegmentVarName(name: string): boolean {
136
171
  return name.indexOf("-", 2) === -1;
137
172
  }
138
173
 
174
+ export interface ContractTokenReferenceCensus {
175
+ /**
176
+ * Distinct style-declaration sites (by file:line:column) carrying at least
177
+ * one contract-judged primary `var()` reference.
178
+ */
179
+ checkedSites: number;
180
+ }
181
+
182
+ /**
183
+ * The checked-site denominator for the `contract-site-health` metric's
184
+ * token-reference population: how many style-declaration sites this rule
185
+ * actually judged against the contract vocabulary. `undefined` while the rule
186
+ * is inert — an unmeasured population, never a measured zero. Walks the same
187
+ * `style_declaration` facts with the same primary-position `var()` scan and
188
+ * shape gate as the rule itself, so a site can violate iff it was counted.
189
+ */
190
+ export function contractTokenReferenceCensus(
191
+ ix: FactIndex
192
+ ): ContractTokenReferenceCensus | undefined {
193
+ const context = contractVocabularyContext(ix);
194
+ if (!context) return undefined;
195
+
196
+ const checked = new Set<string>();
197
+ for (const decl of ix.byKind("style_declaration")) {
198
+ CSS_VAR_REFERENCE.lastIndex = 0;
199
+ let match: RegExpExecArray | null;
200
+ while ((match = CSS_VAR_REFERENCE.exec(decl.value)) !== null) {
201
+ const tokenName = match[1];
202
+ CSS_VAR_REFERENCE.lastIndex = skipToCloseParen(decl.value, match.index + 3);
203
+ if (!isJudgedReference(tokenName, context)) continue;
204
+ checked.add(`${decl.location.file}:${decl.location.line}:${decl.location.column}`);
205
+ break;
206
+ }
207
+ }
208
+ return { checkedSites: checked.size };
209
+ }
210
+
139
211
  /**
140
212
  * Index just past the `)` that closes the open paren AT `openParenIndex`
141
213
  * (a balanced-paren scan, so a nested fallback `var(--a, var(--b))` is skipped as
@@ -9,6 +9,7 @@ import {
9
9
  } from "../severity.js";
10
10
  import type { LegacySeverityLevel, Severity } from "../severity.js";
11
11
  import type { EvidenceGrade } from "../evidence.js";
12
+ import { byRuleId } from "../codes/index.js";
12
13
 
13
14
  const evidenceGradeSchema: z.ZodType<EvidenceGrade> = z.enum([
14
15
  "none",
@@ -357,8 +358,11 @@ export function normalizeSeverity(severity: Severity | LegacySeverityLevel): Sev
357
358
  export function normalizeFinding(
358
359
  input: Omit<Finding, "level"> & { level?: Finding["level"] }
359
360
  ): Finding {
361
+ const code = input.code === undefined ? byRuleId.get(input.ruleId) : undefined;
360
362
  return {
361
363
  ...input,
364
+ code: input.code ?? code?.code,
365
+ helpUrl: input.helpUrl ?? code?.explainUrl,
362
366
  evidenceGrade: input.evidenceGrade ?? "source_backed",
363
367
  level: input.level ?? severityLevel(input.severity),
364
368
  };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `normalizeFinding` — FUI code attachment for findings that skip the rules
3
+ * engine's builder.
4
+ *
5
+ * The engine's suppression system findings (unused/expired/malformed
6
+ * suppression, missing expiry) are normalized here rather than through
7
+ * `rules/finding.ts`, and field report #3 (C16) caught the consequence: the
8
+ * human summary printed FUI9002 while `--format json` emitted
9
+ * `code: undefined`, so no JSON consumer could map the finding to rule
10
+ * metadata. The normalizer now resolves the registered code itself; an
11
+ * explicitly provided code always wins.
12
+ */
13
+
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { normalizeFinding } from "./index.js";
17
+
18
+ const base = {
19
+ ruleVersion: "typestyle-suppressions:v1",
20
+ severity: "serious",
21
+ message: "Suppression has expired for FUI2005.",
22
+ fingerprint: "0000000000000000",
23
+ location: { file: "src/App.tsx", line: 3, column: 1 },
24
+ evidence: [],
25
+ } as const;
26
+
27
+ describe("normalizeFinding", () => {
28
+ it("attaches the registered FUI code and explain URL for a registered ruleId", () => {
29
+ const finding = normalizeFinding({ ...base, ruleId: "expired-suppression" });
30
+ expect(finding.code).toBe("FUI9002");
31
+ expect(finding.helpUrl).toBe("https://usefragments.com/errors/FUI9002");
32
+ });
33
+
34
+ it("keeps an explicitly provided code and helpUrl", () => {
35
+ const finding = normalizeFinding({
36
+ ...base,
37
+ ruleId: "expired-suppression",
38
+ code: "FUI0000",
39
+ helpUrl: "https://example.com/FUI0000",
40
+ });
41
+ expect(finding.code).toBe("FUI0000");
42
+ expect(finding.helpUrl).toBe("https://example.com/FUI0000");
43
+ });
44
+
45
+ it("leaves code undefined for an unregistered ruleId", () => {
46
+ const finding = normalizeFinding({ ...base, ruleId: "not-a-registered-rule" });
47
+ expect(finding.code).toBeUndefined();
48
+ expect(finding.helpUrl).toBeUndefined();
49
+ });
50
+ });