@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.
- package/dist/{chunk-MZ4SW3TP.js → chunk-3IOWHECM.js} +8 -1
- package/dist/{chunk-MZ4SW3TP.js.map → chunk-3IOWHECM.js.map} +1 -1
- package/dist/chunk-7ULGH74M.js +66 -0
- package/dist/chunk-7ULGH74M.js.map +1 -0
- package/dist/{chunk-XN3LSDPY.js → chunk-BMPYIUZE.js} +612 -344
- package/dist/chunk-BMPYIUZE.js.map +1 -0
- package/dist/{chunk-YF65VYRY.js → chunk-ML5S6QNU.js} +191 -169
- package/dist/chunk-ML5S6QNU.js.map +1 -0
- package/dist/{chunk-3LLRNCPX.js → chunk-MZ2FS7U4.js} +1 -1
- package/dist/chunk-MZ2FS7U4.js.map +1 -0
- package/dist/chunk-PWIJMOI4.js +202 -0
- package/dist/chunk-PWIJMOI4.js.map +1 -0
- package/dist/chunk-RYFULE43.js +578 -0
- package/dist/chunk-RYFULE43.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +3 -2
- package/dist/compiled-types/index.d.ts +327 -2
- package/dist/compiled-types/index.js +1 -1
- package/dist/generate/index.d.ts +3 -2
- package/dist/{governance-CLk_wkP9.d.ts → governance-hOPXGbbs.d.ts} +474 -515
- package/dist/governance-telemetry.d.ts +6 -0
- package/dist/governance-telemetry.js +1 -1
- package/dist/{index-_sxhUNqx.d.ts → index-C8bcXVav.d.ts} +448 -448
- package/dist/index.d.ts +3280 -1305
- package/dist/index.js +2414 -350
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +228 -0
- package/dist/manifest.js +24 -0
- package/dist/manifest.js.map +1 -0
- package/dist/preview/index.js +45 -1
- package/dist/preview/index.js.map +1 -1
- package/dist/preview-runtime.d.ts +1 -2
- package/dist/preview-runtime.js +150 -14
- package/dist/preview-runtime.js.map +1 -1
- package/dist/react-types.d.ts +1 -2
- package/dist/registry.d.ts +1412 -203
- package/dist/registry.js +30 -3
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +3 -2
- package/dist/storyAdapter.d.ts +1 -2
- package/dist/storyAdapter.js +11 -49
- package/dist/storyAdapter.js.map +1 -1
- package/dist/test-utils.d.ts +3 -2
- package/dist/topology/index.d.ts +1 -1
- package/dist/topology/index.js +1 -1
- package/package.json +8 -2
- package/src/__tests__/contract-parser.test.ts +318 -277
- package/src/__tests__/preview-runtime-hook.test.tsx +315 -0
- package/src/__tests__/preview-runtime.test.tsx +30 -8
- package/src/__tests__/schema.test.ts +191 -14
- package/src/analysis-plan/analysis-plan-v1.test.ts +320 -0
- package/src/analysis-plan/coverage.ts +181 -0
- package/src/analysis-plan/digest.ts +141 -0
- package/src/analysis-plan/index.ts +34 -0
- package/src/analysis-plan/types.ts +207 -0
- package/src/approved-contract-tokens.test.ts +39 -0
- package/src/approved-contract-tokens.ts +18 -0
- package/src/codes/__tests__/codes.test.ts +13 -0
- package/src/codes/codes.ts +40 -0
- package/src/compiled-types/index.ts +640 -39
- package/src/compiled-types/parse.test.ts +145 -4
- package/src/component-contract.ts +95 -53
- package/src/composition.ts +7 -13
- package/src/constants.ts +3 -6
- package/src/contract/hash.test.ts +20 -0
- package/src/contract/hash.ts +66 -9
- package/src/contract/index.ts +24 -1
- package/src/contract/manifest.test.ts +94 -0
- package/src/contract/manifest.ts +68 -0
- package/src/contract/preimage.test.ts +219 -1
- package/src/contract/preimage.ts +326 -6
- package/src/contract/stamp.test.ts +3 -0
- package/src/contract/stamp.ts +1 -1
- package/src/contract-parser.ts +54 -30
- package/src/defineFragment.test.ts +476 -91
- package/src/defineFragment.ts +204 -114
- package/src/domain-ids.test.ts +35 -0
- package/src/domain-ids.ts +61 -0
- package/src/evaluation/evaluate.test.ts +522 -0
- package/src/evaluation/evaluate.ts +690 -0
- package/src/evaluation/evaluation-v2-receipt-v1.test.ts +772 -0
- package/src/evaluation/index.ts +58 -0
- package/src/evaluation/receipt.ts +753 -0
- package/src/evaluation/types.ts +406 -0
- package/src/facts/builders.ts +2 -0
- package/src/facts/compile.ts +29 -6
- package/src/facts/fact-index.ts +13 -3
- package/src/facts/fact-integrity-v1.test.ts +172 -0
- package/src/facts/facts.test.ts +15 -0
- package/src/facts/ids.ts +46 -3
- package/src/facts/index.ts +14 -1
- package/src/facts/integrity.ts +134 -0
- package/src/facts/types.ts +36 -0
- package/src/governance-integrity.test.ts +1 -0
- package/src/governance-integrity.ts +5 -3
- package/src/governance-telemetry.ts +8 -0
- package/src/governance.ts +70 -8
- package/src/index.ts +230 -37
- package/src/preview/validation.test.ts +62 -0
- package/src/preview/validation.ts +48 -2
- package/src/preview-runtime.tsx +227 -20
- package/src/registry-install-plan.ts +200 -109
- package/src/registry-shards.test.ts +263 -0
- package/src/registry.ts +237 -0
- package/src/repository-binding.test.ts +50 -0
- package/src/repository-binding.ts +96 -0
- package/src/rules/families.test.ts +36 -0
- package/src/rules/finding.ts +7 -2
- package/src/rules/index.ts +17 -1
- package/src/rules/rule-config.test.ts +66 -0
- package/src/rules/rule-config.ts +73 -0
- package/src/rules/rules.test.ts +26 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +51 -2
- package/src/rules/tokens-css-vars-must-be-defined.ts +34 -1
- package/src/schema.ts +293 -113
- package/src/schemas/index.ts +1 -1
- package/src/storyAdapter.test.ts +68 -12
- package/src/storyAdapter.ts +44 -75
- package/src/topology/resolve-area.ts +1 -1
- package/src/types.ts +258 -40
- package/dist/chunk-3LLRNCPX.js.map +0 -1
- package/dist/chunk-RANPUC6C.js +0 -72
- package/dist/chunk-RANPUC6C.js.map +0 -1
- package/dist/chunk-XN3LSDPY.js.map +0 -1
- package/dist/chunk-YF65VYRY.js.map +0 -1
- package/src/fragment-types.ts +0 -214
- package/src/react-create-element.test.ts +0 -22
- package/src/react-create-element.ts +0 -12
- package/src/storyFilters.test.ts +0 -350
- 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"]);
|
package/src/rules/finding.ts
CHANGED
|
@@ -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,
|
package/src/rules/index.ts
CHANGED
|
@@ -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)
|
|
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
|
+
}
|