@usefragments/core 1.5.2 → 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 (41) hide show
  1. package/dist/{chunk-BAHCOAVG.js → chunk-WVFNDPM4.js} +423 -189
  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-pKrfh517.d.ts → governance-DxFipN5V.d.ts} +609 -20
  8. package/dist/index.d.ts +638 -43
  9. package/dist/index.js +304 -37
  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 +40 -0
  20. package/src/facts/builders.ts +35 -0
  21. package/src/facts/compile.ts +135 -21
  22. package/src/facts/fact-index.ts +15 -2
  23. package/src/facts/facts.test.ts +6 -2
  24. package/src/facts/index.ts +9 -6
  25. package/src/facts/types.ts +45 -9
  26. package/src/governance-integrity.test.ts +174 -3
  27. package/src/governance-integrity.ts +305 -5
  28. package/src/governance.ts +87 -1
  29. package/src/index.ts +38 -1
  30. package/src/policy-exclude.ts +113 -0
  31. package/src/rules/families.test.ts +69 -0
  32. package/src/rules/families.ts +52 -0
  33. package/src/rules/index.ts +6 -0
  34. package/src/rules/jsx-preferred-import-path.ts +29 -11
  35. package/src/rules/rules.test.ts +125 -1
  36. package/src/rules/styles-no-raw-color.ts +13 -4
  37. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  38. package/src/rules/styles-no-raw-spacing.ts +12 -4
  39. package/src/rules/styles-no-raw-typography.ts +13 -4
  40. package/src/rules/utils.ts +39 -0
  41. package/dist/chunk-BAHCOAVG.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(),
@@ -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
  };
package/src/index.ts CHANGED
@@ -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,8 +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,
716
743
  detectOrphanGovernanceScales,
717
744
  detectUnconsumedConfigKeys,
745
+ detectUnmatchedPolicyExcludes,
718
746
  evaluateGovernanceIntegrity,
719
747
  hasEffectiveComponentVocabulary,
720
748
  isEffectiveCanonicalSource,
@@ -737,7 +765,12 @@ export {
737
765
  } from "./canonical-direction.js";
738
766
  export type { CanonicalDirectionConflict } from "./canonical-direction.js";
739
767
 
740
- 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";
741
774
 
742
775
  export {
743
776
  compileEffectiveGovernanceInputs,
@@ -800,7 +833,9 @@ export {
800
833
  makeTailwindTokenResolvedFact,
801
834
  compileGlobalGovernanceFacts,
802
835
  compileComponentFacts,
836
+ projectSupersededImportPathPreferences,
803
837
  } from "./facts/index.js";
838
+ export type { SupersededImportPathPreference } from "./facts/index.js";
804
839
  export {
805
840
  canonicalPreimage,
806
841
  AGENT_CONTEXT_RELATIVE_PATH,
@@ -890,6 +925,7 @@ export type {
890
925
  ComponentId,
891
926
  FactId,
892
927
  Fact,
928
+ FactConflict,
893
929
  FactKind,
894
930
  FactOfKind,
895
931
  FactLocation,
@@ -932,6 +968,7 @@ export type {
932
968
  UsageInlineStyleFact,
933
969
  UsageTextChildFact,
934
970
  StyleDeclarationFact,
971
+ StyleValueProvenance,
935
972
  StyleUnsupportedFact,
936
973
  UnsupportedStyleReason,
937
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
  }