@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
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The load-bearing distinction is **enabled ≠ armed**. A rule can be `enabled`
6
6
  * in the policy yet inert because it has no vocabulary to measure against:
7
- * `components/prefer-library` with no effective canonical source enforces
7
+ * canonical-component rules with no effective canonical source enforce
8
8
  * nothing; `tokens/css-vars-must-be-defined` with an empty token vocabulary
9
9
  * enforces nothing. This module derives, per rule *family*, whether the family
10
10
  * is armed (has both an enabled rule AND the vocabulary/signal it needs), then
@@ -18,14 +18,26 @@
18
18
  */
19
19
 
20
20
  import type { CanonicalSource, GovernanceConfig, GovernanceSeverity } from "./governance.js";
21
+ import {
22
+ normalizePolicyExcludes,
23
+ policyExcludeMatchesPath,
24
+ type PolicyExclude,
25
+ } from "./policy-exclude.js";
21
26
  import { compileGlobalGovernanceFacts } from "./facts/index.js";
27
+ import type { FactConflict } from "./facts/index.js";
28
+ import { RULE_FAMILY_IDS } from "./rules/families.js";
22
29
  import { FRAGMENTS_INTERNAL_RULE_IDS, RULE_TIER } from "./rules/tiers.js";
23
30
  import { BLOCKING_RULE_ALLOWLIST } from "./rules/emit-gate.js";
24
31
 
25
32
  export type GovernanceIntegrityStatus = "healthy" | "degraded" | "inert";
26
33
 
27
- export type InertConfigDiagnosticKind = "orphan-scale" | "unconsumed-key";
28
- export type InertConfigDiagnosticCode = "FUI9004" | "FUI9005";
34
+ export type InertConfigDiagnosticKind =
35
+ | "orphan-scale"
36
+ | "unconsumed-key"
37
+ | "unmatched-exclude"
38
+ | "colliding-record"
39
+ | "overridden-record-severity";
40
+ export type InertConfigDiagnosticCode = "FUI9004" | "FUI9005" | "FUI9006" | "FUI9007" | "FUI9008";
29
41
 
30
42
  export interface InertConfigDiagnostic {
31
43
  code: InertConfigDiagnosticCode;
@@ -107,7 +119,7 @@ export function isEffectiveCanonicalSource(source: CanonicalSource): boolean {
107
119
  }
108
120
 
109
121
  /**
110
- * Whether `components/prefer-library`'s effective options carry a real component
122
+ * Whether a canonical-component rule's effective options carry a real component
111
123
  * vocabulary: at least one canonical mapping, or at least one effective
112
124
  * canonical source. Accepts the raw fact `options` bag (values are `unknown`).
113
125
  */
@@ -126,18 +138,27 @@ export function hasEffectiveComponentVocabulary(
126
138
  /**
127
139
  * Compile the policy to effective per-rule configs, keyed by rule id. Reuses the
128
140
  * fact compiler so the effective enabled/severity/options match exactly what the
129
- * scan engine sees (including the canonicalSources → prefer-library injection).
141
+ * scan engine sees (including canonicalSources → component-rule injection).
130
142
  */
131
143
  function effectiveRuleConfigs(
132
144
  policy: GovernanceConfig | undefined
133
145
  ): Map<string, EffectiveRuleConfig> {
134
146
  const configs = new Map<string, EffectiveRuleConfig>();
135
- for (const fact of compileGlobalGovernanceFacts(policy)) {
136
- if (fact.kind !== "governance_rule_config") continue;
137
- configs.set(fact.ruleId, {
138
- enabled: fact.enabled,
139
- severity: fact.severity,
140
- options: fact.options,
147
+ const facts = compileGlobalGovernanceFacts(policy);
148
+ for (const fact of facts) {
149
+ if (fact.kind === "governance_rule_config") {
150
+ configs.set(fact.ruleId, {
151
+ enabled: fact.enabled,
152
+ severity: fact.severity,
153
+ options: fact.options,
154
+ });
155
+ }
156
+ }
157
+ const cssVarsPolicy = facts.find((fact) => fact.kind === "style_css_vars_must_be_defined");
158
+ if (cssVarsPolicy?.kind === "style_css_vars_must_be_defined") {
159
+ configs.set("tokens/css-vars-must-be-defined", {
160
+ enabled: true,
161
+ severity: cssVarsPolicy.severity,
141
162
  });
142
163
  }
143
164
  return configs;
@@ -152,10 +173,13 @@ function dedupe(values: string[]): string[] {
152
173
  return [...new Set(values)];
153
174
  }
154
175
 
155
- const RULE_FAMILY_IDS = new Set(["tokens/hardcoded-values", "components/usage", "a11y/wcag"]);
156
176
  const CONSUMED_RULE_IDS = new Set(Object.keys(RULE_TIER));
157
177
  const RECOGNIZED_RULE_IDS = new Set([...CONSUMED_RULE_IDS, ...RULE_FAMILY_IDS]);
158
- const RULE_CONFIG_KEYS = new Set(["enabled", "severity", "options"]);
178
+ // `exclude` is consumed by the scan's finding-override pass (it scopes the rule off
179
+ // the matching paths and reports each drop in the ignored accounting). It was absent
180
+ // from this allow-set, so a working key was diagnosed as inert — the diagnostic was
181
+ // wrong, not the config.
182
+ const RULE_CONFIG_KEYS = new Set(["enabled", "severity", "options", "exclude"]);
159
183
 
160
184
  const PASSTHROUGH_KEYS = [
161
185
  {
@@ -195,7 +219,7 @@ const PASSTHROUGH_KEYS = [
195
219
  },
196
220
  {
197
221
  path: ["govern", "ci"],
198
- keys: ["failOnWarnings"],
222
+ keys: ["failOnWarnings", "failOnInert"],
199
223
  },
200
224
  ] as const;
201
225
 
@@ -237,6 +261,232 @@ function unconsumedKeyDiagnostic(path: readonly (string | number)[]): InertConfi
237
261
  };
238
262
  }
239
263
 
264
+ /**
265
+ * Config-record fact kinds: the policy facts that a `govern.*` record compiles into,
266
+ * paired with the config section that authored them, the authored record that produced
267
+ * them, and the identity keys their fact id is built from.
268
+ *
269
+ * `record` is load-bearing, not decoration. Several kinds in one section key on nothing
270
+ * (`style.rawColors.forbid`, `style.cssVars.mustBeDefined`, …) or on the same fields
271
+ * (`jsx.importPath.prefer` and `jsx.component.prefer` both on from/to), so a
272
+ * section-only diagnostic path makes two DIFFERENT collisions look like one — and the
273
+ * (code, path) dedupe every consumer runs then discards all but the last, which is the
274
+ * silence this diagnostic exists to end.
275
+ *
276
+ * Deliberately NOT the whole `PolicyFact` union — `token_definition` and
277
+ * `contract_token` are compiled from token *files*, not authored config records, and
278
+ * their collisions belong to the token-catalog diagnostics channel. Naming a duplicate
279
+ * token as a config defect would misattribute it and drown the real signal.
280
+ */
281
+ const CONFIG_RECORD_FACT_KINDS: Readonly<
282
+ Record<string, { section: string; record: string; keys: string[] }>
283
+ > = {
284
+ scale: { section: "govern.scales", record: "scale", keys: ["name"] },
285
+ scale_value: { section: "govern.scales", record: "scale.values", keys: ["scale", "value"] },
286
+ style_raw_color_forbidden: {
287
+ section: "govern.styles",
288
+ record: "style.rawColors.forbid",
289
+ keys: [],
290
+ },
291
+ style_raw_dimension_forbidden: {
292
+ section: "govern.styles",
293
+ record: "style.rawDimensions.forbid",
294
+ keys: [],
295
+ },
296
+ style_property_scale: {
297
+ section: "govern.styles",
298
+ record: "style.rawSpacing.mustMatchScale",
299
+ keys: ["property"],
300
+ },
301
+ style_font_size_scale: {
302
+ section: "govern.styles",
303
+ record: "style.fontSize.mustMatchScale",
304
+ keys: [],
305
+ },
306
+ style_css_vars_must_be_defined: {
307
+ section: "govern.styles",
308
+ record: "style.cssVars.mustBeDefined",
309
+ keys: [],
310
+ },
311
+ jsx_unknown_props_forbidden: {
312
+ section: "govern.jsx",
313
+ record: "jsx.unknownProps.forbid",
314
+ keys: [],
315
+ },
316
+ jsx_inline_style_forbidden_raw: {
317
+ section: "govern.jsx",
318
+ record: "jsx.inlineStyle.forbidRaw",
319
+ keys: ["property"],
320
+ },
321
+ jsx_import_path_preferred: {
322
+ section: "govern.jsx",
323
+ record: "jsx.importPath.prefer",
324
+ keys: ["from", "to", "imported"],
325
+ },
326
+ jsx_component_preferred: {
327
+ section: "govern.jsx",
328
+ record: "jsx.component.prefer",
329
+ keys: ["from", "to"],
330
+ },
331
+ prop_value_avoided: {
332
+ section: "govern.components",
333
+ record: "prop.value.avoid",
334
+ keys: ["componentId", "prop", "value"],
335
+ },
336
+ prop_value_forbidden: {
337
+ section: "govern.components",
338
+ record: "prop.value.forbid",
339
+ keys: ["componentId", "prop", "value", "pathPattern"],
340
+ },
341
+ a11y_name_required: {
342
+ section: "govern.components",
343
+ record: "a11y.requireName",
344
+ keys: ["componentId"],
345
+ },
346
+ tailwind_palette_allow: {
347
+ section: "govern.tailwind",
348
+ record: "palette.allow",
349
+ keys: [],
350
+ },
351
+ tailwind_palette_deny: { section: "govern.tailwind", record: "palette.deny", keys: [] },
352
+ tailwind_unknown_class_enabled: {
353
+ section: "govern.tailwind",
354
+ record: "unknownClass",
355
+ keys: [],
356
+ },
357
+ governance_rule_config: { section: "govern.rules", record: "rule", keys: ["ruleId"] },
358
+ };
359
+
360
+ /** The authored settings a reader needs to see to tell two colliding records apart. */
361
+ const COLLISION_DETAIL_KEYS = [
362
+ "severity",
363
+ "scale",
364
+ "prefer",
365
+ "enabled",
366
+ "appliesTo",
367
+ "except",
368
+ "properties",
369
+ "because",
370
+ ] as const;
371
+
372
+ function renderFactFields(fact: Record<string, unknown>, keys: readonly string[]): string {
373
+ return keys
374
+ .filter((key) => fact[key] !== undefined)
375
+ .map((key) => `${key}=${Array.isArray(fact[key]) ? JSON.stringify(fact[key]) : fact[key]}`)
376
+ .join(" ");
377
+ }
378
+
379
+ /**
380
+ * Name a config record that the fact index dropped as a first-wins duplicate.
381
+ *
382
+ * This is the Layer-6 backstop, not the primary mechanism: merge-time displacement
383
+ * (`mergeGovernanceConfigs`) already makes the later record win for every shape it can
384
+ * key. What reaches here is a collision displacement could NOT resolve — two records
385
+ * that compile to one fact id from different channels (config vs bridge vs Cloud
386
+ * policy). The invariant it defends: a dropped policy fact is never silent.
387
+ *
388
+ * Returns `null` for non-config facts, so usage/token collisions stay on the internal
389
+ * debug channel.
390
+ */
391
+ export function collidingRecordDiagnostic(conflict: FactConflict): InertConfigDiagnostic | null {
392
+ return collidingRecordDiagnostics([conflict])[0] ?? null;
393
+ }
394
+
395
+ /**
396
+ * Aggregate every observed collision into one diagnostic per colliding fact id.
397
+ *
398
+ * Three same-kind records on one fact id produce TWO conflicts (first-wins keeps the
399
+ * first and skips both others), and both render the same path. Emitting them separately
400
+ * meant the `(code, path)` dedupe every consumer runs kept only the last pair, so the
401
+ * intermediate record was dropped in silence — the same failure the per-kind path fixed
402
+ * one layer down. Aggregating names the count and lists every dropped setting, and one
403
+ * user edit still resolves the whole group.
404
+ */
405
+ export function collidingRecordDiagnostics(
406
+ conflicts: readonly FactConflict[]
407
+ ): InertConfigDiagnostic[] {
408
+ const groups = new Map<
409
+ string,
410
+ { record: string; kept: string; dropped: string[]; keptFallback: string }
411
+ >();
412
+
413
+ for (const conflict of conflicts) {
414
+ const shape = CONFIG_RECORD_FACT_KINDS[conflict.kept.kind];
415
+ if (!shape || conflict.kept.kind !== conflict.skipped.kind) continue;
416
+
417
+ const keptFact = conflict.kept as unknown as Record<string, unknown>;
418
+ const skippedFact = conflict.skipped as unknown as Record<string, unknown>;
419
+ const identity = renderFactFields(keptFact, shape.keys);
420
+ // The record kind is part of the identity, not just the prose: two singleton-keyed
421
+ // kinds in one section would otherwise share a path and collapse under the (code,
422
+ // path) dedupe, dropping one collision silently.
423
+ const path = `${shape.section}[${identity ? `${shape.record} ${identity}` : shape.record}]`;
424
+ const group = groups.get(path) ?? {
425
+ record: shape.record,
426
+ kept: renderFactFields(keptFact, COLLISION_DETAIL_KEYS),
427
+ dropped: [],
428
+ keptFallback: "the first record",
429
+ };
430
+ const dropped = renderFactFields(skippedFact, COLLISION_DETAIL_KEYS);
431
+ group.dropped.push(dropped || `record ${group.dropped.length + 2}`);
432
+ groups.set(path, group);
433
+ }
434
+
435
+ return [...groups].map(([path, group]) => ({
436
+ code: "FUI9007" as const,
437
+ kind: "colliding-record" as const,
438
+ severity: "warn" as const,
439
+ path,
440
+ message:
441
+ `${path} has ${group.dropped.length + 1} \`${group.record}\` records that compile to the same policy fact — ` +
442
+ `enforcing ${group.kept || group.keptFallback} and dropping ${group.dropped.join("; ")}. ` +
443
+ "Author one record for this policy, or scope them apart, so the enforced setting is the one you can read.",
444
+ }));
445
+ }
446
+
447
+ /**
448
+ * The authored config record a policy fact was compiled from, or `null` when the fact
449
+ * did not come from a `govern.*` record (token facts, usage facts). One table, read by
450
+ * both the collision diagnostic and the override backstop below.
451
+ */
452
+ export function configRecordShape(kind: string): { section: string; record: string } | null {
453
+ const shape = CONFIG_RECORD_FACT_KINDS[kind];
454
+ return shape ? { section: shape.section, record: shape.record } : null;
455
+ }
456
+
457
+ /**
458
+ * Name a `govern.rules` override that outranks the severity a policy record authored.
459
+ *
460
+ * The backstop for the path provenance cannot reach. Locally, presets are merged here,
461
+ * so a preset's broad rule entry is tagged and treated as a default — a user record's
462
+ * `severity: "error"` wins (report #2 B4). A Cloud-served policy arrives whole: no merge
463
+ * ran, nothing is tagged, and a broad `tokens/hardcoded-values` entry in it still
464
+ * displaces the severity a record next to it authored. That cannot be fixed at this
465
+ * boundary, so it is named instead. Silence is the defect; an unfixable case must at
466
+ * least speak.
467
+ */
468
+ export function overriddenRecordSeverityDiagnostic(input: {
469
+ ruleId: string;
470
+ /** Config path of the override that won — `govern.rules[...]` or `govern.severity`. */
471
+ source: string;
472
+ section: string;
473
+ record: string;
474
+ authored: string;
475
+ enforced: string;
476
+ }): InertConfigDiagnostic {
477
+ const path = `${input.section}[${input.record}]`;
478
+ return {
479
+ code: "FUI9008",
480
+ kind: "overridden-record-severity",
481
+ severity: "warn",
482
+ path,
483
+ message:
484
+ `${path} authored severity=${input.authored}, but ${input.source} enforces ` +
485
+ `severity=${input.enforced} on \`${input.ruleId}\`. The record's severity is not what ` +
486
+ "gates CI — drop the rule override, or author the record at the severity you want enforced.",
487
+ };
488
+ }
489
+
240
490
  function collectStrippedKeys(
241
491
  authored: unknown,
242
492
  parsed: unknown,
@@ -369,6 +619,65 @@ export function detectUnconsumedConfigKeys(
369
619
  return stableConfigDiagnostics(diagnostics);
370
620
  }
371
621
 
622
+ /** Every authored exclude in a policy, paired with the config path that declared it. */
623
+ function authoredPolicyExcludes(
624
+ policy: GovernanceConfig | undefined
625
+ ): Array<{ path: string; scope: string; exclude: PolicyExclude }> {
626
+ const out: Array<{ path: string; scope: string; exclude: PolicyExclude }> = [];
627
+ const collect = (path: string, scope: string, raw: unknown) => {
628
+ for (const exclude of normalizePolicyExcludes(raw) ?? []) {
629
+ out.push({ path, scope, exclude });
630
+ }
631
+ };
632
+
633
+ (policy?.styles ?? []).forEach((record, index) => {
634
+ collect(`govern.styles[${index}].exclude`, record.kind, record.exclude);
635
+ });
636
+ (policy?.jsx ?? []).forEach((record, index) => {
637
+ collect(`govern.jsx[${index}].exclude`, record.kind, record.exclude);
638
+ });
639
+ for (const ruleId of Object.keys(policy?.rules ?? {}).sort()) {
640
+ const record = objectRecord(policy?.rules?.[ruleId]);
641
+ if (!record) continue;
642
+ collect(`govern.rules.${ruleId}.exclude`, ruleId, record.exclude);
643
+ }
644
+
645
+ return out;
646
+ }
647
+
648
+ /**
649
+ * Diagnose excludes that scope nothing: the key is consumed and the glob is valid, but
650
+ * it matched no scanned file, so it silently exempts nothing while reading as active
651
+ * policy. That is the same "declared ≠ armed" failure this module exists to name — a
652
+ * stale path after a refactor is the common cause.
653
+ *
654
+ * `scannedFiles` are repo-relative paths supplied by the caller; core never reads the
655
+ * filesystem. An empty scan (nothing to compare against) yields no diagnostics rather
656
+ * than flagging every exclude.
657
+ */
658
+ export function detectUnmatchedPolicyExcludes(
659
+ policy: GovernanceConfig | undefined,
660
+ scannedFiles: readonly string[]
661
+ ): InertConfigDiagnostic[] {
662
+ if (scannedFiles.length === 0) return [];
663
+ const diagnostics: InertConfigDiagnostic[] = [];
664
+
665
+ for (const { path, scope, exclude } of authoredPolicyExcludes(policy)) {
666
+ if (scannedFiles.some((file) => policyExcludeMatchesPath(exclude, file))) continue;
667
+ diagnostics.push({
668
+ code: "FUI9006",
669
+ kind: "unmatched-exclude",
670
+ severity: "warn",
671
+ path,
672
+ message:
673
+ `${path} pattern "${exclude.glob}" matched no scanned file, so ${scope} is not ` +
674
+ "actually scoped by it. Correct the glob to a path this scan covers, or remove it.",
675
+ });
676
+ }
677
+
678
+ return stableConfigDiagnostics(diagnostics);
679
+ }
680
+
372
681
  interface ScaleBinding {
373
682
  label: string;
374
683
  mechanism: string;
@@ -468,28 +777,34 @@ export function evaluateGovernanceIntegrity(
468
777
  }
469
778
 
470
779
  // --- components family ----------------------------------------------------
471
- const componentsConfig = configs.get("components/prefer-library");
780
+ const preferLibraryConfig = configs.get("components/prefer-library");
781
+ const shadowComponentConfig = configs.get("components/shadow-component");
472
782
  const confirmedBridges = input.policy?.canonicalBridges ?? [];
473
- const componentsArmed =
474
- confirmedBridges.length > 0 ||
475
- (componentsConfig?.enabled === true &&
476
- hasEffectiveComponentVocabulary(componentsConfig.options));
783
+ const componentRuleEnabled =
784
+ preferLibraryConfig?.enabled === true || shadowComponentConfig?.enabled === true;
785
+ const preferLibraryArmed =
786
+ preferLibraryConfig?.enabled === true &&
787
+ hasEffectiveComponentVocabulary(preferLibraryConfig.options);
788
+ const shadowComponentArmed =
789
+ shadowComponentConfig?.enabled === true &&
790
+ hasEffectiveComponentVocabulary(shadowComponentConfig.options);
791
+ const componentsArmed = confirmedBridges.length > 0 || preferLibraryArmed || shadowComponentArmed;
477
792
  const componentsFamily: GovernanceIntegrityFamily = {
478
793
  id: "components",
479
794
  armed: componentsArmed,
480
795
  rules: [
481
796
  ...(confirmedBridges.length > 0 ? ["imports/preferred-path"] : []),
482
- ...(componentsConfig?.enabled === true ? ["components/prefer-library"] : []),
797
+ ...(preferLibraryConfig?.enabled === true ? ["components/prefer-library"] : []),
798
+ ...(shadowComponentConfig?.enabled === true ? ["components/shadow-component"] : []),
483
799
  ],
484
800
  };
485
801
  if (!componentsArmed) {
486
- if (componentsConfig?.enabled === true) {
487
- componentsFamily.reason =
488
- "components/prefer-library enabled but no effective canonical source";
802
+ if (componentRuleEnabled) {
803
+ componentsFamily.reason = "component governance enabled but no effective canonical source";
489
804
  componentsFamily.remediation =
490
805
  "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
491
806
  } else {
492
- componentsFamily.reason = "components/prefer-library not enabled";
807
+ componentsFamily.reason = "canonical-component rules not enabled";
493
808
  componentsFamily.remediation =
494
809
  "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
495
810
  }
@@ -541,7 +856,12 @@ export function evaluateGovernanceIntegrity(
541
856
  for (const ruleId of BLOCKING_RULE_ALLOWLIST) {
542
857
  const config = configs.get(ruleId);
543
858
  if (config?.enabled !== true) continue;
544
- const familyArmed = ruleId === "components/prefer-library" ? componentsArmed : hygieneArmed;
859
+ const familyArmed =
860
+ ruleId === "components/prefer-library" || ruleId === "components/shadow-component"
861
+ ? componentsArmed
862
+ : ruleId === "tokens/css-vars-must-be-defined"
863
+ ? tokensArmed
864
+ : hygieneArmed;
545
865
  if (!familyArmed) continue;
546
866
  if (config.severity !== "error" && !failOnWarnings) continue;
547
867
  blockingRules.push(ruleId);
package/src/governance.ts CHANGED
@@ -10,10 +10,18 @@ import type {
10
10
  PropDefinition,
11
11
  } from "./types.js";
12
12
  import { portableRepoPathError } from "./config-paths.js";
13
+ import { policyExcludeSchema } from "./policy-exclude.js";
13
14
 
14
15
  export const governanceSeveritySchema = z.enum(["error", "warn", "info"]);
15
16
  export type GovernanceSeverity = z.infer<typeof governanceSeveritySchema>;
16
17
 
18
+ /**
19
+ * Per-record path excludes. Scopes THIS policy record off the matching paths without
20
+ * hiding those paths from any other rule — an auditable exemption, not a baseline.
21
+ * Excluded findings are reported in the scan's ignored accounting with their reason.
22
+ */
23
+ const recordExcludeSchema = policyExcludeSchema.optional();
24
+
17
25
  export const scaleGovernanceRecordSchema = z.object({
18
26
  kind: z.literal("scale").default("scale"),
19
27
  name: z.string().min(1).optional(),
@@ -28,6 +36,7 @@ const styleRawColorsForbidRecordSchema = z.object({
28
36
  except: z.array(z.string()),
29
37
  prefer: z.enum(["token", "css-variable"]),
30
38
  severity: governanceSeveritySchema,
39
+ exclude: recordExcludeSchema,
31
40
  });
32
41
 
33
42
  const styleRawDimensionsForbidRecordSchema = z.object({
@@ -35,6 +44,7 @@ const styleRawDimensionsForbidRecordSchema = z.object({
35
44
  appliesTo: z.array(z.string().min(1)),
36
45
  prefer: z.enum(["token", "css-variable"]),
37
46
  severity: governanceSeveritySchema,
47
+ exclude: recordExcludeSchema,
38
48
  });
39
49
 
40
50
  const styleRawSpacingMustMatchScaleRecordSchema = z.object({
@@ -42,17 +52,20 @@ const styleRawSpacingMustMatchScaleRecordSchema = z.object({
42
52
  scale: z.string().min(1),
43
53
  appliesTo: z.array(z.string().min(1)),
44
54
  severity: governanceSeveritySchema,
55
+ exclude: recordExcludeSchema,
45
56
  });
46
57
 
47
58
  const styleFontSizeMustMatchScaleRecordSchema = z.object({
48
59
  kind: z.literal("style.fontSize.mustMatchScale"),
49
60
  scale: z.string().min(1),
50
61
  severity: governanceSeveritySchema,
62
+ exclude: recordExcludeSchema,
51
63
  });
52
64
 
53
65
  const styleCssVarsMustBeDefinedRecordSchema = z.object({
54
66
  kind: z.literal("style.cssVars.mustBeDefined"),
55
67
  severity: governanceSeveritySchema,
68
+ exclude: recordExcludeSchema,
56
69
  });
57
70
 
58
71
  export const globalStyleGovernanceRecordSchema = z.discriminatedUnion("kind", [
@@ -66,12 +79,14 @@ export const globalStyleGovernanceRecordSchema = z.discriminatedUnion("kind", [
66
79
  const jsxUnknownPropsForbidRecordSchema = z.object({
67
80
  kind: z.literal("jsx.unknownProps.forbid"),
68
81
  severity: governanceSeveritySchema,
82
+ exclude: recordExcludeSchema,
69
83
  });
70
84
 
71
85
  const jsxInlineStyleForbidRawRecordSchema = z.object({
72
86
  kind: z.literal("jsx.inlineStyle.forbidRaw"),
73
87
  properties: z.array(z.string().min(1)),
74
88
  severity: governanceSeveritySchema,
89
+ exclude: recordExcludeSchema,
75
90
  });
76
91
 
77
92
  const jsxImportPathPreferRecordSchema = z.object({
@@ -81,6 +96,7 @@ const jsxImportPathPreferRecordSchema = z.object({
81
96
  imported: z.string().min(1).optional(),
82
97
  because: z.string().optional(),
83
98
  severity: governanceSeveritySchema,
99
+ exclude: recordExcludeSchema,
84
100
  });
85
101
 
86
102
  const jsxComponentPreferRecordSchema = z.object({
@@ -89,6 +105,7 @@ const jsxComponentPreferRecordSchema = z.object({
89
105
  to: z.string().min(1),
90
106
  because: z.string().optional(),
91
107
  severity: governanceSeveritySchema,
108
+ exclude: recordExcludeSchema,
92
109
  });
93
110
 
94
111
  export const globalJsxGovernanceRecordSchema = z.discriminatedUnion("kind", [
@@ -337,6 +354,7 @@ export const governanceConfigSchema = z
337
354
  ci: z
338
355
  .object({
339
356
  failOnWarnings: z.boolean().optional(),
357
+ failOnInert: z.boolean().optional(),
340
358
  })
341
359
  .passthrough()
342
360
  .optional(),
@@ -424,14 +442,82 @@ export interface GovernanceConfig {
424
442
  /** Ordered component-policy overrides selected by component identity fields. */
425
443
  overrides?: ComponentPolicyOverride[];
426
444
 
427
- /** Governance CI rendering options, including whether warnings fail the CI verdict. */
445
+ /**
446
+ * Governance CI rendering options: `failOnWarnings` makes warning findings fail the
447
+ * `--ci` verdict, `failOnInert` makes inert-config diagnostics (FUI9004-FUI9008) fail
448
+ * it. Both are opt-in; `--allow-inert` bypasses the inert gates.
449
+ */
428
450
  ci?: {
429
451
  failOnWarnings?: boolean;
452
+ /**
453
+ * Fail the `--ci` verdict when the run reports inert-config diagnostics
454
+ * (FUI9004-FUI9008). Opt-in: the diagnostics themselves stay verdict-neutral,
455
+ * so a team can adopt the gate once its config is clean. `--allow-inert`
456
+ * bypasses it, like the governance-inert gate.
457
+ */
458
+ failOnInert?: boolean;
430
459
  [key: string]: unknown;
431
460
  };
432
461
  [key: string]: unknown;
433
462
  }
434
463
 
464
+ /**
465
+ * Provenance for a `govern.rules` entry: was it authored by the user, or contributed by
466
+ * a preset?
467
+ *
468
+ * A preset's broad entry (`tokens/hardcoded-values: { enabled: true, severity: "warn" }`)
469
+ * and a user's identical one mean different things. The preset's is a **default** — it
470
+ * says what the family looks like when nobody has an opinion. The user's is an
471
+ * **override** — they typed it. After merge the two are indistinguishable by shape, so
472
+ * the enforcement pass was capping user-authored `govern.styles[].severity: "error"`
473
+ * records at the preset's `warn` and saying nothing (report #2 B4).
474
+ *
475
+ * A global symbol rather than a config key, deliberately: provenance must not be
476
+ * authorable (a user cannot claim their entry came from a preset), must not survive the
477
+ * JSON round-trip into a served Cloud policy (where it would be a lie — that path merges
478
+ * no presets), and must not appear in a config the user reads back. `Symbol.for` so the
479
+ * marker survives duplicate module instances.
480
+ */
481
+ const PRESET_SOURCED_RULE = Symbol.for("@usefragments/core:preset-sourced-rule");
482
+
483
+ /**
484
+ * Tag every entry of a preset's `govern.rules` map as preset-sourced. Non-object entries
485
+ * (the `"warn"` string shorthand) cannot carry a symbol and are passed through untagged —
486
+ * presets author the object form, so this is a documented floor, not a silent gap.
487
+ *
488
+ * **Non-enumerable**, which is the difference between "does not serialize" and "is not
489
+ * there". A symbol key already survives neither `JSON.stringify` nor `Object.keys`, but
490
+ * it does survive `toEqual` — so an enumerable marker turns every consumer that compares
491
+ * a composed rule config structurally into a failing test, for a property they cannot see
492
+ * and did not ask for. Fragments Cloud's own policy composer was the first to hit it.
493
+ * Read it through `isPresetSourcedRule`; nothing else should know it exists.
494
+ */
495
+ export function markPresetSourcedRules(
496
+ rules: Record<string, unknown> | undefined
497
+ ): Record<string, unknown> | undefined {
498
+ if (!rules) return rules;
499
+ const out: Record<string, unknown> = {};
500
+ for (const [ruleId, entry] of Object.entries(rules)) {
501
+ out[ruleId] =
502
+ entry && typeof entry === "object" && !Array.isArray(entry)
503
+ ? Object.defineProperty({ ...(entry as Record<string, unknown>) }, PRESET_SOURCED_RULE, {
504
+ value: true,
505
+ enumerable: false,
506
+ })
507
+ : entry;
508
+ }
509
+ return out;
510
+ }
511
+
512
+ /** Whether a merged `govern.rules` entry came from a preset rather than the user. */
513
+ export function isPresetSourcedRule(entry: unknown): boolean {
514
+ return (
515
+ !!entry &&
516
+ typeof entry === "object" &&
517
+ (entry as Record<symbol, unknown>)[PRESET_SOURCED_RULE] === true
518
+ );
519
+ }
520
+
435
521
  type SeverityOption = {
436
522
  severity?: GovernanceSeverity;
437
523
  };