@usefragments/core 1.5.2 → 1.7.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 (64) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +1 -1
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/compiled-types/index.js +8 -0
  9. package/dist/generate/index.d.ts +1 -1
  10. package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
  11. package/dist/index.d.ts +800 -179
  12. package/dist/index.js +1003 -237
  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 +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +2 -1
  19. package/src/__tests__/policy-exclude.test.ts +180 -0
  20. package/src/agent-format.test.ts +13 -0
  21. package/src/agent-format.ts +9 -3
  22. package/src/canonical-bridge.ts +69 -1
  23. package/src/canonical-direction.test.ts +118 -0
  24. package/src/canonical-direction.ts +43 -2
  25. package/src/codes/__tests__/codes.test.ts +14 -2
  26. package/src/codes/codes.ts +41 -2
  27. package/src/compiled-types/index.ts +81 -0
  28. package/src/compiled-types/parse.test.ts +47 -0
  29. package/src/facts/builders.ts +48 -0
  30. package/src/facts/compile.ts +148 -34
  31. package/src/facts/fact-index.ts +15 -2
  32. package/src/facts/facts.test.ts +44 -3
  33. package/src/facts/index.ts +11 -6
  34. package/src/facts/types.ts +60 -9
  35. package/src/governance-integrity.test.ts +272 -4
  36. package/src/governance-integrity.ts +345 -25
  37. package/src/governance.ts +87 -1
  38. package/src/index.ts +46 -1
  39. package/src/policy-exclude.ts +113 -0
  40. package/src/rules/a11y-required-accessible-name.ts +175 -28
  41. package/src/rules/a11y-standard.ts +102 -0
  42. package/src/rules/a11y-utils.ts +7 -0
  43. package/src/rules/components-prefer-library.test.ts +75 -28
  44. package/src/rules/components-prefer-library.ts +35 -15
  45. package/src/rules/components-shadow-component.test.ts +21 -9
  46. package/src/rules/emit-gate.test.ts +74 -4
  47. package/src/rules/emit-gate.ts +24 -9
  48. package/src/rules/families.test.ts +69 -0
  49. package/src/rules/families.ts +52 -0
  50. package/src/rules/fix-availability.ts +1 -0
  51. package/src/rules/index.ts +18 -2
  52. package/src/rules/jsx-preferred-import-path.ts +29 -11
  53. package/src/rules/rules.test.ts +188 -8
  54. package/src/rules/styles-no-raw-color.ts +13 -4
  55. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  56. package/src/rules/styles-no-raw-spacing.ts +12 -4
  57. package/src/rules/styles-no-raw-typography.ts +13 -4
  58. package/src/rules/tiers.ts +1 -0
  59. package/src/rules/utils.ts +39 -0
  60. package/src/tokens/design-token-parser.test.ts +131 -0
  61. package/src/tokens/design-token-parser.ts +362 -49
  62. package/src/types.ts +2 -2
  63. package/dist/chunk-BAHCOAVG.js.map +0 -1
  64. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
@@ -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,
@@ -56,6 +58,7 @@ import type {
56
58
  ThemeDeclarationFact,
57
59
  TokenDefinitionFact,
58
60
  UsageComponentFact,
61
+ UsageChildContentFact,
59
62
  UsageImportFact,
60
63
  UsageInlineStyleFact,
61
64
  UsageNodeFact,
@@ -64,6 +67,17 @@ import type {
64
67
  FactId,
65
68
  } from "./types.js";
66
69
 
70
+ /**
71
+ * Attach a policy record's path excludes without perturbing its fact identity — the
72
+ * field is omitted entirely when nothing was authored, so a config with no excludes
73
+ * produces byte-identical facts (and therefore identical finding fingerprints).
74
+ */
75
+ function policyExcludeField(exclude: readonly PolicyExclude[] | undefined): {
76
+ exclude?: PolicyExclude[];
77
+ } {
78
+ return exclude?.length ? { exclude: exclude.map((entry) => ({ ...entry })) } : {};
79
+ }
80
+
67
81
  // ---------------------------------------------------------------------------
68
82
  // Component facts
69
83
  // ---------------------------------------------------------------------------
@@ -372,6 +386,7 @@ export function makeStyleRawColorForbiddenFact(input: {
372
386
  except: string[];
373
387
  prefer: "token" | "css-variable";
374
388
  severity: GovernanceSeverity;
389
+ exclude?: readonly PolicyExclude[];
375
390
  }): StyleRawColorForbiddenFact {
376
391
  return {
377
392
  id: factId("style_raw_color_forbidden", {}),
@@ -379,6 +394,7 @@ export function makeStyleRawColorForbiddenFact(input: {
379
394
  except: [...input.except],
380
395
  prefer: input.prefer,
381
396
  severity: input.severity,
397
+ ...policyExcludeField(input.exclude),
382
398
  };
383
399
  }
384
400
 
@@ -386,6 +402,7 @@ export function makeStyleRawDimensionForbiddenFact(input: {
386
402
  appliesTo: string[];
387
403
  prefer: "token" | "css-variable";
388
404
  severity: GovernanceSeverity;
405
+ exclude?: readonly PolicyExclude[];
389
406
  }): StyleRawDimensionForbiddenFact {
390
407
  return {
391
408
  id: factId("style_raw_dimension_forbidden", {}),
@@ -393,6 +410,7 @@ export function makeStyleRawDimensionForbiddenFact(input: {
393
410
  appliesTo: [...input.appliesTo],
394
411
  prefer: input.prefer,
395
412
  severity: input.severity,
413
+ ...policyExcludeField(input.exclude),
396
414
  };
397
415
  }
398
416
 
@@ -400,6 +418,7 @@ export function makeStylePropertyScaleFact(input: {
400
418
  property: string;
401
419
  scale: string;
402
420
  severity: GovernanceSeverity;
421
+ exclude?: readonly PolicyExclude[];
403
422
  }): StylePropertyScaleFact {
404
423
  return {
405
424
  id: factId("style_property_scale", { property: input.property }),
@@ -407,28 +426,33 @@ export function makeStylePropertyScaleFact(input: {
407
426
  property: input.property,
408
427
  scale: input.scale,
409
428
  severity: input.severity,
429
+ ...policyExcludeField(input.exclude),
410
430
  };
411
431
  }
412
432
 
413
433
  export function makeStyleFontSizeScaleFact(input: {
414
434
  scale: string;
415
435
  severity: GovernanceSeverity;
436
+ exclude?: readonly PolicyExclude[];
416
437
  }): StyleFontSizeScaleFact {
417
438
  return {
418
439
  id: factId("style_font_size_scale", {}),
419
440
  kind: "style_font_size_scale",
420
441
  scale: input.scale,
421
442
  severity: input.severity,
443
+ ...policyExcludeField(input.exclude),
422
444
  };
423
445
  }
424
446
 
425
447
  export function makeStyleCssVarsMustBeDefinedFact(input: {
426
448
  severity: GovernanceSeverity;
449
+ exclude?: readonly PolicyExclude[];
427
450
  }): StyleCssVarsMustBeDefinedFact {
428
451
  return {
429
452
  id: factId("style_css_vars_must_be_defined", {}),
430
453
  kind: "style_css_vars_must_be_defined",
431
454
  severity: input.severity,
455
+ ...policyExcludeField(input.exclude),
432
456
  };
433
457
  }
434
458
 
@@ -443,23 +467,27 @@ export function makeContractTokenFact(input: { name: string }): ContractTokenFac
443
467
 
444
468
  export function makeJsxUnknownPropsForbiddenFact(input: {
445
469
  severity: GovernanceSeverity;
470
+ exclude?: readonly PolicyExclude[];
446
471
  }): JsxUnknownPropsForbiddenFact {
447
472
  return {
448
473
  id: factId("jsx_unknown_props_forbidden", {}),
449
474
  kind: "jsx_unknown_props_forbidden",
450
475
  severity: input.severity,
476
+ ...policyExcludeField(input.exclude),
451
477
  };
452
478
  }
453
479
 
454
480
  export function makeJsxInlineStyleForbiddenRawFact(input: {
455
481
  property: string;
456
482
  severity: GovernanceSeverity;
483
+ exclude?: readonly PolicyExclude[];
457
484
  }): JsxInlineStyleForbiddenRawFact {
458
485
  return {
459
486
  id: factId("jsx_inline_style_forbidden_raw", { property: input.property }),
460
487
  kind: "jsx_inline_style_forbidden_raw",
461
488
  property: input.property,
462
489
  severity: input.severity,
490
+ ...policyExcludeField(input.exclude),
463
491
  };
464
492
  }
465
493
 
@@ -470,6 +498,7 @@ export function makeJsxImportPathPreferredFact(input: {
470
498
  because?: string;
471
499
  severity: GovernanceSeverity;
472
500
  bridge?: JsxImportPathPreferredFact["bridge"];
501
+ exclude?: readonly PolicyExclude[];
473
502
  }): JsxImportPathPreferredFact {
474
503
  return {
475
504
  id: factId("jsx_import_path_preferred", {
@@ -492,6 +521,7 @@ export function makeJsxImportPathPreferredFact(input: {
492
521
  },
493
522
  }
494
523
  : {}),
524
+ ...policyExcludeField(input.exclude),
495
525
  };
496
526
  }
497
527
 
@@ -500,6 +530,7 @@ export function makeJsxComponentPreferredFact(input: {
500
530
  to: ComponentId;
501
531
  because?: string;
502
532
  severity: GovernanceSeverity;
533
+ exclude?: readonly PolicyExclude[];
503
534
  }): JsxComponentPreferredFact {
504
535
  return {
505
536
  id: factId("jsx_component_preferred", {
@@ -511,6 +542,7 @@ export function makeJsxComponentPreferredFact(input: {
511
542
  to: input.to,
512
543
  because: input.because,
513
544
  severity: input.severity,
545
+ ...policyExcludeField(input.exclude),
514
546
  };
515
547
  }
516
548
 
@@ -690,6 +722,7 @@ export function makeUsageInlineStyleFact(input: {
690
722
  property: string;
691
723
  valueKind: "static" | "number" | "css-variable" | "dynamic-raw";
692
724
  value: string;
725
+ valueFrom?: StyleValueProvenance;
693
726
  }): UsageInlineStyleFact {
694
727
  return {
695
728
  id: factId("usage_inline_style", {
@@ -701,6 +734,7 @@ export function makeUsageInlineStyleFact(input: {
701
734
  property: input.property,
702
735
  valueKind: input.valueKind,
703
736
  value: input.value,
737
+ ...(input.valueFrom ? { valueFrom: input.valueFrom } : {}),
704
738
  };
705
739
  }
706
740
 
@@ -718,6 +752,18 @@ export function makeUsageTextChildFact(input: {
718
752
  };
719
753
  }
720
754
 
755
+ export function makeUsageChildContentFact(input: {
756
+ nodeId: FactId;
757
+ content: UsageChildContentFact["content"];
758
+ }): UsageChildContentFact {
759
+ return {
760
+ id: factId("usage_child_content", { nodeId: input.nodeId }),
761
+ kind: "usage_child_content",
762
+ nodeId: input.nodeId,
763
+ content: input.content,
764
+ };
765
+ }
766
+
721
767
  export function makeClassNameLiteralFact(input: {
722
768
  file: string;
723
769
  nodeId: FactId;
@@ -856,6 +902,7 @@ export function makeStyleDeclarationFact(input: {
856
902
  value: string;
857
903
  location: FactLocation;
858
904
  declaredTokenSource?: boolean;
905
+ valueFrom?: StyleValueProvenance;
859
906
  }): StyleDeclarationFact {
860
907
  return {
861
908
  id: factId("style_declaration", {
@@ -872,6 +919,7 @@ export function makeStyleDeclarationFact(input: {
872
919
  value: input.value,
873
920
  location: input.location,
874
921
  ...(input.declaredTokenSource ? { declaredTokenSource: true } : {}),
922
+ ...(input.valueFrom ? { valueFrom: input.valueFrom } : {}),
875
923
  };
876
924
  }
877
925
 
@@ -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 [];
@@ -239,19 +353,19 @@ function compileRuleConfigFacts(govern: GovernanceConfig): PolicyFact[] {
239
353
  configs.set(ruleId, ruleConfigFromValue(value, govern.severity ?? "warn"));
240
354
  }
241
355
 
242
- if (
243
- govern.canonicalSources?.length &&
244
- configs.get("components/prefer-library")?.enabled !== false
245
- ) {
246
- const existing = configs.get("components/prefer-library");
247
- configs.set("components/prefer-library", {
248
- enabled: true,
249
- severity: existing?.severity ?? govern.severity ?? "warn",
250
- options: {
251
- ...existing?.options,
252
- canonicalSources: existing?.options?.canonicalSources ?? govern.canonicalSources,
253
- },
254
- });
356
+ if (govern.canonicalSources?.length) {
357
+ for (const ruleId of ["components/prefer-library", "components/shadow-component"] as const) {
358
+ const existing = configs.get(ruleId);
359
+ if (existing?.enabled === false) continue;
360
+ configs.set(ruleId, {
361
+ enabled: true,
362
+ severity: existing?.severity ?? govern.severity ?? "warn",
363
+ options: {
364
+ ...existing?.options,
365
+ canonicalSources: existing?.options?.canonicalSources ?? govern.canonicalSources,
366
+ },
367
+ });
368
+ }
255
369
  }
256
370
 
257
371
  return [...configs.entries()].map(([ruleId, config]) =>
@@ -45,9 +45,21 @@ 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
+
48
60
  export interface FactIndexOptions {
49
61
  /** Optional internal diagnostic route. Product output is quiet by default. */
50
- onConflict?: (message: string) => void;
62
+ onConflict?: (message: string, conflict: FactConflict) => void;
51
63
  }
52
64
 
53
65
  interface FactWithComponent {
@@ -133,7 +145,8 @@ export class FactIndex {
133
145
  canonicalJson(logicalFactForComparison(fact))
134
146
  ) {
135
147
  this.options.onConflict?.(
136
- `FactIndex: conflicting facts for id ${fact.id} — keeping ${describeFactForConflict(existing)}, skipping ${describeFactForConflict(fact)}`
148
+ `FactIndex: conflicting facts for id ${fact.id} — keeping ${describeFactForConflict(existing)}, skipping ${describeFactForConflict(fact)}`,
149
+ { kept: existing, skipped: fact }
137
150
  );
138
151
  }
139
152
  return;
@@ -370,7 +370,7 @@ describe("FactIndex — query layer", () => {
370
370
  expect(unknownProps?.severity).toBe("error");
371
371
  });
372
372
 
373
- it("activates prefer-library from top-level canonical sources", () => {
373
+ it("activates canonical-component rules from top-level canonical sources", () => {
374
374
  const ix = new FactIndex();
375
375
  ix.addMany(
376
376
  compileGlobalGovernanceFacts({
@@ -397,6 +397,43 @@ describe("FactIndex — query layer", () => {
397
397
  ],
398
398
  },
399
399
  });
400
+ expect(ix.policy.ruleConfig("components/shadow-component")).toMatchObject({
401
+ enabled: true,
402
+ severity: "warn",
403
+ options: {
404
+ canonicalSources: [
405
+ {
406
+ kind: "npm",
407
+ specifier: "@usefragments/ui",
408
+ include: ["Button"],
409
+ },
410
+ ],
411
+ },
412
+ });
413
+ });
414
+
415
+ it("preserves an explicit shadow-component disable when canonical sources are declared", () => {
416
+ const ix = new FactIndex();
417
+ ix.addMany(
418
+ compileGlobalGovernanceFacts({
419
+ canonicalSources: [
420
+ {
421
+ kind: "npm",
422
+ specifier: "@usefragments/ui",
423
+ include: ["Button"],
424
+ },
425
+ ],
426
+ rules: {
427
+ "components/shadow-component": false,
428
+ },
429
+ })
430
+ );
431
+
432
+ expect(ix.policy.ruleConfig("components/prefer-library")?.enabled).toBe(true);
433
+ expect(ix.policy.ruleConfig("components/shadow-component")).toMatchObject({
434
+ enabled: false,
435
+ ruleId: "components/shadow-component",
436
+ });
400
437
  });
401
438
 
402
439
  it("resolves preferred import and component policy facts", () => {
@@ -469,7 +506,10 @@ describe("FactIndex — query layer", () => {
469
506
  };
470
507
  ix.add(a);
471
508
  expect(() => ix.add(b)).not.toThrow();
472
- expect(onConflict).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"));
509
+ expect(onConflict).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"), {
510
+ kept: a,
511
+ skipped: b,
512
+ });
473
513
  expect(ix.get(id)).toEqual(a);
474
514
  });
475
515
 
@@ -514,7 +554,8 @@ describe("FactIndex — query layer", () => {
514
554
  expect(onConflict).toHaveBeenCalledWith(
515
555
  expect.stringMatching(
516
556
  /conflicting facts.*keeping kind=token_definition location=tokens\/base\.css:2:1, skipping kind=token_definition location=tokens\/theme\.css:4:1/
517
- )
557
+ ),
558
+ { kept: first, skipped: second }
518
559
  );
519
560
  expect(ix.get(first.id)).toEqual(first);
520
561
  });
@@ -48,11 +48,13 @@ export type {
48
48
  GovernanceRuleConfigFact,
49
49
  UsageNodeFact,
50
50
  UsageComponentFact,
51
+ UsageChildContentFact,
51
52
  UsageImportFact,
52
53
  UsagePropResolvedFact,
53
54
  UsageInlineStyleFact,
54
55
  UsageTextChildFact,
55
56
  StyleDeclarationFact,
57
+ StyleValueProvenance,
56
58
  StyleUnsupportedFact,
57
59
  UnsupportedStyleReason,
58
60
  ClassNameLiteralFact,
@@ -60,11 +62,11 @@ export type {
60
62
  ClassNameFact,
61
63
  ClassNameOrigin,
62
64
  ClassNameDynamicReason,
63
- TailwindClassFact,
64
- TailwindModifier,
65
- TailwindModifierKind,
66
- SuppressionDirectiveFact,
67
- TailwindResolvedKind,
65
+ TailwindClassFact,
66
+ TailwindModifier,
67
+ TailwindModifierKind,
68
+ SuppressionDirectiveFact,
69
+ TailwindResolvedKind,
68
70
  TailwindResolutionSource,
69
71
  TailwindResolvedValue,
70
72
  TailwindTokenResolvedFact,
@@ -105,6 +107,7 @@ export {
105
107
  makeGovernanceRuleConfigFact,
106
108
  makeUsageNodeFact,
107
109
  makeUsageComponentFact,
110
+ makeUsageChildContentFact,
108
111
  makeUsageImportFact,
109
112
  makeUsagePropResolvedFact,
110
113
  makeUsageInlineStyleFact,
@@ -119,7 +122,9 @@ export {
119
122
  } from "./builders.js";
120
123
 
121
124
  export { FactIndex, matchesGlob } from "./fact-index.js";
122
- export type { FactEvidence } from "./fact-index.js";
125
+ export type { FactConflict, FactEvidence } from "./fact-index.js";
123
126
 
124
127
  export { compileGlobalGovernanceFacts, compileComponentFacts } from "./compile.js";
128
+ export { projectSupersededImportPathPreferences } from "./compile.js";
129
+ export type { SupersededImportPathPreference } from "./compile.js";
125
130
  export type { ComponentFactInput } from "./compile.js";