@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.
- package/dist/chunk-RANPUC6C.js +72 -0
- package/dist/chunk-RANPUC6C.js.map +1 -0
- package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
- package/dist/chunk-WNMWKUYG.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/compiled-types/index.js +8 -0
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
- package/dist/index.d.ts +800 -179
- package/dist/index.js +1003 -237
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +2 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- package/src/agent-format.test.ts +13 -0
- package/src/agent-format.ts +9 -3
- 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 -2
- package/src/codes/codes.ts +41 -2
- package/src/compiled-types/index.ts +81 -0
- package/src/compiled-types/parse.test.ts +47 -0
- package/src/facts/builders.ts +48 -0
- package/src/facts/compile.ts +148 -34
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +44 -3
- package/src/facts/index.ts +11 -6
- package/src/facts/types.ts +60 -9
- package/src/governance-integrity.test.ts +272 -4
- package/src/governance-integrity.ts +345 -25
- package/src/governance.ts +87 -1
- package/src/index.ts +46 -1
- package/src/policy-exclude.ts +113 -0
- package/src/rules/a11y-required-accessible-name.ts +175 -28
- package/src/rules/a11y-standard.ts +102 -0
- package/src/rules/a11y-utils.ts +7 -0
- package/src/rules/components-prefer-library.test.ts +75 -28
- package/src/rules/components-prefer-library.ts +35 -15
- package/src/rules/components-shadow-component.test.ts +21 -9
- package/src/rules/emit-gate.test.ts +74 -4
- package/src/rules/emit-gate.ts +24 -9
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +18 -2
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +188 -8
- 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/tiers.ts +1 -0
- package/src/rules/utils.ts +39 -0
- package/src/tokens/design-token-parser.test.ts +131 -0
- package/src/tokens/design-token-parser.ts +362 -49
- package/src/types.ts +2 -2
- package/dist/chunk-BAHCOAVG.js.map +0 -1
- 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
|
-
*
|
|
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 =
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
...(
|
|
797
|
+
...(preferLibraryConfig?.enabled === true ? ["components/prefer-library"] : []),
|
|
798
|
+
...(shadowComponentConfig?.enabled === true ? ["components/shadow-component"] : []),
|
|
483
799
|
],
|
|
484
800
|
};
|
|
485
801
|
if (!componentsArmed) {
|
|
486
|
-
if (
|
|
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 = "
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
};
|