@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
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(),
@@ -355,18 +373,55 @@ export type CanonicalSource = z.infer<typeof canonicalSourceSchema>;
355
373
  export type CanonicalBridgeV1 = z.infer<typeof canonicalBridgeV1Schema>;
356
374
 
357
375
  export interface GovernanceConfig {
376
+ /** Shared governance config modules to extend before applying this file's declarations. */
358
377
  extends?: string[];
378
+
379
+ /** Default severity for governance rules that do not declare their own severity. */
359
380
  severity?: GovernanceSeverity;
381
+
382
+ /**
383
+ * Rule-id keyed enablement and severity overrides. Only fields consumed by the named
384
+ * rule are valid; unsupported fields are reported as inert config.
385
+ */
360
386
  rules?: Record<string, unknown>;
387
+
388
+ /** Agent-id keyed rule overrides for supported agent-specific governance policies. */
361
389
  agents?: Record<string, { rules?: Record<string, unknown> }>;
390
+
391
+ /** Reserved audit compatibility object; undeclared child keys are reported as inert. */
362
392
  audit?: Record<string, unknown>;
393
+
394
+ /** Reserved runner compatibility map; undeclared child keys are reported as inert. */
363
395
  runners?: Record<string, Record<string, unknown>>;
396
+
397
+ /**
398
+ * Canonical component authorities: npm packages, repository directories, or registry
399
+ * receipts whose included exports arm canonical-component rules.
400
+ */
364
401
  canonicalSources?: CanonicalSource[];
402
+
403
+ /**
404
+ * Confirmed mappings from an underlying library export to the approved local wrapper.
405
+ * The wrapper's implementationFiles scope permits its direct underlying import.
406
+ */
365
407
  canonicalBridges?: CanonicalBridgeV1[];
408
+
409
+ /** Versioned governance presets to resolve before applying local rule overrides. */
366
410
  presets?: string[];
411
+
412
+ /**
413
+ * Named numeric scales. Spacing rules bind through
414
+ * style.rawSpacing.mustMatchScale; the built-in spacing policy references `space`.
415
+ */
367
416
  scales?: Record<string, ScaleGovernanceRecord>;
417
+
418
+ /** Legacy typed style-policy records, normalized into the active rule policy. */
368
419
  styles?: GlobalStyleGovernanceRecord[];
420
+
421
+ /** Legacy typed JSX-policy records, normalized into the active rule policy. */
369
422
  jsx?: GlobalJsxGovernanceRecord[];
423
+
424
+ /** Tailwind palette allow/deny policy used by Tailwind governance rules. */
370
425
  tailwind?: {
371
426
  palette?: {
372
427
  allow?: string[];
@@ -374,19 +429,95 @@ export interface GovernanceConfig {
374
429
  };
375
430
  [key: string]: unknown;
376
431
  };
432
+
433
+ /** Agent repair-order guidance consumed when presenting deterministic fixes. */
377
434
  agent?: {
378
435
  repairOrder?: string[];
379
436
  [key: string]: unknown;
380
437
  };
438
+
439
+ /** Component-keyed governance records for canonical component metadata and prop policy. */
381
440
  components?: Record<string, ComponentPolicyRecord>;
441
+
442
+ /** Ordered component-policy overrides selected by component identity fields. */
382
443
  overrides?: ComponentPolicyOverride[];
444
+
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
+ */
383
450
  ci?: {
384
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;
385
459
  [key: string]: unknown;
386
460
  };
387
461
  [key: string]: unknown;
388
462
  }
389
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
+
390
521
  type SeverityOption = {
391
522
  severity?: GovernanceSeverity;
392
523
  };
package/src/index.ts CHANGED
@@ -425,7 +425,7 @@ export {
425
425
  } from "./schema.js";
426
426
 
427
427
  // Main API
428
- export { defineConfig } from "./config.js";
428
+ export { configDeclarationForDiagnostics, defineConfig } from "./config.js";
429
429
  export {
430
430
  defineFragment,
431
431
  compileFragment,
@@ -448,6 +448,8 @@ export {
448
448
  globalStyleGovernanceRecordSchema,
449
449
  governanceConfigSchema,
450
450
  governanceSeveritySchema,
451
+ isPresetSourcedRule,
452
+ markPresetSourcedRules,
451
453
  normalizeGovernanceConfig,
452
454
  scaleGovernanceRecordSchema,
453
455
  } from "./governance.js";
@@ -471,6 +473,18 @@ export type {
471
473
  ScaleGovernanceRecord,
472
474
  } from "./governance.js";
473
475
 
476
+ // Scoped policy exemptions — per-record / per-rule path excludes
477
+ export {
478
+ describePolicyExclude,
479
+ excludeGlobToRegExp,
480
+ matchPolicyExclude,
481
+ normalizeExcludePath,
482
+ normalizePolicyExcludes,
483
+ policyExcludeMatchesPath,
484
+ policyExcludeSchema,
485
+ } from "./policy-exclude.js";
486
+ export type { PolicyExclude, PolicyExcludeInput } from "./policy-exclude.js";
487
+
474
488
  // Story adapter (runtime conversion of Storybook modules)
475
489
  export {
476
490
  storyModuleToFragment,
@@ -706,6 +720,15 @@ export {
706
720
  } from "./rules/index.js";
707
721
  export type { RuleTier, PresetRuleState } from "./rules/index.js";
708
722
 
723
+ // Rule families — the ONE expansion of a broad `govern.rules` id (`tokens/hardcoded-values`)
724
+ // into the concrete rules it stands for. Three copies used to disagree.
725
+ export {
726
+ RULE_FAMILY_IDS,
727
+ RULE_FAMILY_MEMBERS,
728
+ isRuleFamilyId,
729
+ ruleFamilyMembers,
730
+ } from "./rules/index.js";
731
+
709
732
  // Blocking deny-set (Contract Mode Phase 0): shared "may this finding hard-deny
710
733
  // a write" criteria — single-sourced for the CLI hook + the emit gate.
711
734
  export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./rules/index.js";
@@ -713,6 +736,13 @@ export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./rules/index.
713
736
  // Governance integrity — armed-vs-declared verdict over a fully-resolved policy.
714
737
  // Encodes "enabled ≠ armed": a rule with no vocabulary enforces nothing.
715
738
  export {
739
+ collidingRecordDiagnostic,
740
+ collidingRecordDiagnostics,
741
+ configRecordShape,
742
+ overriddenRecordSeverityDiagnostic,
743
+ detectOrphanGovernanceScales,
744
+ detectUnconsumedConfigKeys,
745
+ detectUnmatchedPolicyExcludes,
716
746
  evaluateGovernanceIntegrity,
717
747
  hasEffectiveComponentVocabulary,
718
748
  isEffectiveCanonicalSource,
@@ -721,8 +751,12 @@ export type {
721
751
  GovernanceIntegrityFamily,
722
752
  GovernanceIntegrityFamilyId,
723
753
  GovernanceIntegrityInput,
754
+ GovernanceIntegrityRoster,
724
755
  GovernanceIntegrityStatus,
725
756
  GovernanceIntegrityVerdict,
757
+ InertConfigDiagnostic,
758
+ InertConfigDiagnosticCode,
759
+ InertConfigDiagnosticKind,
726
760
  } from "./governance-integrity.js";
727
761
 
728
762
  export {
@@ -731,7 +765,12 @@ export {
731
765
  } from "./canonical-direction.js";
732
766
  export type { CanonicalDirectionConflict } from "./canonical-direction.js";
733
767
 
734
- export { canonicalBridgeContractMappings } from "./canonical-bridge.js";
768
+ export {
769
+ canonicalBridgeContractMappings,
770
+ canonicalBridgeIdentityBindings,
771
+ canonicalBridgeIdentitySources,
772
+ } from "./canonical-bridge.js";
773
+ export type { CanonicalBridgeIdentityBinding } from "./canonical-bridge.js";
735
774
 
736
775
  export {
737
776
  compileEffectiveGovernanceInputs,
@@ -794,7 +833,9 @@ export {
794
833
  makeTailwindTokenResolvedFact,
795
834
  compileGlobalGovernanceFacts,
796
835
  compileComponentFacts,
836
+ projectSupersededImportPathPreferences,
797
837
  } from "./facts/index.js";
838
+ export type { SupersededImportPathPreference } from "./facts/index.js";
798
839
  export {
799
840
  canonicalPreimage,
800
841
  AGENT_CONTEXT_RELATIVE_PATH,
@@ -884,6 +925,7 @@ export type {
884
925
  ComponentId,
885
926
  FactId,
886
927
  Fact,
928
+ FactConflict,
887
929
  FactKind,
888
930
  FactOfKind,
889
931
  FactLocation,
@@ -926,6 +968,7 @@ export type {
926
968
  UsageInlineStyleFact,
927
969
  UsageTextChildFact,
928
970
  StyleDeclarationFact,
971
+ StyleValueProvenance,
929
972
  StyleUnsupportedFact,
930
973
  UnsupportedStyleReason,
931
974
  ClassNameLiteralFact,
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Policy path excludes — the one vocabulary every scoped-exemption surface shares.
3
+ *
4
+ * A global governance record (`govern.styles[]` / `govern.jsx[]`) or a rule-id-keyed
5
+ * override (`govern.rules["styles/no-raw-color"]`) may carry `exclude` globs that scope
6
+ * that policy off a path *without* hiding the path from every other rule — the
7
+ * difference between an exemption and a baseline. Two authoring forms are accepted:
8
+ *
9
+ * ```ts
10
+ * exclude: ["src/legacy/vendor/**"]
11
+ * exclude: [{ glob: "src/legacy/vendor/**", reason: "vendor drop, tracked in DS-611" }]
12
+ * ```
13
+ *
14
+ * Both normalize to `PolicyExclude`. The reason is free text and carries no expiry: an
15
+ * exclude is a standing scope decision, not a dated debt receipt (that is what
16
+ * suppressions and baselines are for). It travels into the scan's ignored-finding
17
+ * accounting so an exemption is never invisible policy.
18
+ *
19
+ * Browser-safe: pure string work, no filesystem.
20
+ */
21
+
22
+ import { z } from "zod";
23
+
24
+ export interface PolicyExclude {
25
+ /** Repo-relative glob; `*` stops at a path segment, `**` crosses segments. */
26
+ glob: string;
27
+ /** Why this path is out of scope for this policy. Surfaced in reports. */
28
+ reason?: string;
29
+ }
30
+
31
+ export const policyExcludeSchema = z.array(
32
+ z.union([
33
+ z.string().min(1),
34
+ z.object({
35
+ glob: z.string().min(1),
36
+ reason: z.string().min(1).optional(),
37
+ }),
38
+ ])
39
+ );
40
+
41
+ /** The authored (pre-normalization) shape of an `exclude` list. */
42
+ export type PolicyExcludeInput = z.infer<typeof policyExcludeSchema>;
43
+
44
+ /**
45
+ * Normalize an authored `exclude` list into `PolicyExclude[]`. Returns `undefined` when
46
+ * nothing usable was authored, so callers can omit the field entirely and keep fact
47
+ * shapes byte-identical for configs that declare no excludes.
48
+ */
49
+ export function normalizePolicyExcludes(value: unknown): PolicyExclude[] | undefined {
50
+ if (!Array.isArray(value)) return undefined;
51
+ const out: PolicyExclude[] = [];
52
+ for (const entry of value) {
53
+ if (typeof entry === "string") {
54
+ if (entry.length > 0) out.push({ glob: entry });
55
+ continue;
56
+ }
57
+ if (!entry || typeof entry !== "object") continue;
58
+ const record = entry as { glob?: unknown; reason?: unknown };
59
+ if (typeof record.glob !== "string" || record.glob.length === 0) continue;
60
+ out.push({
61
+ glob: record.glob,
62
+ ...(typeof record.reason === "string" && record.reason.length > 0
63
+ ? { reason: record.reason }
64
+ : {}),
65
+ });
66
+ }
67
+ return out.length > 0 ? out : undefined;
68
+ }
69
+
70
+ /** Normalize a repo path for glob comparison (POSIX separators, no `./` prefix). */
71
+ export function normalizeExcludePath(path: string): string {
72
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
73
+ }
74
+
75
+ /**
76
+ * Same glob dialect as the rest of the governance surface (`FactIndex.matchesGlob`,
77
+ * the scan's token-source matcher): `**` crosses path segments, `*` does not.
78
+ */
79
+ export function excludeGlobToRegExp(glob: string): RegExp {
80
+ const source = normalizeExcludePath(glob)
81
+ .split("**")
82
+ .map((part) =>
83
+ part
84
+ .split("*")
85
+ .map((segment) => segment.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"))
86
+ .join("[^/]*")
87
+ )
88
+ .join(".*");
89
+ return new RegExp(`^${source}$`);
90
+ }
91
+
92
+ /** Whether `filePath` is inside `exclude`'s glob. */
93
+ export function policyExcludeMatchesPath(exclude: PolicyExclude, filePath: string): boolean {
94
+ return excludeGlobToRegExp(exclude.glob).test(normalizeExcludePath(filePath));
95
+ }
96
+
97
+ /** The first exclude covering `filePath`, or `undefined`. */
98
+ export function matchPolicyExclude(
99
+ excludes: readonly PolicyExclude[] | undefined,
100
+ filePath: string
101
+ ): PolicyExclude | undefined {
102
+ if (!excludes?.length) return undefined;
103
+ const normalized = normalizeExcludePath(filePath);
104
+ return excludes.find((exclude) => excludeGlobToRegExp(exclude.glob).test(normalized));
105
+ }
106
+
107
+ /**
108
+ * The human half of the ignored-finding receipt: which policy stepped aside, on which
109
+ * glob, and why. `scope` is the policy's own identity (a record `kind` or a rule id).
110
+ */
111
+ export function describePolicyExclude(scope: string, exclude: PolicyExclude): string {
112
+ return `${scope} exclude matched ${exclude.glob}${exclude.reason ? ` — ${exclude.reason}` : ""}`;
113
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ RULE_FAMILY_IDS,
5
+ RULE_FAMILY_MEMBERS,
6
+ isRuleFamilyId,
7
+ ruleFamilyMembers,
8
+ } from "./families.js";
9
+ import { compileGlobalGovernanceFacts } from "../facts/index.js";
10
+ import { RULES } from "./index.js";
11
+ import { RULE_TIER } from "./tiers.js";
12
+
13
+ // The defect this file locks down: the family expansion used to be written out three
14
+ // times (engine finding-overrides, fact compiler, integrity roster) and the three
15
+ // disagreed — `styles/no-raw-typography` was a member in one and absent from another,
16
+ // with nothing asserting they matched. There is one definition now; these tests are what
17
+ // keeps a fourth copy from being worth writing.
18
+ describe("rule families", () => {
19
+ it("expands only to rule ids that are actually registered", () => {
20
+ const registered = new Set(RULES.map((rule) => rule.id));
21
+ const unregistered = Object.values(RULE_FAMILY_MEMBERS)
22
+ .flat()
23
+ .filter((ruleId) => !registered.has(ruleId));
24
+
25
+ expect(unregistered).toEqual([]);
26
+ });
27
+
28
+ it("classifies every member in the tier map", () => {
29
+ const unclassified = Object.values(RULE_FAMILY_MEMBERS)
30
+ .flat()
31
+ .filter((ruleId) => !(ruleId in RULE_TIER));
32
+
33
+ expect(unclassified).toEqual([]);
34
+ });
35
+
36
+ // A family id is a config vocabulary word, not a rule: nothing runs under it. If one
37
+ // ever collided with a real rule id, `ruleFamilyMembers` would silently expand that
38
+ // rule into a different set.
39
+ it("keeps family ids disjoint from rule ids", () => {
40
+ const registered = new Set(RULES.map((rule) => rule.id));
41
+ expect([...RULE_FAMILY_IDS].filter((id) => registered.has(id))).toEqual([]);
42
+ });
43
+
44
+ // The member whose absence caused the original divergence. Asserting the definition is
45
+ // not enough — this pins the BEHAVIOR the consolidation changed: a family entry now
46
+ // compiles an effective rule config for typography like it always did for color.
47
+ it("compiles a rule config for every member of an authored family entry", () => {
48
+ const compiled = compileGlobalGovernanceFacts({
49
+ rules: { "tokens/hardcoded-values": { enabled: true, severity: "warn" } },
50
+ })
51
+ .filter((fact) => fact.kind === "governance_rule_config")
52
+ .map((fact) => fact.ruleId)
53
+ .sort();
54
+
55
+ // A superset check: the family id keeps its own passthrough entry alongside the
56
+ // expansion. What matters is that no member is missing.
57
+ for (const member of RULE_FAMILY_MEMBERS["tokens/hardcoded-values"]!) {
58
+ expect(compiled).toContain(member);
59
+ }
60
+ expect(compiled).toContain("styles/no-raw-typography");
61
+ });
62
+
63
+ it("expands a family and passes a concrete rule id through unchanged", () => {
64
+ expect(ruleFamilyMembers("tokens/hardcoded-values")).toContain("styles/no-raw-typography");
65
+ expect(ruleFamilyMembers("styles/no-raw-color")).toEqual(["styles/no-raw-color"]);
66
+ expect(isRuleFamilyId("tokens/hardcoded-values")).toBe(true);
67
+ expect(isRuleFamilyId("styles/no-raw-color")).toBe(false);
68
+ });
69
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Rule families — the single source of truth for the broad ids a config may name
3
+ * instead of a concrete rule.
4
+ *
5
+ * `govern.rules` accepts two vocabularies: a concrete rule id (`styles/no-raw-color`)
6
+ * and a **family** id (`tokens/hardcoded-values`) that stands for several rules at once.
7
+ * Presets and Cloud-served policies author the family form, so this expansion decides
8
+ * what a preset or a served policy actually turns on.
9
+ *
10
+ * It used to be written out three times — the engine's finding-override pass, the fact
11
+ * compiler, and the governance-integrity roster — and the three disagreed:
12
+ * `styles/no-raw-typography` was a member of `tokens/hardcoded-values` in the engine and
13
+ * absent from the compiler, so one config produced two different answers about the same
14
+ * rule and nothing asserted they matched. One definition, one expansion, one coverage
15
+ * test (`families.test.ts` checks every member is a registered rule id).
16
+ *
17
+ * Family ids are NOT rule ids: nothing runs under `tokens/hardcoded-values`, it only
18
+ * expands. Adding a rule to a family is a behavior change for every config that names
19
+ * the family — classify new rules here in the same change that registers them.
20
+ */
21
+
22
+ export const RULE_FAMILY_MEMBERS: Readonly<Record<string, readonly string[]>> = {
23
+ "tokens/hardcoded-values": [
24
+ "styles/no-raw-color",
25
+ "styles/no-raw-spacing",
26
+ "styles/no-raw-typography",
27
+ "tokens/require-dual-fallback",
28
+ "theme/no-theme-coupled-literal",
29
+ ],
30
+ "components/usage": [
31
+ "components/forbidden-prop-value",
32
+ "components/preferred-component",
33
+ "components/unknown-prop",
34
+ "props/invalid-value",
35
+ ],
36
+ "a11y/wcag": ["a11y/required-accessible-name"],
37
+ };
38
+
39
+ /** The family ids themselves — recognized in `govern.rules`, never executed. */
40
+ export const RULE_FAMILY_IDS: ReadonlySet<string> = new Set(Object.keys(RULE_FAMILY_MEMBERS));
41
+
42
+ export function isRuleFamilyId(ruleId: string): boolean {
43
+ return RULE_FAMILY_IDS.has(ruleId);
44
+ }
45
+
46
+ /**
47
+ * The concrete rule ids a `govern.rules` key applies to: a family expands, anything
48
+ * else is itself. Every consumer of the family vocabulary goes through this.
49
+ */
50
+ export function ruleFamilyMembers(ruleId: string): readonly string[] {
51
+ return RULE_FAMILY_MEMBERS[ruleId] ?? [ruleId];
52
+ }
@@ -232,6 +232,12 @@ export {
232
232
  compareByVocabularyRank,
233
233
  } from "./tiers.js";
234
234
  export type { RuleTier } from "./tiers.js";
235
+ export {
236
+ RULE_FAMILY_IDS,
237
+ RULE_FAMILY_MEMBERS,
238
+ isRuleFamilyId,
239
+ ruleFamilyMembers,
240
+ } from "./families.js";
235
241
  export { customerDefaultRuleStates, fragmentsPresetRuleStates } from "./presets.js";
236
242
  export type { PresetRuleState } from "./presets.js";
237
243
  export {
@@ -1,4 +1,4 @@
1
- import type { FactIndex } from "../facts/index.js";
1
+ import type { FactIndex, JsxImportPathPreferredFact, UsageImportFact } from "../facts/index.js";
2
2
  import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
3
3
 
4
4
  import { makeFinding } from "./finding.js";
@@ -15,22 +15,21 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
15
15
  const seen = new Set<string>();
16
16
 
17
17
  for (const usage of ix.byKind("usage_import")) {
18
+ // A confirmed bridge supersedes any non-bridge preference over the same
19
+ // underlying import (report #2 B6): without this, both policies emit with
20
+ // the same fingerprint (corrupting baseline/Cloud dedupe) and the hand
21
+ // rule flags the wrapper's own implementation file with a self-import fix.
22
+ // The bridge's domain — not its implementation-file exemption — decides
23
+ // supersession, so inside an exempt file the duplicate stays silent too.
24
+ const bridgeGoverned = policies.some((policy) => bridgeGovernsUnderlyingImport(policy, usage));
18
25
  const matches = policies.filter((policy) => {
19
26
  if (policy.bridge) {
20
27
  if (policy.bridge.implementationFiles.some((file) => sameRepoPath(file, usage.file))) {
21
28
  return false;
22
29
  }
23
- if (!ownedImportMatchesRoot(usage.source, policy.from)) return false;
24
- if (ownedImportsEqual(usage.source, policy.from)) {
25
- return usage.imported === policy.bridge.underlyingExportName;
26
- }
27
- const subpath = usage.source.slice(policy.from.length + 1);
28
- const leaf = subpath.split("/").at(-1);
29
- return (
30
- leaf === policy.bridge.underlyingExportName &&
31
- (usage.imported === "default" || usage.imported === policy.bridge.underlyingExportName)
32
- );
30
+ return bridgeGovernsUnderlyingImport(policy, usage);
33
31
  }
32
+ if (bridgeGoverned) return false;
34
33
  if (!ownedImportsEqual(policy.from, usage.source)) return false;
35
34
  if (policy.imported !== undefined && policy.imported !== usage.imported) return false;
36
35
  return true;
@@ -94,6 +93,25 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
94
93
  return findings;
95
94
  }
96
95
 
96
+ /** Whether the bridge policy's underlying-import domain covers this usage. */
97
+ function bridgeGovernsUnderlyingImport(
98
+ policy: JsxImportPathPreferredFact,
99
+ usage: UsageImportFact
100
+ ): boolean {
101
+ const bridge = policy.bridge;
102
+ if (!bridge) return false;
103
+ if (!ownedImportMatchesRoot(usage.source, policy.from)) return false;
104
+ if (ownedImportsEqual(usage.source, policy.from)) {
105
+ return usage.imported === bridge.underlyingExportName;
106
+ }
107
+ const subpath = usage.source.slice(policy.from.length + 1);
108
+ const leaf = subpath.split("/").at(-1);
109
+ return (
110
+ leaf === bridge.underlyingExportName &&
111
+ (usage.imported === "default" || usage.imported === bridge.underlyingExportName)
112
+ );
113
+ }
114
+
97
115
  function sameRepoPath(left: string, right: string): boolean {
98
116
  return normalizeRepoPath(left) === normalizeRepoPath(right);
99
117
  }