@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.
- package/dist/{chunk-BAHCOAVG.js → chunk-WVFNDPM4.js} +423 -189
- package/dist/chunk-WVFNDPM4.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-pKrfh517.d.ts → governance-DxFipN5V.d.ts} +609 -20
- package/dist/index.d.ts +638 -43
- package/dist/index.js +304 -37
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- package/src/canonical-bridge.ts +69 -1
- package/src/canonical-direction.test.ts +118 -0
- package/src/canonical-direction.ts +43 -2
- package/src/codes/__tests__/codes.test.ts +14 -1
- package/src/codes/codes.ts +40 -0
- package/src/facts/builders.ts +35 -0
- package/src/facts/compile.ts +135 -21
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +6 -2
- package/src/facts/index.ts +9 -6
- package/src/facts/types.ts +45 -9
- package/src/governance-integrity.test.ts +174 -3
- package/src/governance-integrity.ts +305 -5
- package/src/governance.ts +87 -1
- package/src/index.ts +38 -1
- package/src/policy-exclude.ts +113 -0
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/index.ts +6 -0
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +125 -1
- package/src/rules/styles-no-raw-color.ts +13 -4
- package/src/rules/styles-no-raw-dimensions.ts +13 -4
- package/src/rules/styles-no-raw-spacing.ts +12 -4
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/utils.ts +39 -0
- 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
|
-
/**
|
|
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 {
|
|
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
|
+
}
|
package/src/rules/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|