@usefragments/core 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/{chunk-AOG4FTV6.js → chunk-WVFNDPM4.js} +448 -190
  2. package/dist/chunk-WVFNDPM4.js.map +1 -0
  3. package/dist/codes/index.d.ts +1 -1
  4. package/dist/codes/index.js +1 -1
  5. package/dist/compiled-types/index.d.ts +1 -1
  6. package/dist/generate/index.d.ts +1 -1
  7. package/dist/{governance-B88uR3Zq.d.ts → governance-DxFipN5V.d.ts} +654 -22
  8. package/dist/index.d.ts +678 -40
  9. package/dist/index.js +534 -38
  10. package/dist/index.js.map +1 -1
  11. package/dist/react-types.d.ts +1 -1
  12. package/dist/test-utils.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/__tests__/policy-exclude.test.ts +180 -0
  15. package/src/canonical-bridge.ts +69 -1
  16. package/src/canonical-direction.test.ts +118 -0
  17. package/src/canonical-direction.ts +43 -2
  18. package/src/codes/__tests__/codes.test.ts +14 -1
  19. package/src/codes/codes.ts +60 -0
  20. package/src/config.ts +20 -0
  21. package/src/facts/builders.ts +35 -0
  22. package/src/facts/compile.ts +135 -21
  23. package/src/facts/fact-index.ts +22 -2
  24. package/src/facts/facts.test.ts +19 -19
  25. package/src/facts/index.ts +9 -6
  26. package/src/facts/types.ts +45 -9
  27. package/src/governance-integrity.test.ts +277 -1
  28. package/src/governance-integrity.ts +616 -0
  29. package/src/governance.test.ts +20 -1
  30. package/src/governance.ts +131 -0
  31. package/src/index.ts +45 -2
  32. package/src/policy-exclude.ts +113 -0
  33. package/src/rules/families.test.ts +69 -0
  34. package/src/rules/families.ts +52 -0
  35. package/src/rules/index.ts +6 -0
  36. package/src/rules/jsx-preferred-import-path.ts +29 -11
  37. package/src/rules/rules.test.ts +125 -1
  38. package/src/rules/styles-no-raw-color.ts +13 -4
  39. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  40. package/src/rules/styles-no-raw-spacing.test.ts +48 -0
  41. package/src/rules/styles-no-raw-spacing.ts +15 -7
  42. package/src/rules/styles-no-raw-typography.ts +13 -4
  43. package/src/rules/utils.ts +39 -0
  44. package/src/types.ts +21 -3
  45. package/dist/chunk-AOG4FTV6.js.map +0 -1
@@ -18,12 +18,41 @@
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
 
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";
41
+
42
+ export interface InertConfigDiagnostic {
43
+ code: InertConfigDiagnosticCode;
44
+ kind: InertConfigDiagnosticKind;
45
+ severity: "warn";
46
+ path: string;
47
+ message: string;
48
+ }
49
+
50
+ export interface GovernanceIntegrityRoster {
51
+ configured: number;
52
+ active: number;
53
+ inert: number;
54
+ }
55
+
27
56
  export type GovernanceIntegrityFamilyId =
28
57
  | "policy"
29
58
  | "components"
@@ -53,6 +82,8 @@ export interface GovernanceIntegrityInput {
53
82
  /** Whether `tokens/css-vars-must-be-defined` is activated (caller supplies). */
54
83
  cssVarsActive?: boolean;
55
84
  mode?: "scan" | "ci" | "hook" | "doctor" | "setup";
85
+ /** Named, verdict-neutral diagnostics derived from the authored config. */
86
+ configDiagnostics?: readonly InertConfigDiagnostic[];
56
87
  }
57
88
 
58
89
  export interface GovernanceIntegrityVerdict {
@@ -64,6 +95,8 @@ export interface GovernanceIntegrityVerdict {
64
95
  armed: GovernanceIntegrityFamilyId[];
65
96
  summary: string;
66
97
  remediations: string[];
98
+ configDiagnostics?: InertConfigDiagnostic[];
99
+ roster?: GovernanceIntegrityRoster;
67
100
  }
68
101
 
69
102
  interface EffectiveRuleConfig {
@@ -131,6 +164,575 @@ function dedupe(values: string[]): string[] {
131
164
  return [...new Set(values)];
132
165
  }
133
166
 
167
+ const CONSUMED_RULE_IDS = new Set(Object.keys(RULE_TIER));
168
+ const RECOGNIZED_RULE_IDS = new Set([...CONSUMED_RULE_IDS, ...RULE_FAMILY_IDS]);
169
+ // `exclude` is consumed by the scan's finding-override pass (it scopes the rule off
170
+ // the matching paths and reports each drop in the ignored accounting). It was absent
171
+ // from this allow-set, so a working key was diagnosed as inert — the diagnostic was
172
+ // wrong, not the config.
173
+ const RULE_CONFIG_KEYS = new Set(["enabled", "severity", "options", "exclude"]);
174
+
175
+ const PASSTHROUGH_KEYS = [
176
+ {
177
+ path: ["tokens"],
178
+ keys: [
179
+ "include",
180
+ "sources",
181
+ "packages",
182
+ "aliases",
183
+ "upstream",
184
+ "exclude",
185
+ "themeSelectors",
186
+ "enabled",
187
+ "format",
188
+ "namespace",
189
+ ],
190
+ },
191
+ {
192
+ path: ["screenshots"],
193
+ keys: ["viewport", "threshold", "delay", "outputDir", "themes"],
194
+ },
195
+ {
196
+ path: ["service"],
197
+ keys: ["poolSize", "idleTimeout"],
198
+ },
199
+ {
200
+ path: ["registry"],
201
+ keys: ["requireStory", "publicOnly", "categoryDepth", "includeProps", "embedFragments"],
202
+ },
203
+ {
204
+ path: ["govern", "tailwind"],
205
+ keys: ["palette"],
206
+ },
207
+ {
208
+ path: ["govern", "agent"],
209
+ keys: ["repairOrder"],
210
+ },
211
+ {
212
+ path: ["govern", "ci"],
213
+ keys: ["failOnWarnings", "failOnInert"],
214
+ },
215
+ ] as const;
216
+
217
+ function objectRecord(value: unknown): Record<string, unknown> | undefined {
218
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
219
+ return value as Record<string, unknown>;
220
+ }
221
+
222
+ function valueAtPath(value: unknown, path: readonly string[]): unknown {
223
+ let current = value;
224
+ for (const segment of path) {
225
+ const record = objectRecord(current);
226
+ if (!record) return undefined;
227
+ current = record[segment];
228
+ }
229
+ return current;
230
+ }
231
+
232
+ function configPath(path: readonly (string | number)[]): string {
233
+ return path.reduce<string>(
234
+ (output, segment) =>
235
+ typeof segment === "number"
236
+ ? `${output}[${segment}]`
237
+ : output
238
+ ? `${output}.${segment}`
239
+ : segment,
240
+ ""
241
+ );
242
+ }
243
+
244
+ function unconsumedKeyDiagnostic(path: readonly (string | number)[]): InertConfigDiagnostic {
245
+ const renderedPath = configPath(path);
246
+ return {
247
+ code: "FUI9004",
248
+ kind: "unconsumed-key",
249
+ severity: "warn",
250
+ path: renderedPath,
251
+ message: `${renderedPath} is not consumed by Fragments and has no effect. Remove it or use a supported config key.`,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Config-record fact kinds: the policy facts that a `govern.*` record compiles into,
257
+ * paired with the config section that authored them, the authored record that produced
258
+ * them, and the identity keys their fact id is built from.
259
+ *
260
+ * `record` is load-bearing, not decoration. Several kinds in one section key on nothing
261
+ * (`style.rawColors.forbid`, `style.cssVars.mustBeDefined`, …) or on the same fields
262
+ * (`jsx.importPath.prefer` and `jsx.component.prefer` both on from/to), so a
263
+ * section-only diagnostic path makes two DIFFERENT collisions look like one — and the
264
+ * (code, path) dedupe every consumer runs then discards all but the last, which is the
265
+ * silence this diagnostic exists to end.
266
+ *
267
+ * Deliberately NOT the whole `PolicyFact` union — `token_definition` and
268
+ * `contract_token` are compiled from token *files*, not authored config records, and
269
+ * their collisions belong to the token-catalog diagnostics channel. Naming a duplicate
270
+ * token as a config defect would misattribute it and drown the real signal.
271
+ */
272
+ const CONFIG_RECORD_FACT_KINDS: Readonly<
273
+ Record<string, { section: string; record: string; keys: string[] }>
274
+ > = {
275
+ scale: { section: "govern.scales", record: "scale", keys: ["name"] },
276
+ scale_value: { section: "govern.scales", record: "scale.values", keys: ["scale", "value"] },
277
+ style_raw_color_forbidden: {
278
+ section: "govern.styles",
279
+ record: "style.rawColors.forbid",
280
+ keys: [],
281
+ },
282
+ style_raw_dimension_forbidden: {
283
+ section: "govern.styles",
284
+ record: "style.rawDimensions.forbid",
285
+ keys: [],
286
+ },
287
+ style_property_scale: {
288
+ section: "govern.styles",
289
+ record: "style.rawSpacing.mustMatchScale",
290
+ keys: ["property"],
291
+ },
292
+ style_font_size_scale: {
293
+ section: "govern.styles",
294
+ record: "style.fontSize.mustMatchScale",
295
+ keys: [],
296
+ },
297
+ style_css_vars_must_be_defined: {
298
+ section: "govern.styles",
299
+ record: "style.cssVars.mustBeDefined",
300
+ keys: [],
301
+ },
302
+ jsx_unknown_props_forbidden: {
303
+ section: "govern.jsx",
304
+ record: "jsx.unknownProps.forbid",
305
+ keys: [],
306
+ },
307
+ jsx_inline_style_forbidden_raw: {
308
+ section: "govern.jsx",
309
+ record: "jsx.inlineStyle.forbidRaw",
310
+ keys: ["property"],
311
+ },
312
+ jsx_import_path_preferred: {
313
+ section: "govern.jsx",
314
+ record: "jsx.importPath.prefer",
315
+ keys: ["from", "to", "imported"],
316
+ },
317
+ jsx_component_preferred: {
318
+ section: "govern.jsx",
319
+ record: "jsx.component.prefer",
320
+ keys: ["from", "to"],
321
+ },
322
+ prop_value_avoided: {
323
+ section: "govern.components",
324
+ record: "prop.value.avoid",
325
+ keys: ["componentId", "prop", "value"],
326
+ },
327
+ prop_value_forbidden: {
328
+ section: "govern.components",
329
+ record: "prop.value.forbid",
330
+ keys: ["componentId", "prop", "value", "pathPattern"],
331
+ },
332
+ a11y_name_required: {
333
+ section: "govern.components",
334
+ record: "a11y.requireName",
335
+ keys: ["componentId"],
336
+ },
337
+ tailwind_palette_allow: {
338
+ section: "govern.tailwind",
339
+ record: "palette.allow",
340
+ keys: [],
341
+ },
342
+ tailwind_palette_deny: { section: "govern.tailwind", record: "palette.deny", keys: [] },
343
+ tailwind_unknown_class_enabled: {
344
+ section: "govern.tailwind",
345
+ record: "unknownClass",
346
+ keys: [],
347
+ },
348
+ governance_rule_config: { section: "govern.rules", record: "rule", keys: ["ruleId"] },
349
+ };
350
+
351
+ /** The authored settings a reader needs to see to tell two colliding records apart. */
352
+ const COLLISION_DETAIL_KEYS = [
353
+ "severity",
354
+ "scale",
355
+ "prefer",
356
+ "enabled",
357
+ "appliesTo",
358
+ "except",
359
+ "properties",
360
+ "because",
361
+ ] as const;
362
+
363
+ function renderFactFields(fact: Record<string, unknown>, keys: readonly string[]): string {
364
+ return keys
365
+ .filter((key) => fact[key] !== undefined)
366
+ .map((key) => `${key}=${Array.isArray(fact[key]) ? JSON.stringify(fact[key]) : fact[key]}`)
367
+ .join(" ");
368
+ }
369
+
370
+ /**
371
+ * Name a config record that the fact index dropped as a first-wins duplicate.
372
+ *
373
+ * This is the Layer-6 backstop, not the primary mechanism: merge-time displacement
374
+ * (`mergeGovernanceConfigs`) already makes the later record win for every shape it can
375
+ * key. What reaches here is a collision displacement could NOT resolve — two records
376
+ * that compile to one fact id from different channels (config vs bridge vs Cloud
377
+ * policy). The invariant it defends: a dropped policy fact is never silent.
378
+ *
379
+ * Returns `null` for non-config facts, so usage/token collisions stay on the internal
380
+ * debug channel.
381
+ */
382
+ export function collidingRecordDiagnostic(conflict: FactConflict): InertConfigDiagnostic | null {
383
+ return collidingRecordDiagnostics([conflict])[0] ?? null;
384
+ }
385
+
386
+ /**
387
+ * Aggregate every observed collision into one diagnostic per colliding fact id.
388
+ *
389
+ * Three same-kind records on one fact id produce TWO conflicts (first-wins keeps the
390
+ * first and skips both others), and both render the same path. Emitting them separately
391
+ * meant the `(code, path)` dedupe every consumer runs kept only the last pair, so the
392
+ * intermediate record was dropped in silence — the same failure the per-kind path fixed
393
+ * one layer down. Aggregating names the count and lists every dropped setting, and one
394
+ * user edit still resolves the whole group.
395
+ */
396
+ export function collidingRecordDiagnostics(
397
+ conflicts: readonly FactConflict[]
398
+ ): InertConfigDiagnostic[] {
399
+ const groups = new Map<
400
+ string,
401
+ { record: string; kept: string; dropped: string[]; keptFallback: string }
402
+ >();
403
+
404
+ for (const conflict of conflicts) {
405
+ const shape = CONFIG_RECORD_FACT_KINDS[conflict.kept.kind];
406
+ if (!shape || conflict.kept.kind !== conflict.skipped.kind) continue;
407
+
408
+ const keptFact = conflict.kept as unknown as Record<string, unknown>;
409
+ const skippedFact = conflict.skipped as unknown as Record<string, unknown>;
410
+ const identity = renderFactFields(keptFact, shape.keys);
411
+ // The record kind is part of the identity, not just the prose: two singleton-keyed
412
+ // kinds in one section would otherwise share a path and collapse under the (code,
413
+ // path) dedupe, dropping one collision silently.
414
+ const path = `${shape.section}[${identity ? `${shape.record} ${identity}` : shape.record}]`;
415
+ const group = groups.get(path) ?? {
416
+ record: shape.record,
417
+ kept: renderFactFields(keptFact, COLLISION_DETAIL_KEYS),
418
+ dropped: [],
419
+ keptFallback: "the first record",
420
+ };
421
+ const dropped = renderFactFields(skippedFact, COLLISION_DETAIL_KEYS);
422
+ group.dropped.push(dropped || `record ${group.dropped.length + 2}`);
423
+ groups.set(path, group);
424
+ }
425
+
426
+ return [...groups].map(([path, group]) => ({
427
+ code: "FUI9007" as const,
428
+ kind: "colliding-record" as const,
429
+ severity: "warn" as const,
430
+ path,
431
+ message:
432
+ `${path} has ${group.dropped.length + 1} \`${group.record}\` records that compile to the same policy fact — ` +
433
+ `enforcing ${group.kept || group.keptFallback} and dropping ${group.dropped.join("; ")}. ` +
434
+ "Author one record for this policy, or scope them apart, so the enforced setting is the one you can read.",
435
+ }));
436
+ }
437
+
438
+ /**
439
+ * The authored config record a policy fact was compiled from, or `null` when the fact
440
+ * did not come from a `govern.*` record (token facts, usage facts). One table, read by
441
+ * both the collision diagnostic and the override backstop below.
442
+ */
443
+ export function configRecordShape(kind: string): { section: string; record: string } | null {
444
+ const shape = CONFIG_RECORD_FACT_KINDS[kind];
445
+ return shape ? { section: shape.section, record: shape.record } : null;
446
+ }
447
+
448
+ /**
449
+ * Name a `govern.rules` override that outranks the severity a policy record authored.
450
+ *
451
+ * The backstop for the path provenance cannot reach. Locally, presets are merged here,
452
+ * so a preset's broad rule entry is tagged and treated as a default — a user record's
453
+ * `severity: "error"` wins (report #2 B4). A Cloud-served policy arrives whole: no merge
454
+ * ran, nothing is tagged, and a broad `tokens/hardcoded-values` entry in it still
455
+ * displaces the severity a record next to it authored. That cannot be fixed at this
456
+ * boundary, so it is named instead. Silence is the defect; an unfixable case must at
457
+ * least speak.
458
+ */
459
+ export function overriddenRecordSeverityDiagnostic(input: {
460
+ ruleId: string;
461
+ /** Config path of the override that won — `govern.rules[...]` or `govern.severity`. */
462
+ source: string;
463
+ section: string;
464
+ record: string;
465
+ authored: string;
466
+ enforced: string;
467
+ }): InertConfigDiagnostic {
468
+ const path = `${input.section}[${input.record}]`;
469
+ return {
470
+ code: "FUI9008",
471
+ kind: "overridden-record-severity",
472
+ severity: "warn",
473
+ path,
474
+ message:
475
+ `${path} authored severity=${input.authored}, but ${input.source} enforces ` +
476
+ `severity=${input.enforced} on \`${input.ruleId}\`. The record's severity is not what ` +
477
+ "gates CI — drop the rule override, or author the record at the severity you want enforced.",
478
+ };
479
+ }
480
+
481
+ function collectStrippedKeys(
482
+ authored: unknown,
483
+ parsed: unknown,
484
+ path: readonly (string | number)[],
485
+ diagnostics: InertConfigDiagnostic[]
486
+ ): void {
487
+ if (Array.isArray(authored)) {
488
+ if (!Array.isArray(parsed)) return;
489
+ authored.forEach((item, index) => {
490
+ collectStrippedKeys(item, parsed[index], [...path, index], diagnostics);
491
+ });
492
+ return;
493
+ }
494
+
495
+ const authoredRecord = objectRecord(authored);
496
+ const parsedRecord = objectRecord(parsed);
497
+ if (!authoredRecord || !parsedRecord) return;
498
+
499
+ for (const key of Object.keys(authoredRecord).sort()) {
500
+ if (!Object.prototype.hasOwnProperty.call(parsedRecord, key)) {
501
+ diagnostics.push(unconsumedKeyDiagnostic([...path, key]));
502
+ continue;
503
+ }
504
+ collectStrippedKeys(authoredRecord[key], parsedRecord[key], [...path, key], diagnostics);
505
+ }
506
+ }
507
+
508
+ function collectPassthroughKeys(
509
+ authored: unknown,
510
+ path: readonly string[],
511
+ allowed: ReadonlySet<string>,
512
+ diagnostics: InertConfigDiagnostic[]
513
+ ): void {
514
+ const record = objectRecord(valueAtPath(authored, path));
515
+ if (!record) return;
516
+ for (const key of Object.keys(record).sort()) {
517
+ if (!allowed.has(key)) diagnostics.push(unconsumedKeyDiagnostic([...path, key]));
518
+ }
519
+ }
520
+
521
+ function collectRuleConfigKeys(
522
+ authored: unknown,
523
+ path: readonly string[],
524
+ diagnostics: InertConfigDiagnostic[]
525
+ ): void {
526
+ const rules = objectRecord(valueAtPath(authored, path));
527
+ if (!rules) return;
528
+ for (const ruleId of Object.keys(rules).sort()) {
529
+ if (!RECOGNIZED_RULE_IDS.has(ruleId)) {
530
+ diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId]));
531
+ continue;
532
+ }
533
+ const config = objectRecord(rules[ruleId]);
534
+ if (!config) continue;
535
+ for (const key of Object.keys(config).sort()) {
536
+ if (!RULE_CONFIG_KEYS.has(key)) {
537
+ diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId, key]));
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ function collectPassthroughRecordValues(
544
+ authored: unknown,
545
+ path: readonly string[],
546
+ allowed: ReadonlySet<string>,
547
+ diagnostics: InertConfigDiagnostic[]
548
+ ): void {
549
+ const entries = objectRecord(valueAtPath(authored, path));
550
+ if (!entries) return;
551
+ for (const [entryName, value] of Object.entries(entries).sort(([left], [right]) =>
552
+ left.localeCompare(right)
553
+ )) {
554
+ const record = objectRecord(value);
555
+ if (!record) continue;
556
+ for (const key of Object.keys(record).sort()) {
557
+ if (!allowed.has(key)) {
558
+ diagnostics.push(unconsumedKeyDiagnostic([...path, entryName, key]));
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ function stableConfigDiagnostics(
565
+ diagnostics: readonly InertConfigDiagnostic[]
566
+ ): InertConfigDiagnostic[] {
567
+ const unique = new Map<string, InertConfigDiagnostic>();
568
+ for (const diagnostic of diagnostics) {
569
+ unique.set(`${diagnostic.code}\0${diagnostic.path}`, diagnostic);
570
+ }
571
+ return [...unique.values()].sort(
572
+ (left, right) =>
573
+ left.path.localeCompare(right.path) ||
574
+ left.code.localeCompare(right.code) ||
575
+ left.message.localeCompare(right.message)
576
+ );
577
+ }
578
+
579
+ /**
580
+ * Diagnose config keys that the permissive validation boundary accepts but the
581
+ * runtime cannot consume. The parsed config is the source of truth for stripped
582
+ * keys; explicit allow-sets cover intentional `.passthrough()`/record seams.
583
+ */
584
+ export function detectUnconsumedConfigKeys(
585
+ authoredConfig: unknown,
586
+ parsedConfig: unknown
587
+ ): InertConfigDiagnostic[] {
588
+ const diagnostics: InertConfigDiagnostic[] = [];
589
+ collectStrippedKeys(authoredConfig, parsedConfig, [], diagnostics);
590
+
591
+ for (const boundary of PASSTHROUGH_KEYS) {
592
+ collectPassthroughKeys(
593
+ authoredConfig,
594
+ boundary.path,
595
+ new Set<string>(boundary.keys),
596
+ diagnostics
597
+ );
598
+ }
599
+
600
+ collectRuleConfigKeys(authoredConfig, ["govern", "rules"], diagnostics);
601
+ collectPassthroughRecordValues(
602
+ authoredConfig,
603
+ ["govern", "agents"],
604
+ new Set(["rules"]),
605
+ diagnostics
606
+ );
607
+ collectPassthroughKeys(authoredConfig, ["govern", "audit"], new Set(), diagnostics);
608
+ collectPassthroughRecordValues(authoredConfig, ["govern", "runners"], new Set(), diagnostics);
609
+
610
+ return stableConfigDiagnostics(diagnostics);
611
+ }
612
+
613
+ /** Every authored exclude in a policy, paired with the config path that declared it. */
614
+ function authoredPolicyExcludes(
615
+ policy: GovernanceConfig | undefined
616
+ ): Array<{ path: string; scope: string; exclude: PolicyExclude }> {
617
+ const out: Array<{ path: string; scope: string; exclude: PolicyExclude }> = [];
618
+ const collect = (path: string, scope: string, raw: unknown) => {
619
+ for (const exclude of normalizePolicyExcludes(raw) ?? []) {
620
+ out.push({ path, scope, exclude });
621
+ }
622
+ };
623
+
624
+ (policy?.styles ?? []).forEach((record, index) => {
625
+ collect(`govern.styles[${index}].exclude`, record.kind, record.exclude);
626
+ });
627
+ (policy?.jsx ?? []).forEach((record, index) => {
628
+ collect(`govern.jsx[${index}].exclude`, record.kind, record.exclude);
629
+ });
630
+ for (const ruleId of Object.keys(policy?.rules ?? {}).sort()) {
631
+ const record = objectRecord(policy?.rules?.[ruleId]);
632
+ if (!record) continue;
633
+ collect(`govern.rules.${ruleId}.exclude`, ruleId, record.exclude);
634
+ }
635
+
636
+ return out;
637
+ }
638
+
639
+ /**
640
+ * Diagnose excludes that scope nothing: the key is consumed and the glob is valid, but
641
+ * it matched no scanned file, so it silently exempts nothing while reading as active
642
+ * policy. That is the same "declared ≠ armed" failure this module exists to name — a
643
+ * stale path after a refactor is the common cause.
644
+ *
645
+ * `scannedFiles` are repo-relative paths supplied by the caller; core never reads the
646
+ * filesystem. An empty scan (nothing to compare against) yields no diagnostics rather
647
+ * than flagging every exclude.
648
+ */
649
+ export function detectUnmatchedPolicyExcludes(
650
+ policy: GovernanceConfig | undefined,
651
+ scannedFiles: readonly string[]
652
+ ): InertConfigDiagnostic[] {
653
+ if (scannedFiles.length === 0) return [];
654
+ const diagnostics: InertConfigDiagnostic[] = [];
655
+
656
+ for (const { path, scope, exclude } of authoredPolicyExcludes(policy)) {
657
+ if (scannedFiles.some((file) => policyExcludeMatchesPath(exclude, file))) continue;
658
+ diagnostics.push({
659
+ code: "FUI9006",
660
+ kind: "unmatched-exclude",
661
+ severity: "warn",
662
+ path,
663
+ message:
664
+ `${path} pattern "${exclude.glob}" matched no scanned file, so ${scope} is not ` +
665
+ "actually scoped by it. Correct the glob to a path this scan covers, or remove it.",
666
+ });
667
+ }
668
+
669
+ return stableConfigDiagnostics(diagnostics);
670
+ }
671
+
672
+ interface ScaleBinding {
673
+ label: string;
674
+ mechanism: string;
675
+ scale: string;
676
+ }
677
+
678
+ function effectiveScaleBindings(policy: GovernanceConfig | undefined): ScaleBinding[] {
679
+ const bindings: ScaleBinding[] = [];
680
+ for (const style of policy?.styles ?? []) {
681
+ if (style.kind === "style.rawSpacing.mustMatchScale") {
682
+ bindings.push({
683
+ label: "Spacing properties",
684
+ mechanism: "style.rawSpacing.mustMatchScale",
685
+ scale: style.scale,
686
+ });
687
+ } else if (style.kind === "style.fontSize.mustMatchScale") {
688
+ bindings.push({
689
+ label: "Font-size properties",
690
+ mechanism: "style.fontSize.mustMatchScale",
691
+ scale: style.scale,
692
+ });
693
+ }
694
+ }
695
+ return bindings;
696
+ }
697
+
698
+ /**
699
+ * Compare locally declared scales with the effective property-policy graph.
700
+ * Preset-only scales are not diagnosed because the user did not declare them.
701
+ */
702
+ export function detectOrphanGovernanceScales(
703
+ declaredPolicy: GovernanceConfig | undefined,
704
+ effectivePolicy: GovernanceConfig | undefined
705
+ ): InertConfigDiagnostic[] {
706
+ const bindings = effectiveScaleBindings(effectivePolicy);
707
+ const referenced = new Set(bindings.map((binding) => binding.scale));
708
+ const diagnostics: InertConfigDiagnostic[] = [];
709
+
710
+ for (const scale of Object.keys(declaredPolicy?.scales ?? {}).sort()) {
711
+ if (referenced.has(scale)) continue;
712
+ const path = `govern.scales.${scale}`;
713
+ const preferred =
714
+ (scale.toLowerCase().includes("spac")
715
+ ? bindings.find((binding) => binding.mechanism === "style.rawSpacing.mustMatchScale")
716
+ : undefined) ??
717
+ (scale.toLowerCase().includes("font")
718
+ ? bindings.find((binding) => binding.mechanism === "style.fontSize.mustMatchScale")
719
+ : undefined) ??
720
+ bindings[0];
721
+ const remediation = preferred
722
+ ? `${preferred.label} are bound to the scale named "${preferred.scale}" — rename the key to "${preferred.scale}" or bind it via ${preferred.mechanism}.`
723
+ : `Bind it via style.rawSpacing.mustMatchScale or style.fontSize.mustMatchScale, or remove it.`;
724
+ diagnostics.push({
725
+ code: "FUI9005",
726
+ kind: "orphan-scale",
727
+ severity: "warn",
728
+ path,
729
+ message: `${path} is not referenced by any property policy. ${remediation}`,
730
+ });
731
+ }
732
+
733
+ return stableConfigDiagnostics(diagnostics);
734
+ }
735
+
134
736
  function summarize(
135
737
  status: GovernanceIntegrityStatus,
136
738
  flags: { componentsArmed: boolean; tokensArmed: boolean; blockingArmed: boolean }
@@ -152,6 +754,7 @@ export function evaluateGovernanceIntegrity(
152
754
  input: GovernanceIntegrityInput
153
755
  ): GovernanceIntegrityVerdict {
154
756
  const configs = effectiveRuleConfigs(input.policy);
757
+ const configDiagnostics = stableConfigDiagnostics(input.configDiagnostics ?? []);
155
758
 
156
759
  // --- policy family --------------------------------------------------------
157
760
  const policyArmed = input.policy !== undefined && input.policySource !== "none";
@@ -280,6 +883,17 @@ export function evaluateGovernanceIntegrity(
280
883
  .filter((remediation): remediation is string => remediation !== undefined)
281
884
  );
282
885
  const summary = summarize(status, { componentsArmed, tokensArmed, blockingArmed });
886
+ const configuredRuleCount = [...configs.keys()].filter((ruleId) =>
887
+ CONSUMED_RULE_IDS.has(ruleId)
888
+ ).length;
889
+ const activeRuleCount = [...configs.entries()].filter(
890
+ ([ruleId, config]) => CONSUMED_RULE_IDS.has(ruleId) && config.enabled
891
+ ).length;
892
+ const roster = {
893
+ configured: configuredRuleCount + configDiagnostics.length,
894
+ active: activeRuleCount,
895
+ inert: configDiagnostics.length,
896
+ };
283
897
 
284
898
  return {
285
899
  status,
@@ -290,5 +904,7 @@ export function evaluateGovernanceIntegrity(
290
904
  armed,
291
905
  summary,
292
906
  remediations,
907
+ configDiagnostics,
908
+ roster,
293
909
  };
294
910
  }
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { compileFragment, defineConfig, defineFragment, g } from "./index.js";
2
+ import {
3
+ compileFragment,
4
+ configDeclarationForDiagnostics,
5
+ defineConfig,
6
+ defineFragment,
7
+ g,
8
+ type FragmentsConfig,
9
+ } from "./index.js";
3
10
 
4
11
  type ButtonProps = {
5
12
  variant?: "primary" | "secondary" | "ghost" | "link";
@@ -12,6 +19,18 @@ function Button(_props: ButtonProps) {
12
19
  }
13
20
 
14
21
  describe("governance DSL", () => {
22
+ it("preserves the authored declaration for stripped-key diagnostics", () => {
23
+ const authored = {
24
+ include: ["src/**/*.fragment.ts"],
25
+ styles: { spacing: true },
26
+ };
27
+ const config = defineConfig(authored as FragmentsConfig);
28
+
29
+ expect(config).not.toHaveProperty("styles");
30
+ expect(configDeclarationForDiagnostics(config)).toBe(authored);
31
+ expect(Object.keys(config)).toEqual(["include"]);
32
+ });
33
+
15
34
  it("defineConfig accepts global governance records", () => {
16
35
  const config = defineConfig({
17
36
  include: ["src/**/*.fragment.ts"],