@usefragments/core 1.5.0 → 1.5.2

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 (65) hide show
  1. package/dist/{chunk-HXGE3O2F.js → chunk-BAHCOAVG.js} +133 -7
  2. package/dist/chunk-BAHCOAVG.js.map +1 -0
  3. package/dist/{chunk-57QDBEHQ.js → chunk-ZHS52OT4.js} +3 -9
  4. package/dist/chunk-ZHS52OT4.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-BLsyk56o.d.ts → governance-pKrfh517.d.ts} +264 -4
  10. package/dist/{index-h_yWj15D.d.ts → index-DbkPE46t.d.ts} +40 -0
  11. package/dist/index.d.ts +472 -28
  12. package/dist/index.js +561 -39
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/schemas/index.d.ts +1 -1
  16. package/dist/schemas/index.js +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +1 -1
  19. package/src/agent-format.test.ts +1 -0
  20. package/src/canonical-bridge.ts +46 -0
  21. package/src/canonical-direction.ts +118 -0
  22. package/src/codes/__tests__/codes.test.ts +1 -1
  23. package/src/codes/codes.ts +29 -0
  24. package/src/config.ts +20 -0
  25. package/src/contract/preimage.test.ts +40 -0
  26. package/src/contract/preimage.ts +20 -0
  27. package/src/effective-governance-inputs.test.ts +52 -0
  28. package/src/effective-governance-inputs.ts +106 -0
  29. package/src/facts/builders.ts +25 -1
  30. package/src/facts/compile.ts +20 -2
  31. package/src/facts/fact-index.ts +12 -1
  32. package/src/facts/facts.test.ts +14 -18
  33. package/src/facts/types.ts +25 -6
  34. package/src/governance-integrity.test.ts +127 -0
  35. package/src/governance-integrity.ts +326 -4
  36. package/src/governance.test.ts +87 -1
  37. package/src/governance.ts +121 -0
  38. package/src/index.ts +34 -5
  39. package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
  40. package/src/rules/components-prefer-library.test.ts +103 -0
  41. package/src/rules/components-prefer-library.ts +30 -4
  42. package/src/rules/fix-availability.ts +1 -0
  43. package/src/rules/index.ts +7 -0
  44. package/src/rules/jsx-preferred-import-path.ts +45 -9
  45. package/src/rules/rules.test.ts +202 -6
  46. package/src/rules/spacing-resolution.ts +2 -2
  47. package/src/rules/styles-no-raw-color.test.ts +6 -1
  48. package/src/rules/styles-no-raw-color.ts +3 -2
  49. package/src/rules/styles-no-raw-dimensions.ts +5 -7
  50. package/src/rules/styles-no-raw-spacing.test.ts +48 -0
  51. package/src/rules/styles-no-raw-spacing.ts +10 -7
  52. package/src/rules/styles-no-raw-typography.ts +7 -4
  53. package/src/rules/taxonomy.test.ts +3 -0
  54. package/src/rules/tiers.ts +2 -0
  55. package/src/rules/token-candidates.ts +36 -0
  56. package/src/rules/tokens-require-dual-fallback.ts +2 -1
  57. package/src/rules/tokens-upstream-drift.test.ts +111 -0
  58. package/src/rules/tokens-upstream-drift.ts +57 -0
  59. package/src/rules/utils.ts +22 -14
  60. package/src/schema.ts +13 -0
  61. package/src/schemas/index.ts +5 -8
  62. package/src/token-types.ts +71 -1
  63. package/src/types.ts +27 -3
  64. package/dist/chunk-57QDBEHQ.js.map +0 -1
  65. package/dist/chunk-HXGE3O2F.js.map +0 -1
@@ -24,6 +24,23 @@ import { BLOCKING_RULE_ALLOWLIST } from "./rules/emit-gate.js";
24
24
 
25
25
  export type GovernanceIntegrityStatus = "healthy" | "degraded" | "inert";
26
26
 
27
+ export type InertConfigDiagnosticKind = "orphan-scale" | "unconsumed-key";
28
+ export type InertConfigDiagnosticCode = "FUI9004" | "FUI9005";
29
+
30
+ export interface InertConfigDiagnostic {
31
+ code: InertConfigDiagnosticCode;
32
+ kind: InertConfigDiagnosticKind;
33
+ severity: "warn";
34
+ path: string;
35
+ message: string;
36
+ }
37
+
38
+ export interface GovernanceIntegrityRoster {
39
+ configured: number;
40
+ active: number;
41
+ inert: number;
42
+ }
43
+
27
44
  export type GovernanceIntegrityFamilyId =
28
45
  | "policy"
29
46
  | "components"
@@ -53,6 +70,8 @@ export interface GovernanceIntegrityInput {
53
70
  /** Whether `tokens/css-vars-must-be-defined` is activated (caller supplies). */
54
71
  cssVarsActive?: boolean;
55
72
  mode?: "scan" | "ci" | "hook" | "doctor" | "setup";
73
+ /** Named, verdict-neutral diagnostics derived from the authored config. */
74
+ configDiagnostics?: readonly InertConfigDiagnostic[];
56
75
  }
57
76
 
58
77
  export interface GovernanceIntegrityVerdict {
@@ -64,6 +83,8 @@ export interface GovernanceIntegrityVerdict {
64
83
  armed: GovernanceIntegrityFamilyId[];
65
84
  summary: string;
66
85
  remediations: string[];
86
+ configDiagnostics?: InertConfigDiagnostic[];
87
+ roster?: GovernanceIntegrityRoster;
67
88
  }
68
89
 
69
90
  interface EffectiveRuleConfig {
@@ -131,6 +152,287 @@ function dedupe(values: string[]): string[] {
131
152
  return [...new Set(values)];
132
153
  }
133
154
 
155
+ const RULE_FAMILY_IDS = new Set(["tokens/hardcoded-values", "components/usage", "a11y/wcag"]);
156
+ const CONSUMED_RULE_IDS = new Set(Object.keys(RULE_TIER));
157
+ const RECOGNIZED_RULE_IDS = new Set([...CONSUMED_RULE_IDS, ...RULE_FAMILY_IDS]);
158
+ const RULE_CONFIG_KEYS = new Set(["enabled", "severity", "options"]);
159
+
160
+ const PASSTHROUGH_KEYS = [
161
+ {
162
+ path: ["tokens"],
163
+ keys: [
164
+ "include",
165
+ "sources",
166
+ "packages",
167
+ "aliases",
168
+ "upstream",
169
+ "exclude",
170
+ "themeSelectors",
171
+ "enabled",
172
+ "format",
173
+ "namespace",
174
+ ],
175
+ },
176
+ {
177
+ path: ["screenshots"],
178
+ keys: ["viewport", "threshold", "delay", "outputDir", "themes"],
179
+ },
180
+ {
181
+ path: ["service"],
182
+ keys: ["poolSize", "idleTimeout"],
183
+ },
184
+ {
185
+ path: ["registry"],
186
+ keys: ["requireStory", "publicOnly", "categoryDepth", "includeProps", "embedFragments"],
187
+ },
188
+ {
189
+ path: ["govern", "tailwind"],
190
+ keys: ["palette"],
191
+ },
192
+ {
193
+ path: ["govern", "agent"],
194
+ keys: ["repairOrder"],
195
+ },
196
+ {
197
+ path: ["govern", "ci"],
198
+ keys: ["failOnWarnings"],
199
+ },
200
+ ] as const;
201
+
202
+ function objectRecord(value: unknown): Record<string, unknown> | undefined {
203
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
204
+ return value as Record<string, unknown>;
205
+ }
206
+
207
+ function valueAtPath(value: unknown, path: readonly string[]): unknown {
208
+ let current = value;
209
+ for (const segment of path) {
210
+ const record = objectRecord(current);
211
+ if (!record) return undefined;
212
+ current = record[segment];
213
+ }
214
+ return current;
215
+ }
216
+
217
+ function configPath(path: readonly (string | number)[]): string {
218
+ return path.reduce<string>(
219
+ (output, segment) =>
220
+ typeof segment === "number"
221
+ ? `${output}[${segment}]`
222
+ : output
223
+ ? `${output}.${segment}`
224
+ : segment,
225
+ ""
226
+ );
227
+ }
228
+
229
+ function unconsumedKeyDiagnostic(path: readonly (string | number)[]): InertConfigDiagnostic {
230
+ const renderedPath = configPath(path);
231
+ return {
232
+ code: "FUI9004",
233
+ kind: "unconsumed-key",
234
+ severity: "warn",
235
+ path: renderedPath,
236
+ message: `${renderedPath} is not consumed by Fragments and has no effect. Remove it or use a supported config key.`,
237
+ };
238
+ }
239
+
240
+ function collectStrippedKeys(
241
+ authored: unknown,
242
+ parsed: unknown,
243
+ path: readonly (string | number)[],
244
+ diagnostics: InertConfigDiagnostic[]
245
+ ): void {
246
+ if (Array.isArray(authored)) {
247
+ if (!Array.isArray(parsed)) return;
248
+ authored.forEach((item, index) => {
249
+ collectStrippedKeys(item, parsed[index], [...path, index], diagnostics);
250
+ });
251
+ return;
252
+ }
253
+
254
+ const authoredRecord = objectRecord(authored);
255
+ const parsedRecord = objectRecord(parsed);
256
+ if (!authoredRecord || !parsedRecord) return;
257
+
258
+ for (const key of Object.keys(authoredRecord).sort()) {
259
+ if (!Object.prototype.hasOwnProperty.call(parsedRecord, key)) {
260
+ diagnostics.push(unconsumedKeyDiagnostic([...path, key]));
261
+ continue;
262
+ }
263
+ collectStrippedKeys(authoredRecord[key], parsedRecord[key], [...path, key], diagnostics);
264
+ }
265
+ }
266
+
267
+ function collectPassthroughKeys(
268
+ authored: unknown,
269
+ path: readonly string[],
270
+ allowed: ReadonlySet<string>,
271
+ diagnostics: InertConfigDiagnostic[]
272
+ ): void {
273
+ const record = objectRecord(valueAtPath(authored, path));
274
+ if (!record) return;
275
+ for (const key of Object.keys(record).sort()) {
276
+ if (!allowed.has(key)) diagnostics.push(unconsumedKeyDiagnostic([...path, key]));
277
+ }
278
+ }
279
+
280
+ function collectRuleConfigKeys(
281
+ authored: unknown,
282
+ path: readonly string[],
283
+ diagnostics: InertConfigDiagnostic[]
284
+ ): void {
285
+ const rules = objectRecord(valueAtPath(authored, path));
286
+ if (!rules) return;
287
+ for (const ruleId of Object.keys(rules).sort()) {
288
+ if (!RECOGNIZED_RULE_IDS.has(ruleId)) {
289
+ diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId]));
290
+ continue;
291
+ }
292
+ const config = objectRecord(rules[ruleId]);
293
+ if (!config) continue;
294
+ for (const key of Object.keys(config).sort()) {
295
+ if (!RULE_CONFIG_KEYS.has(key)) {
296
+ diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId, key]));
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ function collectPassthroughRecordValues(
303
+ authored: unknown,
304
+ path: readonly string[],
305
+ allowed: ReadonlySet<string>,
306
+ diagnostics: InertConfigDiagnostic[]
307
+ ): void {
308
+ const entries = objectRecord(valueAtPath(authored, path));
309
+ if (!entries) return;
310
+ for (const [entryName, value] of Object.entries(entries).sort(([left], [right]) =>
311
+ left.localeCompare(right)
312
+ )) {
313
+ const record = objectRecord(value);
314
+ if (!record) continue;
315
+ for (const key of Object.keys(record).sort()) {
316
+ if (!allowed.has(key)) {
317
+ diagnostics.push(unconsumedKeyDiagnostic([...path, entryName, key]));
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ function stableConfigDiagnostics(
324
+ diagnostics: readonly InertConfigDiagnostic[]
325
+ ): InertConfigDiagnostic[] {
326
+ const unique = new Map<string, InertConfigDiagnostic>();
327
+ for (const diagnostic of diagnostics) {
328
+ unique.set(`${diagnostic.code}\0${diagnostic.path}`, diagnostic);
329
+ }
330
+ return [...unique.values()].sort(
331
+ (left, right) =>
332
+ left.path.localeCompare(right.path) ||
333
+ left.code.localeCompare(right.code) ||
334
+ left.message.localeCompare(right.message)
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Diagnose config keys that the permissive validation boundary accepts but the
340
+ * runtime cannot consume. The parsed config is the source of truth for stripped
341
+ * keys; explicit allow-sets cover intentional `.passthrough()`/record seams.
342
+ */
343
+ export function detectUnconsumedConfigKeys(
344
+ authoredConfig: unknown,
345
+ parsedConfig: unknown
346
+ ): InertConfigDiagnostic[] {
347
+ const diagnostics: InertConfigDiagnostic[] = [];
348
+ collectStrippedKeys(authoredConfig, parsedConfig, [], diagnostics);
349
+
350
+ for (const boundary of PASSTHROUGH_KEYS) {
351
+ collectPassthroughKeys(
352
+ authoredConfig,
353
+ boundary.path,
354
+ new Set<string>(boundary.keys),
355
+ diagnostics
356
+ );
357
+ }
358
+
359
+ collectRuleConfigKeys(authoredConfig, ["govern", "rules"], diagnostics);
360
+ collectPassthroughRecordValues(
361
+ authoredConfig,
362
+ ["govern", "agents"],
363
+ new Set(["rules"]),
364
+ diagnostics
365
+ );
366
+ collectPassthroughKeys(authoredConfig, ["govern", "audit"], new Set(), diagnostics);
367
+ collectPassthroughRecordValues(authoredConfig, ["govern", "runners"], new Set(), diagnostics);
368
+
369
+ return stableConfigDiagnostics(diagnostics);
370
+ }
371
+
372
+ interface ScaleBinding {
373
+ label: string;
374
+ mechanism: string;
375
+ scale: string;
376
+ }
377
+
378
+ function effectiveScaleBindings(policy: GovernanceConfig | undefined): ScaleBinding[] {
379
+ const bindings: ScaleBinding[] = [];
380
+ for (const style of policy?.styles ?? []) {
381
+ if (style.kind === "style.rawSpacing.mustMatchScale") {
382
+ bindings.push({
383
+ label: "Spacing properties",
384
+ mechanism: "style.rawSpacing.mustMatchScale",
385
+ scale: style.scale,
386
+ });
387
+ } else if (style.kind === "style.fontSize.mustMatchScale") {
388
+ bindings.push({
389
+ label: "Font-size properties",
390
+ mechanism: "style.fontSize.mustMatchScale",
391
+ scale: style.scale,
392
+ });
393
+ }
394
+ }
395
+ return bindings;
396
+ }
397
+
398
+ /**
399
+ * Compare locally declared scales with the effective property-policy graph.
400
+ * Preset-only scales are not diagnosed because the user did not declare them.
401
+ */
402
+ export function detectOrphanGovernanceScales(
403
+ declaredPolicy: GovernanceConfig | undefined,
404
+ effectivePolicy: GovernanceConfig | undefined
405
+ ): InertConfigDiagnostic[] {
406
+ const bindings = effectiveScaleBindings(effectivePolicy);
407
+ const referenced = new Set(bindings.map((binding) => binding.scale));
408
+ const diagnostics: InertConfigDiagnostic[] = [];
409
+
410
+ for (const scale of Object.keys(declaredPolicy?.scales ?? {}).sort()) {
411
+ if (referenced.has(scale)) continue;
412
+ const path = `govern.scales.${scale}`;
413
+ const preferred =
414
+ (scale.toLowerCase().includes("spac")
415
+ ? bindings.find((binding) => binding.mechanism === "style.rawSpacing.mustMatchScale")
416
+ : undefined) ??
417
+ (scale.toLowerCase().includes("font")
418
+ ? bindings.find((binding) => binding.mechanism === "style.fontSize.mustMatchScale")
419
+ : undefined) ??
420
+ bindings[0];
421
+ const remediation = preferred
422
+ ? `${preferred.label} are bound to the scale named "${preferred.scale}" — rename the key to "${preferred.scale}" or bind it via ${preferred.mechanism}.`
423
+ : `Bind it via style.rawSpacing.mustMatchScale or style.fontSize.mustMatchScale, or remove it.`;
424
+ diagnostics.push({
425
+ code: "FUI9005",
426
+ kind: "orphan-scale",
427
+ severity: "warn",
428
+ path,
429
+ message: `${path} is not referenced by any property policy. ${remediation}`,
430
+ });
431
+ }
432
+
433
+ return stableConfigDiagnostics(diagnostics);
434
+ }
435
+
134
436
  function summarize(
135
437
  status: GovernanceIntegrityStatus,
136
438
  flags: { componentsArmed: boolean; tokensArmed: boolean; blockingArmed: boolean }
@@ -152,6 +454,7 @@ export function evaluateGovernanceIntegrity(
152
454
  input: GovernanceIntegrityInput
153
455
  ): GovernanceIntegrityVerdict {
154
456
  const configs = effectiveRuleConfigs(input.policy);
457
+ const configDiagnostics = stableConfigDiagnostics(input.configDiagnostics ?? []);
155
458
 
156
459
  // --- policy family --------------------------------------------------------
157
460
  const policyArmed = input.policy !== undefined && input.policySource !== "none";
@@ -166,23 +469,29 @@ export function evaluateGovernanceIntegrity(
166
469
 
167
470
  // --- components family ----------------------------------------------------
168
471
  const componentsConfig = configs.get("components/prefer-library");
472
+ const confirmedBridges = input.policy?.canonicalBridges ?? [];
169
473
  const componentsArmed =
170
- componentsConfig?.enabled === true && hasEffectiveComponentVocabulary(componentsConfig.options);
474
+ confirmedBridges.length > 0 ||
475
+ (componentsConfig?.enabled === true &&
476
+ hasEffectiveComponentVocabulary(componentsConfig.options));
171
477
  const componentsFamily: GovernanceIntegrityFamily = {
172
478
  id: "components",
173
479
  armed: componentsArmed,
174
- rules: ["components/prefer-library"],
480
+ rules: [
481
+ ...(confirmedBridges.length > 0 ? ["imports/preferred-path"] : []),
482
+ ...(componentsConfig?.enabled === true ? ["components/prefer-library"] : []),
483
+ ],
175
484
  };
176
485
  if (!componentsArmed) {
177
486
  if (componentsConfig?.enabled === true) {
178
487
  componentsFamily.reason =
179
488
  "components/prefer-library enabled but no effective canonical source";
180
489
  componentsFamily.remediation =
181
- "add govern.canonicalSources (a directory source, or npm/registry with a non-empty include) or designSystem.path/packageName";
490
+ "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
182
491
  } else {
183
492
  componentsFamily.reason = "components/prefer-library not enabled";
184
493
  componentsFamily.remediation =
185
- "add govern.canonicalSources (a directory source, or npm/registry with a non-empty include) or designSystem.path/packageName";
494
+ "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
186
495
  }
187
496
  }
188
497
 
@@ -274,6 +583,17 @@ export function evaluateGovernanceIntegrity(
274
583
  .filter((remediation): remediation is string => remediation !== undefined)
275
584
  );
276
585
  const summary = summarize(status, { componentsArmed, tokensArmed, blockingArmed });
586
+ const configuredRuleCount = [...configs.keys()].filter((ruleId) =>
587
+ CONSUMED_RULE_IDS.has(ruleId)
588
+ ).length;
589
+ const activeRuleCount = [...configs.entries()].filter(
590
+ ([ruleId, config]) => CONSUMED_RULE_IDS.has(ruleId) && config.enabled
591
+ ).length;
592
+ const roster = {
593
+ configured: configuredRuleCount + configDiagnostics.length,
594
+ active: activeRuleCount,
595
+ inert: configDiagnostics.length,
596
+ };
277
597
 
278
598
  return {
279
599
  status,
@@ -284,5 +604,7 @@ export function evaluateGovernanceIntegrity(
284
604
  armed,
285
605
  summary,
286
606
  remediations,
607
+ configDiagnostics,
608
+ roster,
287
609
  };
288
610
  }
@@ -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"],
@@ -139,6 +158,73 @@ describe("governance DSL", () => {
139
158
  });
140
159
  });
141
160
 
161
+ it("accepts one confirmed package-to-local canonical bridge", () => {
162
+ const config = defineConfig({
163
+ govern: {
164
+ canonicalBridges: [
165
+ {
166
+ underlying: { packageName: "@mui/material", exportName: "Button" },
167
+ local: {
168
+ componentKey: "src/components/Button.tsx#Button",
169
+ moduleSpecifier: "@/components",
170
+ exportName: "Button",
171
+ implementationFiles: ["src/components/Button.tsx"],
172
+ },
173
+ decision: { state: "confirmed", source: "authored" },
174
+ },
175
+ ],
176
+ },
177
+ });
178
+
179
+ expect(config.govern?.canonicalBridges?.[0]).toMatchObject({
180
+ underlying: { packageName: "@mui/material", exportName: "Button" },
181
+ local: { moduleSpecifier: "@/components", exportName: "Button" },
182
+ decision: { state: "confirmed", source: "authored" },
183
+ });
184
+ });
185
+
186
+ it("rejects ambiguous or unsafe canonical bridge declarations", () => {
187
+ const bridge = {
188
+ underlying: { packageName: "@mui/material", exportName: "Button" },
189
+ local: {
190
+ componentKey: "src/components/Button.tsx#Button",
191
+ moduleSpecifier: "@/components",
192
+ exportName: "Button",
193
+ implementationFiles: ["src/components/Button.tsx"],
194
+ },
195
+ decision: { state: "confirmed" as const, source: "authored" as const },
196
+ };
197
+
198
+ expect(() => defineConfig({ govern: { canonicalBridges: [bridge, bridge] } })).toThrow(
199
+ "Invalid fragments config"
200
+ );
201
+ expect(() =>
202
+ defineConfig({
203
+ govern: {
204
+ canonicalBridges: [
205
+ bridge,
206
+ {
207
+ ...bridge,
208
+ underlying: { packageName: "@radix-ui/react-slot", exportName: "Slot" },
209
+ },
210
+ ],
211
+ },
212
+ })
213
+ ).toThrow("Invalid fragments config");
214
+ expect(() =>
215
+ defineConfig({
216
+ govern: {
217
+ canonicalBridges: [
218
+ {
219
+ ...bridge,
220
+ local: { ...bridge.local, moduleSpecifier: "../components" },
221
+ },
222
+ ],
223
+ },
224
+ })
225
+ ).toThrow("Invalid fragments config");
226
+ });
227
+
142
228
  it("preserves top-level component identity decisions", () => {
143
229
  const config = defineConfig({
144
230
  identity: {
package/src/governance.ts CHANGED
@@ -192,6 +192,79 @@ const canonicalSourcePathSchema = z
192
192
 
193
193
  const canonicalSourceRegistryHashSchema = z.string().regex(/^[0-9a-f]{64}$/);
194
194
 
195
+ const canonicalBridgeModuleSpecifierSchema = z
196
+ .string()
197
+ .min(1)
198
+ .superRefine((value, ctx) => {
199
+ const normalized = value.replace(/\\/gu, "/");
200
+ if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
201
+ ctx.addIssue({
202
+ code: z.ZodIssueCode.custom,
203
+ message: "Canonical bridge moduleSpecifier must be a portable import specifier",
204
+ });
205
+ }
206
+ if (normalized.split("/").includes("..")) {
207
+ ctx.addIssue({
208
+ code: z.ZodIssueCode.custom,
209
+ message: "Canonical bridge moduleSpecifier must not traverse outside the project",
210
+ });
211
+ }
212
+ });
213
+
214
+ export const canonicalBridgeV1Schema = z
215
+ .object({
216
+ underlying: z
217
+ .object({
218
+ packageName: z.string().min(1),
219
+ exportName: z.string().min(1),
220
+ })
221
+ .strict(),
222
+ local: z
223
+ .object({
224
+ componentKey: z.string().min(1),
225
+ moduleSpecifier: canonicalBridgeModuleSpecifierSchema,
226
+ exportName: z.string().min(1),
227
+ implementationFiles: z.array(canonicalSourcePathSchema).min(1),
228
+ })
229
+ .strict(),
230
+ decision: z
231
+ .object({
232
+ state: z.literal("confirmed"),
233
+ source: z.enum(["authored", "migration"]),
234
+ })
235
+ .strict(),
236
+ })
237
+ .strict();
238
+
239
+ const canonicalBridgesV1Schema = z.array(canonicalBridgeV1Schema).superRefine((bridges, ctx) => {
240
+ const underlying = new Map<string, number>();
241
+ const componentKeys = new Map<string, number>();
242
+ for (const [index, bridge] of bridges.entries()) {
243
+ const underlyingKey = `${bridge.underlying.packageName}\u0000${bridge.underlying.exportName}`;
244
+ const previousUnderlying = underlying.get(underlyingKey);
245
+ if (previousUnderlying !== undefined) {
246
+ ctx.addIssue({
247
+ code: z.ZodIssueCode.custom,
248
+ message: `Duplicate confirmed bridge target for ${bridge.underlying.packageName}#${bridge.underlying.exportName} (also at canonicalBridges.${previousUnderlying})`,
249
+ path: [index, "underlying"],
250
+ });
251
+ } else {
252
+ underlying.set(underlyingKey, index);
253
+ }
254
+
255
+ const previousComponent = componentKeys.get(bridge.local.componentKey);
256
+ if (previousComponent !== undefined) {
257
+ ctx.addIssue({
258
+ code: z.ZodIssueCode.custom,
259
+ message: `Local component key overlaps canonicalBridges.${previousComponent}`,
260
+ path: [index, "local", "componentKey"],
261
+ });
262
+ } else {
263
+ componentKeys.set(bridge.local.componentKey, index);
264
+ }
265
+ }
266
+ });
267
+
195
268
  const canonicalSourceSchema = z.discriminatedUnion("kind", [
196
269
  z.object({
197
270
  kind: z.literal("npm"),
@@ -237,6 +310,7 @@ export const governanceConfigSchema = z
237
310
  audit: z.object({}).passthrough().optional(),
238
311
  runners: z.record(z.string(), z.object({}).passthrough()).optional(),
239
312
  canonicalSources: z.array(canonicalSourceSchema).optional(),
313
+ canonicalBridges: canonicalBridgesV1Schema.optional(),
240
314
  presets: z.array(z.string()).optional(),
241
315
  scales: z.record(z.string(), scaleGovernanceRecordSchema).optional(),
242
316
  styles: z.array(globalStyleGovernanceRecordSchema).optional(),
@@ -278,19 +352,58 @@ export type ComponentPolicyMatch = z.infer<typeof componentPolicyMatchSchema>;
278
352
  export type ComponentPolicyRecord = z.infer<typeof componentPolicyRecordSchema>;
279
353
  export type ComponentPolicyOverride = z.infer<typeof componentPolicyOverrideSchema>;
280
354
  export type CanonicalSource = z.infer<typeof canonicalSourceSchema>;
355
+ export type CanonicalBridgeV1 = z.infer<typeof canonicalBridgeV1Schema>;
281
356
 
282
357
  export interface GovernanceConfig {
358
+ /** Shared governance config modules to extend before applying this file's declarations. */
283
359
  extends?: string[];
360
+
361
+ /** Default severity for governance rules that do not declare their own severity. */
284
362
  severity?: GovernanceSeverity;
363
+
364
+ /**
365
+ * Rule-id keyed enablement and severity overrides. Only fields consumed by the named
366
+ * rule are valid; unsupported fields are reported as inert config.
367
+ */
285
368
  rules?: Record<string, unknown>;
369
+
370
+ /** Agent-id keyed rule overrides for supported agent-specific governance policies. */
286
371
  agents?: Record<string, { rules?: Record<string, unknown> }>;
372
+
373
+ /** Reserved audit compatibility object; undeclared child keys are reported as inert. */
287
374
  audit?: Record<string, unknown>;
375
+
376
+ /** Reserved runner compatibility map; undeclared child keys are reported as inert. */
288
377
  runners?: Record<string, Record<string, unknown>>;
378
+
379
+ /**
380
+ * Canonical component authorities: npm packages, repository directories, or registry
381
+ * receipts whose included exports arm canonical-component rules.
382
+ */
289
383
  canonicalSources?: CanonicalSource[];
384
+
385
+ /**
386
+ * Confirmed mappings from an underlying library export to the approved local wrapper.
387
+ * The wrapper's implementationFiles scope permits its direct underlying import.
388
+ */
389
+ canonicalBridges?: CanonicalBridgeV1[];
390
+
391
+ /** Versioned governance presets to resolve before applying local rule overrides. */
290
392
  presets?: string[];
393
+
394
+ /**
395
+ * Named numeric scales. Spacing rules bind through
396
+ * style.rawSpacing.mustMatchScale; the built-in spacing policy references `space`.
397
+ */
291
398
  scales?: Record<string, ScaleGovernanceRecord>;
399
+
400
+ /** Legacy typed style-policy records, normalized into the active rule policy. */
292
401
  styles?: GlobalStyleGovernanceRecord[];
402
+
403
+ /** Legacy typed JSX-policy records, normalized into the active rule policy. */
293
404
  jsx?: GlobalJsxGovernanceRecord[];
405
+
406
+ /** Tailwind palette allow/deny policy used by Tailwind governance rules. */
294
407
  tailwind?: {
295
408
  palette?: {
296
409
  allow?: string[];
@@ -298,12 +411,20 @@ export interface GovernanceConfig {
298
411
  };
299
412
  [key: string]: unknown;
300
413
  };
414
+
415
+ /** Agent repair-order guidance consumed when presenting deterministic fixes. */
301
416
  agent?: {
302
417
  repairOrder?: string[];
303
418
  [key: string]: unknown;
304
419
  };
420
+
421
+ /** Component-keyed governance records for canonical component metadata and prop policy. */
305
422
  components?: Record<string, ComponentPolicyRecord>;
423
+
424
+ /** Ordered component-policy overrides selected by component identity fields. */
306
425
  overrides?: ComponentPolicyOverride[];
426
+
427
+ /** Governance CI rendering options, including whether warnings fail the CI verdict. */
307
428
  ci?: {
308
429
  failOnWarnings?: boolean;
309
430
  [key: string]: unknown;