@usefragments/core 2.0.2 → 2.1.1

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.
@@ -1,6 +1,6 @@
1
1
  import { ReactNode, ComponentType } from 'react';
2
2
  import { isExportStory as isExportStory$1, storyNameFromExport as storyNameFromExport$1, toId as toId$1 } from '@storybook/csf';
3
- import { F as FragmentDefinition } from './governance-hOPXGbbs.js';
3
+ import { F as FragmentDefinition } from './source-identity-DvOBz2yJ.js';
4
4
  import 'zod';
5
5
  import './topology/index.js';
6
6
 
@@ -1,6 +1,6 @@
1
1
  import { CompiledBlock, CompiledFragment } from './compiled-types/index.js';
2
2
  import './types-xJ2xyp_G.js';
3
- import './governance-hOPXGbbs.js';
3
+ import './source-identity-DvOBz2yJ.js';
4
4
  import 'zod';
5
5
  import 'react';
6
6
  import './topology/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
package/src/conform.ts CHANGED
@@ -11,6 +11,11 @@
11
11
  * surface agree on one shape without duplication.
12
12
  */
13
13
 
14
+ import type {
15
+ ContractComponentSource,
16
+ ContractComponentExport,
17
+ } from "./contract/source-identity.js";
18
+
14
19
  export type ConformConfidence = "high" | "medium" | "low";
15
20
 
16
21
  export interface ConformInput {
@@ -82,6 +87,9 @@ export interface ConformPropMapping {
82
87
  }
83
88
 
84
89
  export interface ConformComponentMapping {
90
+ /** Portable authority, interpreted only under the context's generation marker. */
91
+ source?: ContractComponentSource;
92
+ exportAddresses?: readonly ContractComponentExport[];
85
93
  /**
86
94
  * Canonical primitive name, e.g. "Button". This is the JSX identifier the
87
95
  * conform engine rewrites toward.
@@ -158,6 +166,8 @@ export interface ConformToken {
158
166
  * each surface (cloud from Convex, CLI from local config, MCP from a bundle).
159
167
  */
160
168
  export interface DesignSystemContext {
169
+ /** Additive generation boundary; unmarked contexts retain legacy behavior. */
170
+ sourceIdentitySchema?: "component-source:v1";
161
171
  designSystemName?: string;
162
172
  tokens: ConformToken[];
163
173
  /** Confirmed canonical component replacements for this tenant. */
@@ -12,6 +12,10 @@ export {
12
12
  CONTRACT_ENFORCEMENT_FIELDS,
13
13
  CONTRACT_PREIMAGE_CAPABILITY_HEADER,
14
14
  CONTRACT_PREIMAGE_SCHEMA,
15
+ CONTRACT_PREIMAGE_SCHEMA_V2,
16
+ CONTRACT_PREIMAGE_CAPABILITIES,
17
+ SUPPORTED_CONTRACT_PREIMAGE_SCHEMAS,
18
+ isSupportedContractPreimageSchema,
15
19
  ContractCatalogValidationError,
16
20
  contractComponentsFromFragments,
17
21
  contractPolicyFromGovernanceConfig,
@@ -31,6 +35,7 @@ export type {
31
35
  ContractIdentityPin,
32
36
  ContractPolicyInput,
33
37
  ContractPreimage,
38
+ ContractPreimageSchema,
34
39
  ContractPropMappingInput,
35
40
  ContractTokenInput,
36
41
  ContractWaiverInput,
@@ -67,3 +72,22 @@ export type {
67
72
  FragmentsManifestPattern,
68
73
  FragmentsManifestPrimitive,
69
74
  } from "./manifest.js";
75
+
76
+ export {
77
+ contractComponentSourceKey,
78
+ approvedComponentGovernanceByteLimit,
79
+ APPROVED_COMPONENT_GOVERNANCE_LEGACY_MAX_BYTES,
80
+ APPROVED_COMPONENT_GOVERNANCE_QUALIFIED_MAX_BYTES,
81
+ contractSourceFromFragment,
82
+ normalizeContractComponentSource,
83
+ normalizeContractSourcePath,
84
+ contractComponentsFromFragmentsV2,
85
+ projectContractPreimageV2,
86
+ } from "./source-identity.js";
87
+ export type {
88
+ ContractComponentSource,
89
+ ContractComponentExport,
90
+ ContractComponentInputV2,
91
+ ContractCanonicalMappingInputV2,
92
+ ContractCatalogInputV2,
93
+ } from "./source-identity.js";
@@ -24,6 +24,7 @@
24
24
  *
25
25
  * Runtime-portable by construction: no Node APIs, no Convex imports, no React.
26
26
  */
27
+ import type { ContractComponentSource } from "./source-identity.js";
27
28
  import type { ComponentGovernanceRecord, GovernanceConfig } from "../governance.js";
28
29
  import { resolveGovernanceRecordsForIdentity } from "../governance.js";
29
30
  import { projectV1OwnedComponentId, projectV1OwnedImportIdentity } from "../package-identity.js";
@@ -110,6 +111,8 @@ export interface ContractPropMappingInput {
110
111
  }
111
112
 
112
113
  export interface ContractCanonicalMappingInput {
114
+ /** Source-qualified identity, required by the V2 projection; ignored by immutable V1. */
115
+ source?: ContractComponentSource;
113
116
  /** The raw/source component the mapping covers. */
114
117
  component: string;
115
118
  /** The canonical component it maps to. */
@@ -190,6 +193,17 @@ export class ContractCatalogValidationError extends TypeError {
190
193
  // ---------------------------------------------------------------------------
191
194
 
192
195
  export const CONTRACT_PREIMAGE_SCHEMA = "fcid-preimage:v1" as const;
196
+ export const CONTRACT_PREIMAGE_SCHEMA_V2 = "fcid-preimage:v2" as const;
197
+ export const SUPPORTED_CONTRACT_PREIMAGE_SCHEMAS = [
198
+ CONTRACT_PREIMAGE_SCHEMA_V2,
199
+ CONTRACT_PREIMAGE_SCHEMA,
200
+ ] as const;
201
+ export type ContractPreimageSchema = (typeof SUPPORTED_CONTRACT_PREIMAGE_SCHEMAS)[number];
202
+ export const CONTRACT_PREIMAGE_CAPABILITIES = SUPPORTED_CONTRACT_PREIMAGE_SCHEMAS.join(", ");
203
+
204
+ export function isSupportedContractPreimageSchema(value: unknown): value is ContractPreimageSchema {
205
+ return value === CONTRACT_PREIMAGE_SCHEMA || value === CONTRACT_PREIMAGE_SCHEMA_V2;
206
+ }
193
207
  /** Request header used by clients that can verify and consume this preimage. */
194
208
  export const CONTRACT_PREIMAGE_CAPABILITY_HEADER = "X-Fragments-Contract-Preimage" as const;
195
209
 
@@ -199,7 +213,7 @@ export const CONTRACT_PREIMAGE_CAPABILITY_HEADER = "X-Fragments-Contract-Preimag
199
213
  * `contractHash(preimage)` — hash of the domain hashes, per the spine pipeline.
200
214
  */
201
215
  export interface ContractPreimage {
202
- schema: typeof CONTRACT_PREIMAGE_SCHEMA;
216
+ schema: ContractPreimageSchema;
203
217
  domains: Record<ContractDomain, string>;
204
218
  }
205
219
 
@@ -226,7 +240,7 @@ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u;
226
240
  */
227
241
  export function verifiedContractPreimageFromPin(pin: ContractIdentityPin): ContractPreimage | null {
228
242
  if (
229
- pin.preimageSchema !== CONTRACT_PREIMAGE_SCHEMA ||
243
+ !isSupportedContractPreimageSchema(pin.preimageSchema) ||
230
244
  !SHA256_HEX_PATTERN.test(pin.contractHash) ||
231
245
  !pin.domainHashes ||
232
246
  typeof pin.domainHashes !== "object"
@@ -251,7 +265,7 @@ export function verifiedContractPreimageFromPin(pin: ContractIdentityPin): Contr
251
265
  }
252
266
 
253
267
  const preimage: ContractPreimage = {
254
- schema: CONTRACT_PREIMAGE_SCHEMA,
268
+ schema: pin.preimageSchema,
255
269
  domains: domains as Record<ContractDomain, string>,
256
270
  };
257
271
  return contractHash(preimage) === pin.contractHash ? preimage : null;
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { contractHash } from "./hash.js";
3
+ import { catalogFixtureA, CATALOG_FIXTURE_A_FCID } from "./fixture.js";
4
+ import {
5
+ CONTRACT_PREIMAGE_SCHEMA_V2,
6
+ projectContractPreimage,
7
+ verifiedContractPreimageFromPin,
8
+ } from "./preimage.js";
9
+ import {
10
+ approvedComponentGovernanceByteLimit,
11
+ contractComponentsFromFragmentsV2,
12
+ contractComponentSourceKey,
13
+ projectContractPreimageV2,
14
+ } from "./source-identity.js";
15
+
16
+ const fragments = [
17
+ {
18
+ meta: { name: "Button" },
19
+ filePath: "first/Button.tsx",
20
+ props: { label: { required: true } },
21
+ exports: [{ exportedAs: "Button", filePath: "first/index.ts" }],
22
+ },
23
+ {
24
+ meta: { name: "Button" },
25
+ filePath: "second/Button.tsx",
26
+ props: { href: { required: true } },
27
+ exports: [{ exportedAs: "Button", filePath: "second/index.ts" }],
28
+ },
29
+ ];
30
+ const components = () => contractComponentsFromFragmentsV2(fragments);
31
+
32
+ describe("source-qualified contract identity", () => {
33
+ it("retains both same-name components and is independent of collection order", () => {
34
+ const projected = projectContractPreimageV2({ components: components() });
35
+ expect(projected.schema).toBe(CONTRACT_PREIMAGE_SCHEMA_V2);
36
+ expect(projected).toEqual(projectContractPreimageV2({ components: components().reverse() }));
37
+ expect(contractHash(projected)).not.toBe(
38
+ contractHash(projectContractPreimageV2({ components: components().slice(0, 1) }))
39
+ );
40
+ expect(
41
+ new Set(components().map((entry) => contractComponentSourceKey(entry.source))).size
42
+ ).toBe(2);
43
+ });
44
+ it("leaves every V1 fixture byte and duplicate guard intact", () => {
45
+ expect(contractHash(projectContractPreimage(catalogFixtureA))).toBe(CATALOG_FIXTURE_A_FCID);
46
+ expect(() => projectContractPreimage({ components: components() })).toThrow(/duplicate/);
47
+ });
48
+ it("hashes source and exported module provenance", () => {
49
+ const baseline = projectContractPreimageV2({ components: components() });
50
+ const moved = components();
51
+ moved[0]!.source = { kind: "repository", path: "third/Button.tsx", symbol: "Button" };
52
+ expect(projectContractPreimageV2({ components: moved })).not.toEqual(baseline);
53
+ const reexported = components();
54
+ reexported[0]!.exports = [{ kind: "repository", path: "third/index.ts", exportName: "Button" }];
55
+ expect(projectContractPreimageV2({ components: reexported })).not.toEqual(baseline);
56
+ });
57
+ it("rejects duplicate definitions and competing owners of one export", () => {
58
+ expect(() =>
59
+ projectContractPreimageV2({ components: [components()[0]!, components()[0]!] })
60
+ ).toThrow(/duplicate source identity/);
61
+ const conflicting = components();
62
+ conflicting[1]!.exports = conflicting[0]!.exports;
63
+ expect(() => projectContractPreimageV2({ components: conflicting })).toThrow(
64
+ /conflicting owners/
65
+ );
66
+ });
67
+ it("qualifies same-name canonical mappings and rejects source ambiguity", () => {
68
+ const mappings = components().map((entry) => ({
69
+ component: "Button",
70
+ source: entry.source,
71
+ canonical: "Button",
72
+ status: "confirmed" as const,
73
+ }));
74
+ const projected = projectContractPreimageV2({
75
+ components: components(),
76
+ canonicalMappings: mappings,
77
+ });
78
+ expect(projected).toEqual(
79
+ projectContractPreimageV2({ components: components(), canonicalMappings: mappings.reverse() })
80
+ );
81
+ expect(() =>
82
+ projectContractPreimageV2({ canonicalMappings: [mappings[0]!, mappings[0]!] })
83
+ ).toThrow(/duplicate confirmed source/);
84
+ expect(() =>
85
+ projectContractPreimageV2({
86
+ canonicalMappings: [
87
+ { component: "Button", canonical: "Button", status: "confirmed" } as never,
88
+ ],
89
+ })
90
+ ).toThrow(/source/);
91
+ });
92
+ it.each([
93
+ "/checkout/Button.tsx",
94
+ "../Button.tsx",
95
+ "C:\\repo\\Button.tsx",
96
+ "src/../../Button.tsx",
97
+ ])("rejects non-portable source %s", (path) => {
98
+ expect(() =>
99
+ contractComponentsFromFragmentsV2([{ meta: { name: "Button" }, filePath: path }])
100
+ ).toThrow(/source/);
101
+ });
102
+ it("normalizes equivalent relative paths without case-folding distinct files", () => {
103
+ const key = (path: string) =>
104
+ contractComponentSourceKey({ kind: "repository", path, symbol: "Button" });
105
+ expect(key("./src/ui/../Button.tsx")).toBe(key("src\\Button.tsx"));
106
+ expect(key("src/Button.tsx")).not.toBe(key("src/button.tsx"));
107
+ });
108
+ it("verifies V2 pins and refuses unknown or mismatched generations", () => {
109
+ const preimage = projectContractPreimageV2({ components: components() });
110
+ const pin = {
111
+ contractHash: contractHash(preimage),
112
+ preimageSchema: preimage.schema,
113
+ domainHashes: preimage.domains,
114
+ };
115
+ expect(verifiedContractPreimageFromPin(pin)).toEqual(preimage);
116
+ expect(
117
+ verifiedContractPreimageFromPin({ ...pin, preimageSchema: "fcid-preimage:v1" })
118
+ ).toBeNull();
119
+ expect(
120
+ verifiedContractPreimageFromPin({ ...pin, preimageSchema: "fcid-preimage:v99" })
121
+ ).toBeNull();
122
+ });
123
+ });
124
+
125
+ it.each([
126
+ "/checkout/private/Button.tsx",
127
+ "../Button.tsx",
128
+ "file:///Users/person/Button.tsx",
129
+ "@scope",
130
+ "@scope/pkg/../secret",
131
+ "pkg//Button",
132
+ "pkg?query",
133
+ "pkg\\Button",
134
+ ])("rejects a non-package address %s in sources and exports", (importPath) => {
135
+ expect(() =>
136
+ contractComponentSourceKey({ kind: "package", importPath, exportName: "Button" })
137
+ ).toThrow(/public package import/);
138
+ expect(() =>
139
+ projectContractPreimageV2({
140
+ components: [
141
+ { ...components()[0]!, exports: [{ kind: "package", importPath, exportName: "Button" }] },
142
+ ],
143
+ })
144
+ ).toThrow(/public package import/);
145
+ });
146
+
147
+ it("accepts package subpaths and canonicalizes the owned package address", () => {
148
+ const key = (importPath: string) =>
149
+ contractComponentSourceKey({ kind: "package", importPath, exportName: "Button" });
150
+ expect(key("@mui/material/Button")).toContain("@mui/material/Button");
151
+ expect(key("@fragments-sdk/ui/button")).toBe(key("@usefragments/ui/button"));
152
+ });
153
+
154
+ it.each([
155
+ ["fcid-preimage:v1", undefined, 512 * 1024],
156
+ ["fcid-preimage:v2", "component-source:v1", 6 * 1024 * 1024],
157
+ ["fcid-preimage:v1", "component-source:v1", null],
158
+ ["fcid-preimage:v2", undefined, null],
159
+ ["fcid-preimage:v2", "component-source:v2", null],
160
+ ["fcid-preimage:v3", "component-source:v1", null],
161
+ [null, null, null],
162
+ ])(
163
+ "selects approved envelope capacity only for consistent generations (%s / %s)",
164
+ (schema, marker, limit) => {
165
+ expect(approvedComponentGovernanceByteLimit(schema, marker)).toBe(limit);
166
+ }
167
+ );
@@ -0,0 +1,303 @@
1
+ import { canonicalizeOwnedImport } from "../package-identity.js";
2
+ import { canonicalPreimage, contractHash } from "./hash.js";
3
+ import {
4
+ CONTRACT_PREIMAGE_SCHEMA,
5
+ CONTRACT_PREIMAGE_SCHEMA_V2,
6
+ ContractCatalogValidationError,
7
+ contractComponentsFromFragments,
8
+ projectContractPreimage,
9
+ type ContractCanonicalMappingInput,
10
+ type ContractCatalogInput,
11
+ type ContractComponentInput,
12
+ type ContractPreimage,
13
+ } from "./preimage.js";
14
+
15
+ export const APPROVED_COMPONENT_GOVERNANCE_LEGACY_MAX_BYTES = 512 * 1024;
16
+ export const APPROVED_COMPONENT_GOVERNANCE_QUALIFIED_MAX_BYTES = 6 * 1024 * 1024;
17
+
18
+ /** Expanded portable capacity requires the enclosing FCID and component generation to agree. */
19
+ export function approvedComponentGovernanceByteLimit(
20
+ expectedPreimageSchema: unknown,
21
+ sourceIdentitySchema: unknown
22
+ ): number | null {
23
+ if (expectedPreimageSchema === CONTRACT_PREIMAGE_SCHEMA && sourceIdentitySchema === undefined) {
24
+ return APPROVED_COMPONENT_GOVERNANCE_LEGACY_MAX_BYTES;
25
+ }
26
+ if (
27
+ expectedPreimageSchema === CONTRACT_PREIMAGE_SCHEMA_V2 &&
28
+ sourceIdentitySchema === "component-source:v1"
29
+ ) {
30
+ return APPROVED_COMPONENT_GOVERNANCE_QUALIFIED_MAX_BYTES;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /** A portable address; authorization remains pinned to the enclosing contract. */
36
+ export type ContractComponentSource =
37
+ | { kind: "repository"; path: string; symbol: string }
38
+ | { kind: "package"; importPath: string; exportName: string };
39
+
40
+ export type ContractComponentExport =
41
+ | { kind: "repository"; path: string; exportName: string }
42
+ | { kind: "package"; importPath: string; exportName: string };
43
+
44
+ export interface ContractComponentInputV2 extends ContractComponentInput {
45
+ source: ContractComponentSource;
46
+ exports?: readonly ContractComponentExport[];
47
+ }
48
+
49
+ export interface ContractCanonicalMappingInputV2 extends ContractCanonicalMappingInput {
50
+ source: ContractComponentSource;
51
+ }
52
+
53
+ export interface ContractCatalogInputV2 extends Omit<
54
+ ContractCatalogInput,
55
+ "components" | "canonicalMappings"
56
+ > {
57
+ components?: readonly ContractComponentInputV2[];
58
+ canonicalMappings?: readonly ContractCanonicalMappingInputV2[];
59
+ }
60
+
61
+ export function normalizeContractSourcePath(value: string): string {
62
+ const path = value.trim().replace(/\\/gu, "/");
63
+ if (!path || path.startsWith("/") || /^[a-z]:/iu.test(path) || path.includes("\0")) {
64
+ throw new ContractCatalogValidationError(["source: expected a repository-relative path"]);
65
+ }
66
+ const segments: string[] = [];
67
+ for (const part of path.split("/")) {
68
+ if (!part || part === ".") continue;
69
+ if (part === "..") {
70
+ if (!segments.length) {
71
+ throw new ContractCatalogValidationError(["source: path escapes the repository"]);
72
+ }
73
+ segments.pop();
74
+ } else segments.push(part);
75
+ }
76
+ if (!segments.length) {
77
+ throw new ContractCatalogValidationError(["source: expected a component file"]);
78
+ }
79
+ return segments.join("/");
80
+ }
81
+
82
+ function requiredString(value: unknown, field: string): string {
83
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
84
+ throw new ContractCatalogValidationError([`${field}: expected a non-empty string`]);
85
+ }
86
+ return value.trim();
87
+ }
88
+
89
+ function normalizePublicPackageImport(value: unknown): string {
90
+ const specifier = requiredString(value, "source.importPath");
91
+ const parts = specifier.split("/");
92
+ const rootLength = specifier.startsWith("@") ? 2 : 1;
93
+ if (
94
+ parts.length < rootLength ||
95
+ parts.some((part, index) => {
96
+ const name = index === 0 && rootLength === 2 ? part.slice(1) : part;
97
+ return !name || name === "." || name === ".." || !/^[a-z0-9_~.-]+$/iu.test(name);
98
+ }) ||
99
+ parts[0]!.startsWith(".")
100
+ ) {
101
+ throw new ContractCatalogValidationError([
102
+ "source.importPath: expected a public package import or subpath",
103
+ ]);
104
+ }
105
+ return canonicalizeOwnedImport(specifier);
106
+ }
107
+
108
+ export function normalizeContractComponentSource(
109
+ value: ContractComponentSource
110
+ ): ContractComponentSource {
111
+ if (value?.kind === "repository") {
112
+ return {
113
+ kind: "repository",
114
+ path: normalizeContractSourcePath(requiredString(value.path, "source.path")),
115
+ symbol: requiredString(value.symbol, "source.symbol"),
116
+ };
117
+ }
118
+ if (value?.kind === "package") {
119
+ return {
120
+ kind: "package",
121
+ importPath: normalizePublicPackageImport(value.importPath),
122
+ exportName: requiredString(value.exportName, "source.exportName"),
123
+ };
124
+ }
125
+ throw new ContractCatalogValidationError(["source: expected repository or package identity"]);
126
+ }
127
+
128
+ export function contractComponentSourceKey(source: ContractComponentSource): string {
129
+ return canonicalPreimage(normalizeContractComponentSource(source));
130
+ }
131
+
132
+ interface ComponentGuidance {
133
+ name: string;
134
+ source?: ContractComponentSource;
135
+ exportAddresses?: readonly ContractComponentExport[];
136
+ }
137
+
138
+ /** Public addresses proven by the exact component source, never package display metadata. */
139
+ export function contractComponentExportAddresses(
140
+ component: ComponentGuidance
141
+ ): Array<{ importPath: string; exportName: string }> {
142
+ const addresses =
143
+ component.source?.kind === "package"
144
+ ? [component.source]
145
+ : (component.exportAddresses ?? []).filter(
146
+ (address): address is Extract<ContractComponentExport, { kind: "package" }> =>
147
+ address.kind === "package"
148
+ );
149
+ return [
150
+ ...new Map(
151
+ addresses.map((address) => [
152
+ contractComponentSourceKey(address),
153
+ { importPath: canonicalizeOwnedImport(address.importPath), exportName: address.exportName },
154
+ ])
155
+ ).values(),
156
+ ];
157
+ }
158
+
159
+ /** Existing replacement syntax supports a unique named JSX component binding. */
160
+ export function contractComponentReplacementImport(
161
+ component: ComponentGuidance
162
+ ): { importPath: string; exportName: string } | undefined {
163
+ const addresses = contractComponentExportAddresses(component);
164
+ const named = addresses.filter((address) => address.exportName === component.name);
165
+ const usable = (name: string) => /^[A-Za-z_$][\w$]*$/.test(name) && !/^[a-z]/.test(name);
166
+ if (named.length === 1 && usable(named[0]!.exportName)) return named[0];
167
+ if (addresses.length === 1 && usable(addresses[0]!.exportName)) return addresses[0];
168
+ return undefined;
169
+ }
170
+
171
+ function normalizedExport(value: ContractComponentExport): ContractComponentExport {
172
+ if (value?.kind === "repository") {
173
+ return {
174
+ kind: "repository",
175
+ path: normalizeContractSourcePath(requiredString(value.path, "exports.path")),
176
+ exportName: requiredString(value.exportName, "exports.exportName"),
177
+ };
178
+ }
179
+ if (value?.kind === "package") {
180
+ return normalizeContractComponentSource(value) as ContractComponentExport;
181
+ }
182
+ throw new ContractCatalogValidationError(["exports: expected repository or package identity"]);
183
+ }
184
+
185
+ function stableOrder<T>(values: readonly T[]): T[] {
186
+ return [...values].sort((a, b) => {
187
+ const left = canonicalPreimage(a);
188
+ const right = canonicalPreimage(b);
189
+ return left < right ? -1 : left > right ? 1 : 0;
190
+ });
191
+ }
192
+
193
+ /** V1 remains immutable. V2 scopes the same enforcement projection by source. */
194
+ export function projectContractPreimageV2(catalog: ContractCatalogInputV2): ContractPreimage {
195
+ const componentsSeen = new Set<string>();
196
+ const exportOwners = new Map<string, string>();
197
+ const components = (catalog.components ?? []).map((component) => {
198
+ const source = normalizeContractComponentSource(component.source);
199
+ const key = contractComponentSourceKey(source);
200
+ if (componentsSeen.has(key)) {
201
+ throw new ContractCatalogValidationError([`components: duplicate source identity ${key}`]);
202
+ }
203
+ componentsSeen.add(key);
204
+ const exports = new Map<string, ContractComponentExport>();
205
+ for (const raw of component.exports ?? []) {
206
+ const entry = normalizedExport(raw);
207
+ const alias = canonicalPreimage(entry);
208
+ const owner = exportOwners.get(alias);
209
+ if (owner && owner !== key) {
210
+ throw new ContractCatalogValidationError([`exports: conflicting owners for ${alias}`]);
211
+ }
212
+ exportOwners.set(alias, key);
213
+ exports.set(alias, entry);
214
+ }
215
+ return {
216
+ source,
217
+ exports: stableOrder([...exports.values()]),
218
+ enforcement: projectContractPreimage({ components: [component] }).domains.components,
219
+ };
220
+ });
221
+ const mappingsSeen = new Set<string>();
222
+ const mappings = (catalog.canonicalMappings ?? []).flatMap((mapping) => {
223
+ // Validate proposed records too; their authority is still excluded by V1.
224
+ const enforcement = projectContractPreimage({ canonicalMappings: [mapping] }).domains
225
+ .canonicalMap;
226
+ const source = normalizeContractComponentSource(mapping.source);
227
+ if (mapping.status !== "confirmed") return [];
228
+ const key = contractComponentSourceKey(source);
229
+ if (mappingsSeen.has(key)) {
230
+ throw new ContractCatalogValidationError([
231
+ `canonicalMappings: duplicate confirmed source ${key}`,
232
+ ]);
233
+ }
234
+ mappingsSeen.add(key);
235
+ return [{ source, enforcement }];
236
+ });
237
+ const shared = projectContractPreimage({ tokens: catalog.tokens, policy: catalog.policy });
238
+ return {
239
+ schema: CONTRACT_PREIMAGE_SCHEMA_V2,
240
+ domains: {
241
+ ...shared.domains,
242
+ components: contractHash(stableOrder(components)),
243
+ canonicalMap: contractHash(stableOrder(mappings)),
244
+ },
245
+ };
246
+ }
247
+
248
+ /** Normalize one persisted fragment without using its display name as its address. */
249
+ export function contractSourceFromFragment(
250
+ fragment: unknown,
251
+ packageName?: string
252
+ ): ContractComponentSource {
253
+ const row = fragment as Record<string, unknown>;
254
+ if (!row || typeof row !== "object") {
255
+ throw new ContractCatalogValidationError(["component: expected an object"]);
256
+ }
257
+ if (row.source) return normalizeContractComponentSource(row.source as ContractComponentSource);
258
+ const meta = row.meta as { name?: unknown } | undefined;
259
+ const name = requiredString(row.exportName ?? meta?.name, "component export");
260
+ const path = row.sourcePath ?? row.filePath;
261
+ if (typeof path === "string" && path.trim()) {
262
+ return normalizeContractComponentSource({ kind: "repository", path, symbol: name });
263
+ }
264
+ const importPath = row.importPath ?? row.packageName ?? packageName;
265
+ return normalizeContractComponentSource({
266
+ kind: "package",
267
+ importPath: requiredString(importPath, "component source"),
268
+ exportName: name,
269
+ });
270
+ }
271
+
272
+ export function contractComponentsFromFragmentsV2(
273
+ fragments: readonly unknown[],
274
+ packageName?: string
275
+ ): ContractComponentInputV2[] {
276
+ return fragments.map((fragment) => {
277
+ const [component] = contractComponentsFromFragments([fragment]);
278
+ if (!component) throw new ContractCatalogValidationError(["component: missing name"]);
279
+ const source = contractSourceFromFragment(fragment, packageName);
280
+ const row = fragment as { exports?: unknown };
281
+ const exports: ContractComponentExport[] = [];
282
+ if (Array.isArray(row.exports)) {
283
+ for (const raw of row.exports) {
284
+ const entry = raw as { exportedAs?: unknown; filePath?: unknown; importPath?: unknown };
285
+ const exportName = requiredString(entry.exportedAs, "exports.exportedAs");
286
+ if (typeof entry.importPath === "string") {
287
+ exports.push(
288
+ normalizedExport({ kind: "package", importPath: entry.importPath, exportName })
289
+ );
290
+ } else if (source.kind === "repository") {
291
+ exports.push(
292
+ normalizedExport({
293
+ kind: "repository",
294
+ path: typeof entry.filePath === "string" ? entry.filePath : source.path,
295
+ exportName,
296
+ })
297
+ );
298
+ } else exports.push({ ...source, exportName });
299
+ }
300
+ }
301
+ return { ...component, source, exports };
302
+ });
303
+ }