@usefragments/core 1.5.1 → 1.6.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 (45) hide show
  1. package/dist/{chunk-AOG4FTV6.js → chunk-WVFNDPM4.js} +448 -190
  2. package/dist/chunk-WVFNDPM4.js.map +1 -0
  3. package/dist/codes/index.d.ts +1 -1
  4. package/dist/codes/index.js +1 -1
  5. package/dist/compiled-types/index.d.ts +1 -1
  6. package/dist/generate/index.d.ts +1 -1
  7. package/dist/{governance-B88uR3Zq.d.ts → governance-DxFipN5V.d.ts} +654 -22
  8. package/dist/index.d.ts +678 -40
  9. package/dist/index.js +534 -38
  10. package/dist/index.js.map +1 -1
  11. package/dist/react-types.d.ts +1 -1
  12. package/dist/test-utils.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/__tests__/policy-exclude.test.ts +180 -0
  15. package/src/canonical-bridge.ts +69 -1
  16. package/src/canonical-direction.test.ts +118 -0
  17. package/src/canonical-direction.ts +43 -2
  18. package/src/codes/__tests__/codes.test.ts +14 -1
  19. package/src/codes/codes.ts +60 -0
  20. package/src/config.ts +20 -0
  21. package/src/facts/builders.ts +35 -0
  22. package/src/facts/compile.ts +135 -21
  23. package/src/facts/fact-index.ts +22 -2
  24. package/src/facts/facts.test.ts +19 -19
  25. package/src/facts/index.ts +9 -6
  26. package/src/facts/types.ts +45 -9
  27. package/src/governance-integrity.test.ts +277 -1
  28. package/src/governance-integrity.ts +616 -0
  29. package/src/governance.test.ts +20 -1
  30. package/src/governance.ts +131 -0
  31. package/src/index.ts +45 -2
  32. package/src/policy-exclude.ts +113 -0
  33. package/src/rules/families.test.ts +69 -0
  34. package/src/rules/families.ts +52 -0
  35. package/src/rules/index.ts +6 -0
  36. package/src/rules/jsx-preferred-import-path.ts +29 -11
  37. package/src/rules/rules.test.ts +125 -1
  38. package/src/rules/styles-no-raw-color.ts +13 -4
  39. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  40. package/src/rules/styles-no-raw-spacing.test.ts +48 -0
  41. package/src/rules/styles-no-raw-spacing.ts +15 -7
  42. package/src/rules/styles-no-raw-typography.ts +13 -4
  43. package/src/rules/utils.ts +39 -0
  44. package/src/types.ts +21 -3
  45. package/dist/chunk-AOG4FTV6.js.map +0 -1
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { GovernanceSeverity } from "../governance.js";
12
+ import type { PolicyExclude } from "../policy-exclude.js";
12
13
  import { asComponentId, factId } from "./ids.js";
13
14
  import type {
14
15
  A11yNameRequiredFact,
@@ -42,6 +43,7 @@ import type {
42
43
  StyleFontSizeScaleFact,
43
44
  StylePropertyScaleFact,
44
45
  StyleRawColorForbiddenFact,
46
+ StyleValueProvenance,
45
47
  StyleRawDimensionForbiddenFact,
46
48
  StyleUnsupportedFact,
47
49
  UnsupportedStyleReason,
@@ -64,6 +66,17 @@ import type {
64
66
  FactId,
65
67
  } from "./types.js";
66
68
 
69
+ /**
70
+ * Attach a policy record's path excludes without perturbing its fact identity — the
71
+ * field is omitted entirely when nothing was authored, so a config with no excludes
72
+ * produces byte-identical facts (and therefore identical finding fingerprints).
73
+ */
74
+ function policyExcludeField(exclude: readonly PolicyExclude[] | undefined): {
75
+ exclude?: PolicyExclude[];
76
+ } {
77
+ return exclude?.length ? { exclude: exclude.map((entry) => ({ ...entry })) } : {};
78
+ }
79
+
67
80
  // ---------------------------------------------------------------------------
68
81
  // Component facts
69
82
  // ---------------------------------------------------------------------------
@@ -372,6 +385,7 @@ export function makeStyleRawColorForbiddenFact(input: {
372
385
  except: string[];
373
386
  prefer: "token" | "css-variable";
374
387
  severity: GovernanceSeverity;
388
+ exclude?: readonly PolicyExclude[];
375
389
  }): StyleRawColorForbiddenFact {
376
390
  return {
377
391
  id: factId("style_raw_color_forbidden", {}),
@@ -379,6 +393,7 @@ export function makeStyleRawColorForbiddenFact(input: {
379
393
  except: [...input.except],
380
394
  prefer: input.prefer,
381
395
  severity: input.severity,
396
+ ...policyExcludeField(input.exclude),
382
397
  };
383
398
  }
384
399
 
@@ -386,6 +401,7 @@ export function makeStyleRawDimensionForbiddenFact(input: {
386
401
  appliesTo: string[];
387
402
  prefer: "token" | "css-variable";
388
403
  severity: GovernanceSeverity;
404
+ exclude?: readonly PolicyExclude[];
389
405
  }): StyleRawDimensionForbiddenFact {
390
406
  return {
391
407
  id: factId("style_raw_dimension_forbidden", {}),
@@ -393,6 +409,7 @@ export function makeStyleRawDimensionForbiddenFact(input: {
393
409
  appliesTo: [...input.appliesTo],
394
410
  prefer: input.prefer,
395
411
  severity: input.severity,
412
+ ...policyExcludeField(input.exclude),
396
413
  };
397
414
  }
398
415
 
@@ -400,6 +417,7 @@ export function makeStylePropertyScaleFact(input: {
400
417
  property: string;
401
418
  scale: string;
402
419
  severity: GovernanceSeverity;
420
+ exclude?: readonly PolicyExclude[];
403
421
  }): StylePropertyScaleFact {
404
422
  return {
405
423
  id: factId("style_property_scale", { property: input.property }),
@@ -407,28 +425,33 @@ export function makeStylePropertyScaleFact(input: {
407
425
  property: input.property,
408
426
  scale: input.scale,
409
427
  severity: input.severity,
428
+ ...policyExcludeField(input.exclude),
410
429
  };
411
430
  }
412
431
 
413
432
  export function makeStyleFontSizeScaleFact(input: {
414
433
  scale: string;
415
434
  severity: GovernanceSeverity;
435
+ exclude?: readonly PolicyExclude[];
416
436
  }): StyleFontSizeScaleFact {
417
437
  return {
418
438
  id: factId("style_font_size_scale", {}),
419
439
  kind: "style_font_size_scale",
420
440
  scale: input.scale,
421
441
  severity: input.severity,
442
+ ...policyExcludeField(input.exclude),
422
443
  };
423
444
  }
424
445
 
425
446
  export function makeStyleCssVarsMustBeDefinedFact(input: {
426
447
  severity: GovernanceSeverity;
448
+ exclude?: readonly PolicyExclude[];
427
449
  }): StyleCssVarsMustBeDefinedFact {
428
450
  return {
429
451
  id: factId("style_css_vars_must_be_defined", {}),
430
452
  kind: "style_css_vars_must_be_defined",
431
453
  severity: input.severity,
454
+ ...policyExcludeField(input.exclude),
432
455
  };
433
456
  }
434
457
 
@@ -443,23 +466,27 @@ export function makeContractTokenFact(input: { name: string }): ContractTokenFac
443
466
 
444
467
  export function makeJsxUnknownPropsForbiddenFact(input: {
445
468
  severity: GovernanceSeverity;
469
+ exclude?: readonly PolicyExclude[];
446
470
  }): JsxUnknownPropsForbiddenFact {
447
471
  return {
448
472
  id: factId("jsx_unknown_props_forbidden", {}),
449
473
  kind: "jsx_unknown_props_forbidden",
450
474
  severity: input.severity,
475
+ ...policyExcludeField(input.exclude),
451
476
  };
452
477
  }
453
478
 
454
479
  export function makeJsxInlineStyleForbiddenRawFact(input: {
455
480
  property: string;
456
481
  severity: GovernanceSeverity;
482
+ exclude?: readonly PolicyExclude[];
457
483
  }): JsxInlineStyleForbiddenRawFact {
458
484
  return {
459
485
  id: factId("jsx_inline_style_forbidden_raw", { property: input.property }),
460
486
  kind: "jsx_inline_style_forbidden_raw",
461
487
  property: input.property,
462
488
  severity: input.severity,
489
+ ...policyExcludeField(input.exclude),
463
490
  };
464
491
  }
465
492
 
@@ -470,6 +497,7 @@ export function makeJsxImportPathPreferredFact(input: {
470
497
  because?: string;
471
498
  severity: GovernanceSeverity;
472
499
  bridge?: JsxImportPathPreferredFact["bridge"];
500
+ exclude?: readonly PolicyExclude[];
473
501
  }): JsxImportPathPreferredFact {
474
502
  return {
475
503
  id: factId("jsx_import_path_preferred", {
@@ -492,6 +520,7 @@ export function makeJsxImportPathPreferredFact(input: {
492
520
  },
493
521
  }
494
522
  : {}),
523
+ ...policyExcludeField(input.exclude),
495
524
  };
496
525
  }
497
526
 
@@ -500,6 +529,7 @@ export function makeJsxComponentPreferredFact(input: {
500
529
  to: ComponentId;
501
530
  because?: string;
502
531
  severity: GovernanceSeverity;
532
+ exclude?: readonly PolicyExclude[];
503
533
  }): JsxComponentPreferredFact {
504
534
  return {
505
535
  id: factId("jsx_component_preferred", {
@@ -511,6 +541,7 @@ export function makeJsxComponentPreferredFact(input: {
511
541
  to: input.to,
512
542
  because: input.because,
513
543
  severity: input.severity,
544
+ ...policyExcludeField(input.exclude),
514
545
  };
515
546
  }
516
547
 
@@ -690,6 +721,7 @@ export function makeUsageInlineStyleFact(input: {
690
721
  property: string;
691
722
  valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
692
723
  value: string;
724
+ valueFrom?: StyleValueProvenance;
693
725
  }): UsageInlineStyleFact {
694
726
  return {
695
727
  id: factId("usage_inline_style", {
@@ -701,6 +733,7 @@ export function makeUsageInlineStyleFact(input: {
701
733
  property: input.property,
702
734
  valueKind: input.valueKind,
703
735
  value: input.value,
736
+ ...(input.valueFrom ? { valueFrom: input.valueFrom } : {}),
704
737
  };
705
738
  }
706
739
 
@@ -856,6 +889,7 @@ export function makeStyleDeclarationFact(input: {
856
889
  value: string;
857
890
  location: FactLocation;
858
891
  declaredTokenSource?: boolean;
892
+ valueFrom?: StyleValueProvenance;
859
893
  }): StyleDeclarationFact {
860
894
  return {
861
895
  id: factId("style_declaration", {
@@ -872,6 +906,7 @@ export function makeStyleDeclarationFact(input: {
872
906
  value: input.value,
873
907
  location: input.location,
874
908
  ...(input.declaredTokenSource ? { declaredTokenSource: true } : {}),
909
+ ...(input.valueFrom ? { valueFrom: input.valueFrom } : {}),
875
910
  };
876
911
  }
877
912
 
@@ -19,6 +19,9 @@ import type {
19
19
  ResolvedGovernedFragmentDefinition,
20
20
  } from "../governance.js";
21
21
  import { resolveComponentGovernance } from "../governance.js";
22
+ import { RULE_FAMILY_MEMBERS } from "../rules/families.js";
23
+ import { normalizePolicyExcludes, type PolicyExclude } from "../policy-exclude.js";
24
+ import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
22
25
  import type { CompiledFragment, PropDefinition } from "../compiled-types/index.js";
23
26
  import { asComponentId } from "./ids.js";
24
27
  import {
@@ -50,6 +53,52 @@ import type { ComponentId, Fact, PolicyFact } from "./types.js";
50
53
  // Global governance → facts
51
54
  // ---------------------------------------------------------------------------
52
55
 
56
+ /**
57
+ * A global record's authored path excludes, normalized for the fact it compiles into.
58
+ * Omitted when absent so no-exclude configs keep byte-identical policy facts.
59
+ */
60
+ function recordExclude(record: { exclude?: unknown }): { exclude?: PolicyExclude[] } {
61
+ const exclude = normalizePolicyExcludes(record.exclude);
62
+ return exclude ? { exclude } : {};
63
+ }
64
+
65
+ /**
66
+ * Reconcile excludes across records that compile to the SAME policy fact.
67
+ *
68
+ * A resolved policy concatenates preset records with the user's, and the singleton
69
+ * style/jsx kinds are content-addressed on their kind alone — so a user record and the
70
+ * preset record it refines share one fact id, and only one survives indexing. Without
71
+ * this pass the survivor is usually the preset's, and the user's authored exemption
72
+ * disappears with no diagnostic: the facts-seam silence this train exists to kill.
73
+ *
74
+ * Scope is deliberately narrow. Excludes are unioned onto every instance of the id, so
75
+ * whichever copy the index keeps carries the whole set. Nothing else about the records
76
+ * is merged — general merge displacement for the other fields is a separate concern
77
+ * (`10-config-truth`), and widening it here would silently change severity/prefer
78
+ * resolution for existing repos.
79
+ */
80
+ function unionExcludesBySharedFactId(facts: PolicyFact[]): PolicyFact[] {
81
+ const byId = new Map<string, PolicyExclude[]>();
82
+ for (const fact of facts) {
83
+ const exclude = (fact as { exclude?: PolicyExclude[] }).exclude;
84
+ if (!exclude?.length) continue;
85
+ const merged = byId.get(fact.id) ?? [];
86
+ for (const entry of exclude) {
87
+ if (!merged.some((seen) => seen.glob === entry.glob && seen.reason === entry.reason)) {
88
+ merged.push(entry);
89
+ }
90
+ }
91
+ byId.set(fact.id, merged);
92
+ }
93
+ if (byId.size === 0) return facts;
94
+
95
+ return facts.map((fact) => {
96
+ const merged = byId.get(fact.id);
97
+ if (!merged) return fact;
98
+ return { ...fact, exclude: merged.map((entry) => ({ ...entry })) } as PolicyFact;
99
+ });
100
+ }
101
+
53
102
  export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefined): PolicyFact[] {
54
103
  if (!govern) return [];
55
104
  const out: PolicyFact[] = [];
@@ -79,6 +128,7 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
79
128
  except: style.except,
80
129
  prefer: style.prefer,
81
130
  severity: style.severity,
131
+ ...recordExclude(style),
82
132
  })
83
133
  );
84
134
  break;
@@ -88,6 +138,7 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
88
138
  appliesTo: style.appliesTo,
89
139
  prefer: style.prefer,
90
140
  severity: style.severity,
141
+ ...recordExclude(style),
91
142
  })
92
143
  );
93
144
  break;
@@ -98,15 +149,27 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
98
149
  property,
99
150
  scale: style.scale,
100
151
  severity: style.severity,
152
+ ...recordExclude(style),
101
153
  })
102
154
  );
103
155
  }
104
156
  break;
105
157
  case "style.fontSize.mustMatchScale":
106
- out.push(makeStyleFontSizeScaleFact({ scale: style.scale, severity: style.severity }));
158
+ out.push(
159
+ makeStyleFontSizeScaleFact({
160
+ scale: style.scale,
161
+ severity: style.severity,
162
+ ...recordExclude(style),
163
+ })
164
+ );
107
165
  break;
108
166
  case "style.cssVars.mustBeDefined":
109
- out.push(makeStyleCssVarsMustBeDefinedFact({ severity: style.severity }));
167
+ out.push(
168
+ makeStyleCssVarsMustBeDefinedFact({
169
+ severity: style.severity,
170
+ ...recordExclude(style),
171
+ })
172
+ );
110
173
  break;
111
174
  }
112
175
  }
@@ -116,7 +179,12 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
116
179
  for (const jsx of govern.jsx) {
117
180
  switch (jsx.kind) {
118
181
  case "jsx.unknownProps.forbid":
119
- out.push(makeJsxUnknownPropsForbiddenFact({ severity: jsx.severity }));
182
+ out.push(
183
+ makeJsxUnknownPropsForbiddenFact({
184
+ severity: jsx.severity,
185
+ ...recordExclude(jsx),
186
+ })
187
+ );
120
188
  break;
121
189
  case "jsx.inlineStyle.forbidRaw":
122
190
  for (const property of jsx.properties) {
@@ -124,6 +192,7 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
124
192
  makeJsxInlineStyleForbiddenRawFact({
125
193
  property,
126
194
  severity: jsx.severity,
195
+ ...recordExclude(jsx),
127
196
  })
128
197
  );
129
198
  }
@@ -136,6 +205,7 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
136
205
  imported: jsx.imported,
137
206
  because: jsx.because,
138
207
  severity: jsx.severity,
208
+ ...recordExclude(jsx),
139
209
  })
140
210
  );
141
211
  break;
@@ -146,6 +216,7 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
146
216
  to: asComponentId(jsx.to) as ComponentId,
147
217
  because: jsx.because,
148
218
  severity: jsx.severity,
219
+ ...recordExclude(jsx),
149
220
  })
150
221
  );
151
222
  break;
@@ -172,6 +243,67 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
172
243
  );
173
244
  }
174
245
 
246
+ compileTailwindAndRuleConfigFacts(govern, out);
247
+ return unionExcludesBySharedFactId(out);
248
+ }
249
+
250
+ export interface SupersededImportPathPreference {
251
+ from: string;
252
+ imported?: string;
253
+ to: string;
254
+ packageName: string;
255
+ underlyingExportName: string;
256
+ localExportName: string;
257
+ }
258
+
259
+ /**
260
+ * Name each non-bridge `jsx.importPath.prefer` record whose domain a confirmed
261
+ * bridge covers. The rule suppresses the duplicate finding at match time
262
+ * (report #2 B6); this projection is the compile-side voice of that
263
+ * supersession, so the record is never silenced without a word — the exact
264
+ * silence class the config-truth layer exists to kill.
265
+ */
266
+ export function projectSupersededImportPathPreferences(
267
+ govern: GovernanceConfig | undefined
268
+ ): SupersededImportPathPreference[] {
269
+ const bridges = govern?.canonicalBridges ?? [];
270
+ if (bridges.length === 0 || !govern?.jsx) return [];
271
+ const out: SupersededImportPathPreference[] = [];
272
+ for (const record of govern.jsx) {
273
+ if (record.kind !== "jsx.importPath.prefer") continue;
274
+ const bridge = bridges.find((candidate) => importPathRecordOverlapsBridge(record, candidate));
275
+ if (!bridge) continue;
276
+ out.push({
277
+ from: record.from,
278
+ ...(record.imported !== undefined ? { imported: record.imported } : {}),
279
+ to: record.to,
280
+ packageName: bridge.underlying.packageName,
281
+ underlyingExportName: bridge.underlying.exportName,
282
+ localExportName: bridge.local.exportName,
283
+ });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function importPathRecordOverlapsBridge(
289
+ record: { from: string; imported?: string },
290
+ bridge: NonNullable<GovernanceConfig["canonicalBridges"]>[number]
291
+ ): boolean {
292
+ if (!ownedImportMatchesRoot(record.from, bridge.underlying.packageName)) return false;
293
+ if (ownedImportsEqual(record.from, bridge.underlying.packageName)) {
294
+ return record.imported === undefined || record.imported === bridge.underlying.exportName;
295
+ }
296
+ const subpath = record.from.slice(bridge.underlying.packageName.length + 1);
297
+ const leaf = subpath.split("/").at(-1);
298
+ return (
299
+ leaf === bridge.underlying.exportName &&
300
+ (record.imported === undefined ||
301
+ record.imported === "default" ||
302
+ record.imported === bridge.underlying.exportName)
303
+ );
304
+ }
305
+
306
+ function compileTailwindAndRuleConfigFacts(govern: GovernanceConfig, out: PolicyFact[]): void {
175
307
  const tailwindPalette = govern.tailwind?.palette;
176
308
  const forbiddenPaletteSeverity = ruleSeverity(
177
309
  govern.rules?.["tailwind/forbidden-palette"],
@@ -203,26 +335,8 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
203
335
  }
204
336
 
205
337
  out.push(...compileRuleConfigFacts(govern));
206
-
207
- return out;
208
338
  }
209
339
 
210
- const RULE_FAMILY_MEMBERS: Record<string, readonly string[]> = {
211
- "tokens/hardcoded-values": [
212
- "styles/no-raw-color",
213
- "styles/no-raw-spacing",
214
- "tokens/require-dual-fallback",
215
- "theme/no-theme-coupled-literal",
216
- ],
217
- "components/usage": [
218
- "components/forbidden-prop-value",
219
- "components/preferred-component",
220
- "components/unknown-prop",
221
- "props/invalid-value",
222
- ],
223
- "a11y/wcag": ["a11y/required-accessible-name"],
224
- };
225
-
226
340
  function compileRuleConfigFacts(govern: GovernanceConfig): PolicyFact[] {
227
341
  const rules = govern.rules;
228
342
  if (!rules && !govern.canonicalSources?.length) return [];
@@ -45,6 +45,23 @@ export interface FactEvidence {
45
45
  fact: Fact;
46
46
  }
47
47
 
48
+ /**
49
+ * The two facts involved in a first-wins drop: `kept` is already indexed, `skipped`
50
+ * carries different logical content and is discarded. Handed to `onConflict` alongside
51
+ * the rendered message so callers can classify the drop (a colliding *config record* is
52
+ * a named user-facing diagnostic; anything else stays an internal debug line) instead of
53
+ * re-parsing prose.
54
+ */
55
+ export interface FactConflict {
56
+ kept: Fact;
57
+ skipped: Fact;
58
+ }
59
+
60
+ export interface FactIndexOptions {
61
+ /** Optional internal diagnostic route. Product output is quiet by default. */
62
+ onConflict?: (message: string, conflict: FactConflict) => void;
63
+ }
64
+
48
65
  interface FactWithComponent {
49
66
  componentId: ComponentId;
50
67
  }
@@ -115,6 +132,8 @@ export class FactIndex {
115
132
  private readonly idsByComponent = new Map<ComponentId, Set<FactId>>();
116
133
  private readonly tokenBySymbol = new Map<string, TokenDefinitionFact>();
117
134
 
135
+ constructor(private readonly options: FactIndexOptions = {}) {}
136
+
118
137
  add(fact: Fact): void {
119
138
  if (fact.kind === "token_definition") {
120
139
  this.indexTokenSymbols(fact);
@@ -125,8 +144,9 @@ export class FactIndex {
125
144
  canonicalJson(logicalFactForComparison(existing)) !==
126
145
  canonicalJson(logicalFactForComparison(fact))
127
146
  ) {
128
- console.warn(
129
- `FactIndex: conflicting facts for id ${fact.id} — keeping ${describeFactForConflict(existing)}, skipping ${describeFactForConflict(fact)}`
147
+ this.options.onConflict?.(
148
+ `FactIndex: conflicting facts for id ${fact.id} — keeping ${describeFactForConflict(existing)}, skipping ${describeFactForConflict(fact)}`,
149
+ { kept: existing, skipped: fact }
130
150
  );
131
151
  }
132
152
  return;
@@ -446,9 +446,9 @@ describe("FactIndex — query layer", () => {
446
446
  expect(() => ix.evidence([componentFact.id, ghost])).toThrow(/missing/i);
447
447
  });
448
448
 
449
- it("warns and keeps the first fact when a conflicting fact reuses an id", () => {
450
- const ix = new FactIndex();
451
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
449
+ it("reports through an opt-in callback and keeps the first fact when an id conflicts", () => {
450
+ const onConflict = vi.fn();
451
+ const ix = new FactIndex({ onConflict });
452
452
  const id = factId("prop_value_forbidden", {
453
453
  componentId: buttonId,
454
454
  prop: "variant",
@@ -469,14 +469,16 @@ describe("FactIndex — query layer", () => {
469
469
  };
470
470
  ix.add(a);
471
471
  expect(() => ix.add(b)).not.toThrow();
472
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"));
472
+ expect(onConflict).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"), {
473
+ kept: a,
474
+ skipped: b,
475
+ });
473
476
  expect(ix.get(id)).toEqual(a);
474
- warn.mockRestore();
475
477
  });
476
478
 
477
479
  it("does not treat token-definition provenance as a logical conflict", () => {
478
- const ix = new FactIndex();
479
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
480
+ const onConflict = vi.fn();
481
+ const ix = new FactIndex({ onConflict });
480
482
  const first = makeTokenDefinitionFact({
481
483
  name: "--fui-color-accent",
482
484
  value: "#2563eb",
@@ -491,14 +493,13 @@ describe("FactIndex — query layer", () => {
491
493
  ix.add(first);
492
494
  ix.add(second);
493
495
 
494
- expect(warn).not.toHaveBeenCalled();
496
+ expect(onConflict).not.toHaveBeenCalled();
495
497
  expect(ix.get(first.id)).toEqual(first);
496
- warn.mockRestore();
497
498
  });
498
499
 
499
- it("includes token-definition provenance in logical conflict warnings", () => {
500
- const ix = new FactIndex();
501
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
500
+ it("includes token-definition provenance in logical conflict reports", () => {
501
+ const onConflict = vi.fn();
502
+ const ix = new FactIndex({ onConflict });
502
503
  const first = makeTokenDefinitionFact({
503
504
  name: "--fui-color-accent",
504
505
  value: "#2563eb",
@@ -513,13 +514,13 @@ describe("FactIndex — query layer", () => {
513
514
  ix.add(first);
514
515
  ix.add(second);
515
516
 
516
- expect(warn).toHaveBeenCalledWith(
517
+ expect(onConflict).toHaveBeenCalledWith(
517
518
  expect.stringMatching(
518
519
  /conflicting facts.*keeping kind=token_definition location=tokens\/base\.css:2:1, skipping kind=token_definition location=tokens\/theme\.css:4:1/
519
- )
520
+ ),
521
+ { kept: first, skipped: second }
520
522
  );
521
523
  expect(ix.get(first.id)).toEqual(first);
522
- warn.mockRestore();
523
524
  });
524
525
 
525
526
  it("treats old and current owned component spellings as the same indexed facts", () => {
@@ -527,17 +528,16 @@ describe("FactIndex — query layer", () => {
527
528
  const currentId = asComponentId("@usefragments/ui#Button");
528
529
  const legacy = compileComponentFacts(legacyId, buildSampleFragment());
529
530
  const current = compileComponentFacts(currentId, buildSampleFragment());
530
- const ix = new FactIndex();
531
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
531
+ const onConflict = vi.fn();
532
+ const ix = new FactIndex({ onConflict });
532
533
 
533
534
  ix.addMany(legacy);
534
535
  ix.addMany(current);
535
536
 
536
- expect(warn).not.toHaveBeenCalled();
537
+ expect(onConflict).not.toHaveBeenCalled();
537
538
  expect(ix.size()).toBe(legacy.length);
538
539
  expect(ix.components.byId(legacyId)?.name).toBe("Button");
539
540
  expect(ix.components.byId(currentId)?.name).toBe("Button");
540
- warn.mockRestore();
541
541
  });
542
542
  });
543
543
 
@@ -53,6 +53,7 @@ export type {
53
53
  UsageInlineStyleFact,
54
54
  UsageTextChildFact,
55
55
  StyleDeclarationFact,
56
+ StyleValueProvenance,
56
57
  StyleUnsupportedFact,
57
58
  UnsupportedStyleReason,
58
59
  ClassNameLiteralFact,
@@ -60,11 +61,11 @@ export type {
60
61
  ClassNameFact,
61
62
  ClassNameOrigin,
62
63
  ClassNameDynamicReason,
63
- TailwindClassFact,
64
- TailwindModifier,
65
- TailwindModifierKind,
66
- SuppressionDirectiveFact,
67
- TailwindResolvedKind,
64
+ TailwindClassFact,
65
+ TailwindModifier,
66
+ TailwindModifierKind,
67
+ SuppressionDirectiveFact,
68
+ TailwindResolvedKind,
68
69
  TailwindResolutionSource,
69
70
  TailwindResolvedValue,
70
71
  TailwindTokenResolvedFact,
@@ -119,7 +120,9 @@ export {
119
120
  } from "./builders.js";
120
121
 
121
122
  export { FactIndex, matchesGlob } from "./fact-index.js";
122
- export type { FactEvidence } from "./fact-index.js";
123
+ export type { FactConflict, FactEvidence } from "./fact-index.js";
123
124
 
124
125
  export { compileGlobalGovernanceFacts, compileComponentFacts } from "./compile.js";
126
+ export { projectSupersededImportPathPreferences } from "./compile.js";
127
+ export type { SupersededImportPathPreference } from "./compile.js";
125
128
  export type { ComponentFactInput } from "./compile.js";