@usefragments/core 1.10.2 → 2.0.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 (130) hide show
  1. package/dist/{chunk-MZ4SW3TP.js → chunk-3IOWHECM.js} +8 -1
  2. package/dist/{chunk-MZ4SW3TP.js.map → chunk-3IOWHECM.js.map} +1 -1
  3. package/dist/chunk-7ULGH74M.js +66 -0
  4. package/dist/chunk-7ULGH74M.js.map +1 -0
  5. package/dist/{chunk-XN3LSDPY.js → chunk-BMPYIUZE.js} +612 -344
  6. package/dist/chunk-BMPYIUZE.js.map +1 -0
  7. package/dist/{chunk-YF65VYRY.js → chunk-ML5S6QNU.js} +191 -169
  8. package/dist/chunk-ML5S6QNU.js.map +1 -0
  9. package/dist/{chunk-3LLRNCPX.js → chunk-MZ2FS7U4.js} +1 -1
  10. package/dist/chunk-MZ2FS7U4.js.map +1 -0
  11. package/dist/chunk-PWIJMOI4.js +202 -0
  12. package/dist/chunk-PWIJMOI4.js.map +1 -0
  13. package/dist/chunk-RYFULE43.js +578 -0
  14. package/dist/chunk-RYFULE43.js.map +1 -0
  15. package/dist/codes/index.d.ts +2 -2
  16. package/dist/codes/index.js +3 -2
  17. package/dist/compiled-types/index.d.ts +327 -2
  18. package/dist/compiled-types/index.js +1 -1
  19. package/dist/generate/index.d.ts +3 -2
  20. package/dist/{governance-CLk_wkP9.d.ts → governance-hOPXGbbs.d.ts} +474 -515
  21. package/dist/governance-telemetry.d.ts +6 -0
  22. package/dist/governance-telemetry.js +1 -1
  23. package/dist/{index-_sxhUNqx.d.ts → index-C8bcXVav.d.ts} +448 -448
  24. package/dist/index.d.ts +3280 -1305
  25. package/dist/index.js +2414 -350
  26. package/dist/index.js.map +1 -1
  27. package/dist/manifest.d.ts +228 -0
  28. package/dist/manifest.js +24 -0
  29. package/dist/manifest.js.map +1 -0
  30. package/dist/preview/index.js +45 -1
  31. package/dist/preview/index.js.map +1 -1
  32. package/dist/preview-runtime.d.ts +1 -2
  33. package/dist/preview-runtime.js +150 -14
  34. package/dist/preview-runtime.js.map +1 -1
  35. package/dist/react-types.d.ts +1 -2
  36. package/dist/registry.d.ts +1412 -203
  37. package/dist/registry.js +30 -3
  38. package/dist/schemas/index.d.ts +1 -1
  39. package/dist/schemas/index.js +3 -2
  40. package/dist/storyAdapter.d.ts +1 -2
  41. package/dist/storyAdapter.js +11 -49
  42. package/dist/storyAdapter.js.map +1 -1
  43. package/dist/test-utils.d.ts +3 -2
  44. package/dist/topology/index.d.ts +1 -1
  45. package/dist/topology/index.js +1 -1
  46. package/package.json +8 -2
  47. package/src/__tests__/contract-parser.test.ts +318 -277
  48. package/src/__tests__/preview-runtime-hook.test.tsx +315 -0
  49. package/src/__tests__/preview-runtime.test.tsx +30 -8
  50. package/src/__tests__/schema.test.ts +191 -14
  51. package/src/analysis-plan/analysis-plan-v1.test.ts +320 -0
  52. package/src/analysis-plan/coverage.ts +181 -0
  53. package/src/analysis-plan/digest.ts +141 -0
  54. package/src/analysis-plan/index.ts +34 -0
  55. package/src/analysis-plan/types.ts +207 -0
  56. package/src/approved-contract-tokens.test.ts +39 -0
  57. package/src/approved-contract-tokens.ts +18 -0
  58. package/src/codes/__tests__/codes.test.ts +13 -0
  59. package/src/codes/codes.ts +40 -0
  60. package/src/compiled-types/index.ts +640 -39
  61. package/src/compiled-types/parse.test.ts +145 -4
  62. package/src/component-contract.ts +95 -53
  63. package/src/composition.ts +7 -13
  64. package/src/constants.ts +3 -6
  65. package/src/contract/hash.test.ts +20 -0
  66. package/src/contract/hash.ts +66 -9
  67. package/src/contract/index.ts +24 -1
  68. package/src/contract/manifest.test.ts +94 -0
  69. package/src/contract/manifest.ts +68 -0
  70. package/src/contract/preimage.test.ts +219 -1
  71. package/src/contract/preimage.ts +326 -6
  72. package/src/contract/stamp.test.ts +3 -0
  73. package/src/contract/stamp.ts +1 -1
  74. package/src/contract-parser.ts +54 -30
  75. package/src/defineFragment.test.ts +476 -91
  76. package/src/defineFragment.ts +204 -114
  77. package/src/domain-ids.test.ts +35 -0
  78. package/src/domain-ids.ts +61 -0
  79. package/src/evaluation/evaluate.test.ts +522 -0
  80. package/src/evaluation/evaluate.ts +690 -0
  81. package/src/evaluation/evaluation-v2-receipt-v1.test.ts +772 -0
  82. package/src/evaluation/index.ts +58 -0
  83. package/src/evaluation/receipt.ts +753 -0
  84. package/src/evaluation/types.ts +406 -0
  85. package/src/facts/builders.ts +2 -0
  86. package/src/facts/compile.ts +29 -6
  87. package/src/facts/fact-index.ts +13 -3
  88. package/src/facts/fact-integrity-v1.test.ts +172 -0
  89. package/src/facts/facts.test.ts +15 -0
  90. package/src/facts/ids.ts +46 -3
  91. package/src/facts/index.ts +14 -1
  92. package/src/facts/integrity.ts +134 -0
  93. package/src/facts/types.ts +36 -0
  94. package/src/governance-integrity.test.ts +1 -0
  95. package/src/governance-integrity.ts +5 -3
  96. package/src/governance-telemetry.ts +8 -0
  97. package/src/governance.ts +70 -8
  98. package/src/index.ts +230 -37
  99. package/src/preview/validation.test.ts +62 -0
  100. package/src/preview/validation.ts +48 -2
  101. package/src/preview-runtime.tsx +227 -20
  102. package/src/registry-install-plan.ts +200 -109
  103. package/src/registry-shards.test.ts +263 -0
  104. package/src/registry.ts +237 -0
  105. package/src/repository-binding.test.ts +50 -0
  106. package/src/repository-binding.ts +96 -0
  107. package/src/rules/families.test.ts +36 -0
  108. package/src/rules/finding.ts +7 -2
  109. package/src/rules/index.ts +17 -1
  110. package/src/rules/rule-config.test.ts +66 -0
  111. package/src/rules/rule-config.ts +73 -0
  112. package/src/rules/rules.test.ts +26 -0
  113. package/src/rules/tokens-css-vars-must-be-defined.test.ts +51 -2
  114. package/src/rules/tokens-css-vars-must-be-defined.ts +34 -1
  115. package/src/schema.ts +293 -113
  116. package/src/schemas/index.ts +1 -1
  117. package/src/storyAdapter.test.ts +68 -12
  118. package/src/storyAdapter.ts +44 -75
  119. package/src/topology/resolve-area.ts +1 -1
  120. package/src/types.ts +258 -40
  121. package/dist/chunk-3LLRNCPX.js.map +0 -1
  122. package/dist/chunk-RANPUC6C.js +0 -72
  123. package/dist/chunk-RANPUC6C.js.map +0 -1
  124. package/dist/chunk-XN3LSDPY.js.map +0 -1
  125. package/dist/chunk-YF65VYRY.js.map +0 -1
  126. package/src/fragment-types.ts +0 -214
  127. package/src/react-create-element.test.ts +0 -22
  128. package/src/react-create-element.ts +0 -12
  129. package/src/storyFilters.test.ts +0 -350
  130. package/src/storyFilters.ts +0 -253
package/src/registry.ts CHANGED
@@ -141,6 +141,8 @@ const registryManifestBaseSchema = z.object({
141
141
  generatedAt: z.string().datetime(),
142
142
  source: registrySourceSchema,
143
143
  packageFiles: z.array(registryFileSchema).default([]),
144
+ /** Files every install writes, whatever the selection: the stylesheet entry and its closure. */
145
+ baseFiles: z.array(registryFileSchema).default([]),
144
146
  entrypoints: z.array(registryEntrypointSchema).default([]),
145
147
  components: z.record(z.string(), registryComponentSchema),
146
148
  dependencies: z.array(registryDependencySchema).default([]),
@@ -173,6 +175,7 @@ export const registryArtifactSchema = z
173
175
 
174
176
  const manifestFiles = [
175
177
  ...artifact.manifest.packageFiles,
178
+ ...artifact.manifest.baseFiles,
176
179
  ...artifact.manifest.entrypoints.map((entrypoint) => ({
177
180
  path: entrypoint.sourcePath,
178
181
  role: "barrel" as const,
@@ -367,3 +370,237 @@ export function registryManifestCanonicalPreimage(
367
370
  ): string {
368
371
  return canonicalPreimage(registryHashPayload(manifest));
369
372
  }
373
+
374
+ // ============================================================================
375
+ // Sharded artifact (schema v2): index without bodies + content shards
376
+ // ============================================================================
377
+
378
+ export const REGISTRY_INDEX_SCHEMA_VERSION = "fragments.registry-index.v2";
379
+ export const REGISTRY_SHARD_SCHEMA_VERSION = "fragments.registry-shard.v2";
380
+
381
+ const registryShardPathSchema = registryFilePathSchema.superRefine((value, ctx) => {
382
+ if (!value.endsWith(".json")) {
383
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Shard path must end with .json" });
384
+ }
385
+ });
386
+
387
+ export const registryShardSchema = z
388
+ .object({
389
+ schemaVersion: z.literal(REGISTRY_SHARD_SCHEMA_VERSION),
390
+ files: z.record(z.string().min(1), registryFileContentSchema),
391
+ })
392
+ .superRefine((shard, ctx) => {
393
+ for (const [contentRef, file] of Object.entries(shard.files)) {
394
+ if (contentRef !== `sha256:${file.sha256}`) {
395
+ ctx.addIssue({
396
+ code: z.ZodIssueCode.custom,
397
+ path: ["files", contentRef],
398
+ message: `Shard key ${contentRef} does not match content hash of ${file.path}`,
399
+ });
400
+ }
401
+ }
402
+ });
403
+
404
+ export const registryIndexSchema = z
405
+ .object({
406
+ schemaVersion: z.literal(REGISTRY_INDEX_SCHEMA_VERSION),
407
+ manifest: registryManifestSchema,
408
+ /** Shard path (relative to the index) → registry file paths whose bodies it carries. */
409
+ shards: z.record(registryShardPathSchema, z.array(registryFilePathSchema).min(1)),
410
+ })
411
+ .superRefine((index, ctx) => {
412
+ const owner = new Map<string, string>();
413
+ for (const [shardPath, paths] of Object.entries(index.shards)) {
414
+ for (const path of paths) {
415
+ const existing = owner.get(path);
416
+ if (existing) {
417
+ ctx.addIssue({
418
+ code: z.ZodIssueCode.custom,
419
+ path: ["shards", shardPath],
420
+ message: `Registry file ${path} is listed in both ${existing} and ${shardPath}`,
421
+ });
422
+ }
423
+ owner.set(path, shardPath);
424
+ }
425
+ }
426
+ for (const path of registryManifestFilePaths(index.manifest)) {
427
+ if (!owner.has(path)) {
428
+ ctx.addIssue({
429
+ code: z.ZodIssueCode.custom,
430
+ path: ["shards"],
431
+ message: `Registry file ${path} is not carried by any shard`,
432
+ });
433
+ }
434
+ }
435
+ });
436
+
437
+ /**
438
+ * A short-lived pointer (`latest.json`) to an immutable versioned index. One hop only;
439
+ * `index` is a relative path so the hop cannot leave the pointer's origin.
440
+ */
441
+ export const REGISTRY_POINTER_SCHEMA_VERSION = "fragments.registry-pointer.v1";
442
+
443
+ export const registryPointerSchema = z.object({
444
+ schemaVersion: z.literal(REGISTRY_POINTER_SCHEMA_VERSION),
445
+ registryId: z.string().min(1),
446
+ version: z.string().min(1),
447
+ registryHash: sha256Schema,
448
+ index: registryShardPathSchema,
449
+ generatedAt: z.string().datetime(),
450
+ });
451
+
452
+ export type RegistryShard = z.infer<typeof registryShardSchema>;
453
+ export type RegistryIndex = z.infer<typeof registryIndexSchema>;
454
+ export type RegistryPointer = z.infer<typeof registryPointerSchema>;
455
+
456
+ export function buildRegistryPointer(index: RegistryIndex, indexPath: string): RegistryPointer {
457
+ return registryPointerSchema.parse({
458
+ schemaVersion: REGISTRY_POINTER_SCHEMA_VERSION,
459
+ registryId: index.manifest.registryId,
460
+ version: index.manifest.version,
461
+ registryHash: index.manifest.registryHash,
462
+ index: indexPath,
463
+ generatedAt: index.manifest.generatedAt,
464
+ });
465
+ }
466
+
467
+ /** Every file path the manifest references, sorted and unique. */
468
+ export function registryManifestFilePaths(manifest: RegistryManifest): string[] {
469
+ return [...registryManifestFileMetadata(manifest).keys()].sort((left, right) =>
470
+ left.localeCompare(right)
471
+ );
472
+ }
473
+
474
+ /** Path → metadata for every manifest file; entrypoint-only paths map to `null`. */
475
+ export function registryManifestFileMetadata(
476
+ manifest: RegistryManifest
477
+ ): Map<string, RegistryFile | null> {
478
+ const files = new Map<string, RegistryFile | null>();
479
+ const add = (file: RegistryFile) => {
480
+ if (!files.get(file.path)) files.set(file.path, file);
481
+ };
482
+ manifest.packageFiles.forEach(add);
483
+ manifest.baseFiles.forEach(add);
484
+ for (const component of Object.values(manifest.components)) component.files.forEach(add);
485
+ for (const entrypoint of manifest.entrypoints) {
486
+ if (!files.has(entrypoint.sourcePath)) files.set(entrypoint.sourcePath, null);
487
+ }
488
+ return files;
489
+ }
490
+
491
+ /**
492
+ * Default shard layout: one shard per component directory, one shard per shared
493
+ * top-level directory under `src/` (`shared/tokens.json`, `shared/utils.json`, …),
494
+ * `shared/src.json` for files directly under `src/`, and `shared/package.json` for
495
+ * package-root files (`package.json`, `fragments.json`) that only complete installs need.
496
+ */
497
+ export function defaultRegistryShardPath(path: string): string {
498
+ const componentMatch = /^src\/components\/([^/]+)\//.exec(path);
499
+ if (componentMatch) return `components/${componentMatch[1]}.json`;
500
+ if (!path.startsWith("src/")) return "shared/package.json";
501
+ const segments = path.slice("src/".length).split("/");
502
+ return segments.length > 1 ? `shared/${segments[0]}.json` : "shared/src.json";
503
+ }
504
+
505
+ export interface ShardedRegistryArtifact {
506
+ index: RegistryIndex;
507
+ shards: Map<string, RegistryShard>;
508
+ }
509
+
510
+ function shardKeyFor(metadata: RegistryFile | null | undefined, sha256: string): string {
511
+ return metadata?.contentRef ?? `sha256:${sha256}`;
512
+ }
513
+
514
+ export function shardRegistryArtifact(
515
+ artifact: RegistryArtifact,
516
+ shardPathFor: (path: string) => string = defaultRegistryShardPath
517
+ ): ShardedRegistryArtifact {
518
+ const metadata = registryManifestFileMetadata(artifact.manifest);
519
+ const shards = new Map<string, RegistryShard>();
520
+ const shardPaths: Record<string, string[]> = {};
521
+
522
+ for (const [path, content] of Object.entries(artifact.files).sort(([left], [right]) =>
523
+ left.localeCompare(right)
524
+ )) {
525
+ const shardPath = shardPathFor(path);
526
+ const shard = shards.get(shardPath) ?? {
527
+ schemaVersion: REGISTRY_SHARD_SCHEMA_VERSION,
528
+ files: {},
529
+ };
530
+ shard.files[shardKeyFor(metadata.get(path), content.sha256)] = content;
531
+ shards.set(shardPath, shard);
532
+ (shardPaths[shardPath] ??= []).push(path);
533
+ }
534
+
535
+ const index = registryIndexSchema.parse({
536
+ schemaVersion: REGISTRY_INDEX_SCHEMA_VERSION,
537
+ manifest: artifact.manifest,
538
+ shards: shardPaths,
539
+ });
540
+ for (const [path, shard] of shards) shards.set(path, registryShardSchema.parse(shard));
541
+ return { index, shards };
542
+ }
543
+
544
+ function shardOwnerMap(index: RegistryIndex): Map<string, string> {
545
+ const owner = new Map<string, string>();
546
+ for (const [shardPath, paths] of Object.entries(index.shards)) {
547
+ for (const path of paths) owner.set(path, shardPath);
548
+ }
549
+ return owner;
550
+ }
551
+
552
+ /** The shards (sorted, unique) that carry the given registry file paths. */
553
+ export function registryShardsForPaths(index: RegistryIndex, paths: readonly string[]): string[] {
554
+ const owner = shardOwnerMap(index);
555
+ const shards = new Set<string>();
556
+ for (const path of paths) {
557
+ const shardPath = owner.get(path);
558
+ if (!shardPath) throw new Error(`Registry index has no shard for ${path}`);
559
+ shards.add(shardPath);
560
+ }
561
+ return [...shards].sort((left, right) => left.localeCompare(right));
562
+ }
563
+
564
+ /**
565
+ * Rebuild a v1-shaped artifact from an index plus the shards that cover `paths`.
566
+ * Only the requested paths are materialised; every body is checked against the
567
+ * manifest's hash and size before it is accepted.
568
+ */
569
+ export function assembleRegistryArtifact(args: {
570
+ index: RegistryIndex;
571
+ shards: ReadonlyMap<string, RegistryShard>;
572
+ paths: readonly string[];
573
+ }): RegistryArtifact {
574
+ const metadata = registryManifestFileMetadata(args.index.manifest);
575
+ const owner = shardOwnerMap(args.index);
576
+ const files: Record<string, RegistryFileContent> = {};
577
+
578
+ for (const path of [...new Set(args.paths)].sort((left, right) => left.localeCompare(right))) {
579
+ const shardPath = owner.get(path);
580
+ if (!shardPath) throw new Error(`Registry index has no shard for ${path}`);
581
+ const shard = args.shards.get(shardPath);
582
+ if (!shard) throw new Error(`Registry shard ${shardPath} was not loaded (needed for ${path})`);
583
+ const expected = metadata.get(path) ?? null;
584
+ const content = expected
585
+ ? shard.files[expected.contentRef]
586
+ : Object.values(shard.files).find((file) => file.path === path);
587
+ if (!content || content.path !== path) {
588
+ throw new Error(`Registry shard ${shardPath} is missing content for ${path}`);
589
+ }
590
+ // Re-hash the body: a shard's own sha256/size fields are claims, the index is the truth.
591
+ const actualSha256 = hashRegistryFileContent(content.content);
592
+ const actualSize = registryFileSize(content.content);
593
+ const expectedSha256 = expected?.sha256 ?? content.sha256;
594
+ const expectedSize = expected?.size ?? content.size;
595
+ if (actualSha256 !== expectedSha256 || actualSize !== expectedSize) {
596
+ throw new Error(`Registry shard ${shardPath} content for ${path} does not match the index`);
597
+ }
598
+ files[path] = content;
599
+ }
600
+
601
+ return {
602
+ schemaVersion: REGISTRY_ARTIFACT_SCHEMA_VERSION,
603
+ manifest: args.index.manifest,
604
+ files,
605
+ };
606
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ normalizeRepositoryBindingKeyV1,
4
+ repositoryBindingDigestV1,
5
+ repositoryBindingKeyV1Schema,
6
+ } from "./repository-binding.js";
7
+
8
+ const binding = {
9
+ provider: "github",
10
+ providerInstanceId: "github.com",
11
+ repositoryExternalId: "R_kgDOOpaqueCase",
12
+ bindingId: "binding_OpaqueCase",
13
+ };
14
+
15
+ describe("RepositoryBindingKeyV1", () => {
16
+ it("normalizes provider and instance syntax without changing opaque IDs", () => {
17
+ expect(
18
+ normalizeRepositoryBindingKeyV1({
19
+ ...binding,
20
+ provider: " GitHub ",
21
+ providerInstanceId: "HTTPS://GitHub.COM/",
22
+ })
23
+ ).toEqual(binding);
24
+ });
25
+
26
+ it("has a checked-in stable identity digest", () => {
27
+ expect(repositoryBindingDigestV1(binding)).toBe(
28
+ "b0460189d57f386bab0043a08df24af05b736bb8393a9de429a973b5e26e7898"
29
+ );
30
+ });
31
+
32
+ it("ignores display data by rejecting it at the canonical boundary", () => {
33
+ expect(() => repositoryBindingKeyV1Schema.parse({ ...binding, owner: "renamed" })).toThrow();
34
+ });
35
+
36
+ it("changes identity when opaque repository identity changes", () => {
37
+ expect(repositoryBindingDigestV1({ ...binding, repositoryExternalId: "R_other" })).not.toBe(
38
+ repositoryBindingDigestV1(binding)
39
+ );
40
+ });
41
+
42
+ it("rejects oversized and malformed identity fields", () => {
43
+ expect(() =>
44
+ normalizeRepositoryBindingKeyV1({ ...binding, bindingId: "x".repeat(257) })
45
+ ).toThrow();
46
+ expect(() =>
47
+ normalizeRepositoryBindingKeyV1({ ...binding, providerInstanceId: "https://github.com/a" })
48
+ ).toThrow();
49
+ });
50
+ });
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { canonicalPreimage, sha256Hex } from "./contract/hash.js";
3
+ import { digestHexSchema, type DigestHex } from "./domain-ids.js";
4
+
5
+ export const REPOSITORY_BINDING_ID_MAX_BYTES_V1 = 256;
6
+ export const PROVIDER_ID_MAX_BYTES_V1 = 64;
7
+
8
+ const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength;
9
+ const boundedString = (label: string, maxBytes: number) =>
10
+ z
11
+ .string()
12
+ .min(1, `${label} is required`)
13
+ .refine((value) => utf8Length(value) <= maxBytes, `${label} exceeds ${maxBytes} UTF-8 bytes`);
14
+
15
+ const providerSchema = boundedString("provider", PROVIDER_ID_MAX_BYTES_V1)
16
+ .transform((value) => value.trim().toLowerCase())
17
+ .refine(
18
+ (value) => /^[a-z][a-z0-9_-]*$/u.test(value),
19
+ "provider must be a bounded lowercase identifier"
20
+ );
21
+
22
+ function normalizeProviderInstanceId(value: string): string {
23
+ const trimmed = value.trim();
24
+ if (trimmed.length === 0) throw new TypeError("providerInstanceId is required");
25
+
26
+ if (trimmed.includes("://")) {
27
+ const url = new URL(trimmed);
28
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
29
+ throw new TypeError("providerInstanceId URL must use http or https");
30
+ }
31
+ if (
32
+ url.username ||
33
+ url.password ||
34
+ url.search ||
35
+ url.hash ||
36
+ (url.pathname !== "" && url.pathname !== "/")
37
+ ) {
38
+ throw new TypeError("providerInstanceId must identify an origin, not a path or credential");
39
+ }
40
+ return url.host.toLowerCase().replace(/\.$/u, "");
41
+ }
42
+
43
+ const normalized = trimmed.toLowerCase().replace(/\.$/u, "");
44
+ if (!/^[a-z0-9](?:[a-z0-9._:-]*[a-z0-9])?$/u.test(normalized)) {
45
+ throw new TypeError("providerInstanceId must be a normalized origin or tenant key");
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ const providerInstanceSchema = boundedString(
51
+ "providerInstanceId",
52
+ REPOSITORY_BINDING_ID_MAX_BYTES_V1
53
+ ).transform((value, context) => {
54
+ try {
55
+ return normalizeProviderInstanceId(value);
56
+ } catch (error) {
57
+ context.addIssue({
58
+ code: z.ZodIssueCode.custom,
59
+ message: error instanceof Error ? error.message : String(error),
60
+ });
61
+ return z.NEVER;
62
+ }
63
+ });
64
+
65
+ export const repositoryBindingKeyV1Schema = z
66
+ .object({
67
+ provider: providerSchema,
68
+ providerInstanceId: providerInstanceSchema,
69
+ repositoryExternalId: boundedString("repositoryExternalId", REPOSITORY_BINDING_ID_MAX_BYTES_V1),
70
+ bindingId: boundedString("bindingId", REPOSITORY_BINDING_ID_MAX_BYTES_V1),
71
+ })
72
+ .strict();
73
+
74
+ export type RepositoryBindingKeyV1 = z.output<typeof repositoryBindingKeyV1Schema>;
75
+ export type RepositoryBindingKeyV1Input = z.input<typeof repositoryBindingKeyV1Schema>;
76
+
77
+ export function normalizeRepositoryBindingKeyV1(
78
+ value: RepositoryBindingKeyV1Input
79
+ ): RepositoryBindingKeyV1 {
80
+ return repositoryBindingKeyV1Schema.parse(value);
81
+ }
82
+
83
+ export function repositoryBindingDigestV1(value: RepositoryBindingKeyV1Input): DigestHex {
84
+ const binding = normalizeRepositoryBindingKeyV1(value);
85
+ return digestHexSchema.parse(
86
+ sha256Hex(
87
+ canonicalPreimage({
88
+ schema: "repository-binding.v1",
89
+ provider: binding.provider,
90
+ providerInstanceId: binding.providerInstanceId,
91
+ repositoryExternalId: binding.repositoryExternalId,
92
+ bindingId: binding.bindingId,
93
+ })
94
+ )
95
+ );
96
+ }
@@ -7,6 +7,7 @@ import {
7
7
  ruleFamilyMembers,
8
8
  } from "./families.js";
9
9
  import { compileGlobalGovernanceFacts } from "../facts/index.js";
10
+ import { markPresetSourcedRules } from "../governance.js";
10
11
  import { RULES } from "./index.js";
11
12
  import { RULE_TIER } from "./tiers.js";
12
13
 
@@ -60,6 +61,41 @@ describe("rule families", () => {
60
61
  expect(compiled).toContain("styles/no-raw-typography");
61
62
  });
62
63
 
64
+ it("lets an authored family entry override preset-sourced member entries", () => {
65
+ const enabledMap = (rules: Record<string, unknown>) =>
66
+ new Map(
67
+ compileGlobalGovernanceFacts({ rules })
68
+ .filter((fact) => fact.kind === "governance_rule_config")
69
+ .map((fact) => [fact.ruleId, fact.enabled] as const)
70
+ );
71
+
72
+ // Customer default (members off, preset-sourced) + the user turning the family on.
73
+ const turnedOn = enabledMap({
74
+ ...markPresetSourcedRules({
75
+ "components/forbidden-prop-value": { enabled: false },
76
+ "components/unknown-prop": { enabled: false },
77
+ }),
78
+ "components/usage": { enabled: true, severity: "error" },
79
+ });
80
+ expect(turnedOn.get("components/forbidden-prop-value")).toBe(true);
81
+ expect(turnedOn.get("components/unknown-prop")).toBe(true);
82
+
83
+ // `fragments` preset (members on, preset-sourced) + the user turning the family off.
84
+ const turnedOff = enabledMap({
85
+ ...markPresetSourcedRules({ "styles/no-raw-color": { enabled: true, severity: "warn" } }),
86
+ "tokens/hardcoded-values": false,
87
+ });
88
+ expect(turnedOff.get("styles/no-raw-color")).toBe(false);
89
+
90
+ // An authored member entry is the more specific statement and beats the aggregate.
91
+ const memberWins = enabledMap({
92
+ "tokens/hardcoded-values": { enabled: true, severity: "warn" },
93
+ "styles/no-raw-color": false,
94
+ });
95
+ expect(memberWins.get("styles/no-raw-color")).toBe(false);
96
+ expect(memberWins.get("styles/no-raw-spacing")).toBe(true);
97
+ });
98
+
63
99
  it("expands a family and passes a concrete rule id through unchanged", () => {
64
100
  expect(ruleFamilyMembers("tokens/hardcoded-values")).toContain("styles/no-raw-typography");
65
101
  expect(ruleFamilyMembers("styles/no-raw-color")).toEqual(["styles/no-raw-color"]);
@@ -12,7 +12,7 @@
12
12
  import type { GovernanceSeverity } from "../governance.js";
13
13
  import type { FactEvidence, FactLocation } from "../facts/index.js";
14
14
  import { canonicalJson, hash64Hex } from "../facts/ids.js";
15
- import { byRuleId } from "../codes/index.js";
15
+ import { byCode, byRuleId } from "../codes/index.js";
16
16
  import { normalizeFinding, normalizeSeverity } from "../schemas/index.js";
17
17
  import type { Severity } from "../schemas/index.js";
18
18
  import type { EvidenceGrade } from "../evidence.js";
@@ -28,6 +28,11 @@ export interface MakeFindingInput {
28
28
  evidence: FactEvidence[];
29
29
  evidenceGrade?: EvidenceGrade;
30
30
  fingerprintIdentity: Record<string, unknown>;
31
+ /**
32
+ * Override the FUI code looked up from `ruleId`. Use when one rule emits a
33
+ * distinct config-level diagnostic alongside its ordinary findings.
34
+ */
35
+ code?: string;
31
36
  /**
32
37
  * Legacy identity tuple from before a fingerprint migration. When present,
33
38
  * the finding also carries `previousFingerprint` (the legacy hash) so
@@ -60,7 +65,7 @@ export function makeFinding(input: MakeFindingInput): Finding {
60
65
  const previousFingerprint = input.previousFingerprintIdentity
61
66
  ? hash64Hex(canonicalJson({ ruleId: input.ruleId, ...input.previousFingerprintIdentity }))
62
67
  : undefined;
63
- const code = byRuleId.get(input.ruleId);
68
+ const code = input.code ? byCode.get(input.code) : byRuleId.get(input.ruleId);
64
69
  return normalizeFinding({
65
70
  ruleId: input.ruleId,
66
71
  ruleVersion: input.ruleVersion,
@@ -38,6 +38,7 @@ import { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
38
38
  import { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
39
39
  import { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
40
40
  import { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
41
+ import { readRuleConfig } from "./rule-config.js";
41
42
  import type { Finding } from "./types.js";
42
43
 
43
44
  export type RuleFn = (ix: FactIndex) => Finding[];
@@ -183,8 +184,15 @@ export const RULES: readonly Rule[] = [
183
184
  * fingerprint)` so output is stable across runs.
184
185
  */
185
186
  export function runRules(ix: FactIndex): Finding[] {
187
+ // Enablement is read from the `governance_rule_config` facts so that every
188
+ // evaluator running the rules over the same index (engine scan, kernel
189
+ // facts mode) agrees on which rules exist. A disabled rule costs nothing.
190
+ const { disabled } = readRuleConfig(ix);
186
191
  const findings: Finding[] = [];
187
- for (const rule of RULES) findings.push(...rule.run(ix));
192
+ for (const rule of RULES) {
193
+ if (disabled.has(rule.id)) continue;
194
+ findings.push(...rule.run(ix));
195
+ }
188
196
  return sortFindings(findings);
189
197
  }
190
198
 
@@ -211,6 +219,12 @@ export { makeFinding } from "./finding.js";
211
219
  // Blocking deny-set (Contract Mode Phase 0): the shared "may this finding
212
220
  // hard-deny a write" criteria, used by the CLI pre-write hook and the emit gate.
213
221
  export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./emit-gate.js";
222
+ export {
223
+ capConfiguredSeverity,
224
+ configuredFindingSeverity,
225
+ readRuleConfig,
226
+ type RuleConfigView,
227
+ } from "./rule-config.js";
214
228
  export { ruleComponentsUnknownProp } from "./components-unknown-prop.js";
215
229
  export { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
216
230
  export { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
@@ -232,6 +246,8 @@ export { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js
232
246
  export {
233
247
  ruleTokensCssVarsMustBeDefined,
234
248
  contractTokenReferenceCensus,
249
+ EMPTY_VOCABULARY_MESSAGE,
250
+ emptyVocabularyConfigFile,
235
251
  } from "./tokens-css-vars-must-be-defined.js";
236
252
  export type { ContractTokenReferenceCensus } from "./tokens-css-vars-must-be-defined.js";
237
253
  export { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { FactIndex, makeGovernanceRuleConfigFact } from "../facts/index.js";
4
+ import { capConfiguredSeverity, configuredFindingSeverity, readRuleConfig } from "./rule-config.js";
5
+
6
+ describe("readRuleConfig", () => {
7
+ it("splits rule-config facts into disabled ids and configured severities", () => {
8
+ const ix = new FactIndex();
9
+ ix.add(makeGovernanceRuleConfigFact({ ruleId: "styles/no-raw-dimensions", enabled: false }));
10
+ ix.add(
11
+ makeGovernanceRuleConfigFact({
12
+ ruleId: "styles/no-raw-color",
13
+ enabled: true,
14
+ severity: "info",
15
+ })
16
+ );
17
+ ix.add(makeGovernanceRuleConfigFact({ ruleId: "components/prefer-library", enabled: true }));
18
+
19
+ const view = readRuleConfig(ix);
20
+ expect([...view.disabled]).toEqual(["styles/no-raw-dimensions"]);
21
+ expect([...view.severities]).toEqual([["styles/no-raw-color", "info"]]);
22
+ });
23
+ });
24
+
25
+ describe("capConfiguredSeverity", () => {
26
+ it("passes a configured severity through for confident findings", () => {
27
+ expect(capConfiguredSeverity({ severity: "minor" }, "serious")).toBe("serious");
28
+ expect(capConfiguredSeverity({ severity: "serious" }, "minor")).toBe("minor");
29
+ });
30
+
31
+ it("never promotes an advisory finding to error", () => {
32
+ const advisory = { attributes: { advisory: true } } as const;
33
+ expect(capConfiguredSeverity({ severity: "moderate", ...advisory }, "serious")).toBe(
34
+ "moderate"
35
+ );
36
+ expect(capConfiguredSeverity({ severity: "serious", ...advisory }, "critical")).toBe(
37
+ "moderate"
38
+ );
39
+ expect(capConfiguredSeverity({ severity: "serious", ...advisory }, "minor")).toBe("minor");
40
+ });
41
+ });
42
+
43
+ describe("configuredFindingSeverity", () => {
44
+ it("keeps the finding's own severity when the rule has no configured severity", () => {
45
+ const ix = new FactIndex();
46
+ const view = readRuleConfig(ix);
47
+ expect(
48
+ configuredFindingSeverity({ ruleId: "styles/no-raw-color", severity: "serious" }, view)
49
+ ).toBe("serious");
50
+ });
51
+
52
+ it("maps the configured level onto the finding", () => {
53
+ const ix = new FactIndex();
54
+ ix.add(
55
+ makeGovernanceRuleConfigFact({
56
+ ruleId: "styles/no-raw-color",
57
+ enabled: true,
58
+ severity: "warn",
59
+ })
60
+ );
61
+ const view = readRuleConfig(ix);
62
+ expect(
63
+ configuredFindingSeverity({ ruleId: "styles/no-raw-color", severity: "serious" }, view)
64
+ ).toBe("moderate");
65
+ });
66
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Rule enablement and configured severity, read from the fact index.
3
+ *
4
+ * `govern.rules` is compiled into one `governance_rule_config` fact per rule
5
+ * (`compileRuleConfigFacts`). Every evaluator that runs the rules — the engine
6
+ * scan and the Evaluation kernel's facts mode — reads the SAME facts here, so
7
+ * "which rules exist and how loud they are" cannot diverge between the
8
+ * findings a scan prints and the verdict that gates `--ci` / a Check Run.
9
+ *
10
+ * Before this module the kernel re-ran every rule over the fact index and
11
+ * ignored the config facts entirely: a rule disabled in `fragments.config.ts`
12
+ * still blocked under `--ci`, and the CLI printed zero gating findings for the
13
+ * block (the docs app: 16 `styles/no-raw-dimensions` blocks from a rule the
14
+ * config turned off).
15
+ *
16
+ * Cost: one pass over the rule-config facts per run (tens of facts). A
17
+ * disabled rule is never executed, so the change removes work rather than
18
+ * adding any.
19
+ */
20
+ import type { FactIndex } from "../facts/index.js";
21
+ import type { GovernanceSeverity } from "../governance.js";
22
+ import { severityFromLevel, severityLevel, type Severity } from "../severity.js";
23
+ import type { Finding } from "./types.js";
24
+
25
+ export interface RuleConfigView {
26
+ /** Rule ids whose `governance_rule_config` fact says `enabled: false`. */
27
+ disabled: ReadonlySet<string>;
28
+ /** Configured severity per enabled rule id (absent = the rule's own default). */
29
+ severities: ReadonlyMap<string, GovernanceSeverity>;
30
+ }
31
+
32
+ export function readRuleConfig(ix: FactIndex): RuleConfigView {
33
+ const disabled = new Set<string>();
34
+ const severities = new Map<string, GovernanceSeverity>();
35
+ for (const fact of ix.byKind("governance_rule_config")) {
36
+ if (!fact.enabled) {
37
+ disabled.add(fact.ruleId);
38
+ continue;
39
+ }
40
+ if (fact.severity) severities.set(fact.ruleId, fact.severity);
41
+ }
42
+ return { disabled, severities };
43
+ }
44
+
45
+ /**
46
+ * Apply a configured severity to a finding without erasing the rule's own
47
+ * confidence boundary: advisory findings (the low-confidence precision tiers a
48
+ * rule flags with `attributes.advisory`) are useful signal but never
49
+ * CI-blocking evidence, so a configured `error` caps at the finding's own
50
+ * severity (or `warn` when the rule itself emitted an error-level face).
51
+ */
52
+ export function capConfiguredSeverity(
53
+ finding: Pick<Finding, "severity" | "attributes">,
54
+ configured: Severity
55
+ ): Severity {
56
+ if (finding.attributes?.advisory === true && severityLevel(configured) === "error") {
57
+ return severityLevel(finding.severity) === "error"
58
+ ? severityFromLevel("warn")
59
+ : finding.severity;
60
+ }
61
+ return configured;
62
+ }
63
+
64
+ /** The severity a finding carries once its rule's configured severity is applied. */
65
+ export function configuredFindingSeverity(
66
+ finding: Pick<Finding, "ruleId" | "severity" | "attributes">,
67
+ config: RuleConfigView
68
+ ): Severity {
69
+ const configured = config.severities.get(finding.ruleId);
70
+ return configured
71
+ ? capConfiguredSeverity(finding, severityFromLevel(configured))
72
+ : finding.severity;
73
+ }