@usefragments/core 2.0.1 → 2.1.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.
@@ -7,7 +7,11 @@ import {
7
7
  parseAnalysisPlanV1,
8
8
  } from "../analysis-plan/index.js";
9
9
  import { canonicalPreimage, contractHash, sha256Hex } from "../contract/hash.js";
10
- import { CONTRACT_PREIMAGE_SCHEMA, type ContractPreimage } from "../contract/preimage.js";
10
+ import {
11
+ CONTRACT_PREIMAGE_SCHEMA,
12
+ CONTRACT_PREIMAGE_SCHEMA_V2,
13
+ type ContractPreimage,
14
+ } from "../contract/preimage.js";
11
15
  import {
12
16
  digestHexStringSchema,
13
17
  evaluationReceiptIdFromDigest,
@@ -21,6 +25,7 @@ import {
21
25
  repositoryBindingDigestV1,
22
26
  repositoryBindingKeyV1Schema,
23
27
  } from "../repository-binding.js";
28
+ import { SOURCE_IDENTITY_EVALUATOR_VERSION_V2 } from "./types.js";
24
29
  import type {
25
30
  EffectiveEvaluationWaiverV2,
26
31
  EvaluationInputV2,
@@ -51,7 +56,7 @@ const unique = (values: readonly string[]): boolean => new Set(values).size ===
51
56
 
52
57
  const contractPreimageV1Schema: z.ZodType<ContractPreimage> = z
53
58
  .object({
54
- schema: z.literal(CONTRACT_PREIMAGE_SCHEMA),
59
+ schema: z.enum([CONTRACT_PREIMAGE_SCHEMA, CONTRACT_PREIMAGE_SCHEMA_V2]),
55
60
  domains: z
56
61
  .object({
57
62
  components: digestHexStringSchema,
@@ -205,6 +210,16 @@ const evaluationInputV2RawSchema = z
205
210
  })
206
211
  .strict()
207
212
  .superRefine((input, context) => {
213
+ if (
214
+ input.contract.preimage.schema === CONTRACT_PREIMAGE_SCHEMA_V2 &&
215
+ input.evaluatorVersion !== SOURCE_IDENTITY_EVALUATOR_VERSION_V2
216
+ ) {
217
+ context.addIssue({
218
+ code: z.ZodIssueCode.custom,
219
+ path: ["evaluatorVersion"],
220
+ message: "Source-qualified contracts require evaluation-kernel:v2.2",
221
+ });
222
+ }
208
223
  let plan;
209
224
  try {
210
225
  plan = parseAnalysisPlanV1(input.analysisPlan);
@@ -14,7 +14,11 @@
14
14
  * contract — every scenario's authored kernel verdict must land.
15
15
  */
16
16
 
17
- import type { ContractPreimage } from "../contract/preimage.js";
17
+ import {
18
+ CONTRACT_PREIMAGE_SCHEMA,
19
+ CONTRACT_PREIMAGE_SCHEMA_V2,
20
+ type ContractPreimage,
21
+ } from "../contract/preimage.js";
18
22
  import type {
19
23
  AnalysisPlanV1,
20
24
  CoverageRegionV1,
@@ -26,9 +30,20 @@ import type { FactIndex } from "../facts/fact-index.js";
26
30
  import type { RepositoryBindingKeyV1 } from "../repository-binding.js";
27
31
 
28
32
  /** Bumped when kernel semantics change in a way that can flip a verdict. */
29
- export const EVALUATOR_VERSION = "evaluation-kernel:v1" as const;
30
- /** Authoritative facts-only evaluator introduced by Fragments V1 Brief 04. */
31
- export const EVALUATOR_VERSION_V2 = "evaluation-kernel:v2" as const;
33
+ export const EVALUATOR_VERSION = "evaluation-kernel:v1.1" as const;
34
+ /** Current facts-only evaluator. The input schema remains V2. */
35
+ export const EVALUATOR_VERSION_V2 = "evaluation-kernel:v2.1" as const;
36
+ /** Source-qualified contracts require this generation; older receipts retain their semantics. */
37
+ export const SOURCE_IDENTITY_EVALUATOR_VERSION_V2 = "evaluation-kernel:v2.2" as const;
38
+ /** Replay-only generation: its captured mapping judgments remain authoritative. */
39
+ export const LEGACY_EVALUATOR_VERSION_V2 = "evaluation-kernel:v2" as const;
40
+
41
+ /** Missing schema is a retained V1 row; unknown future generations fail closed. */
42
+ export function evaluatorVersionForContractSchema(schema?: string): string | null {
43
+ if (schema === undefined || schema === CONTRACT_PREIMAGE_SCHEMA) return EVALUATOR_VERSION_V2;
44
+ if (schema === CONTRACT_PREIMAGE_SCHEMA_V2) return SOURCE_IDENTITY_EVALUATOR_VERSION_V2;
45
+ return null;
46
+ }
32
47
 
33
48
  /**
34
49
  * Four-state per-finding model (spec §2). `candidate` is the derivation step —
package/src/index.ts CHANGED
@@ -881,6 +881,10 @@ export {
881
881
  CONTRACT_ENFORCEMENT_FIELDS,
882
882
  CONTRACT_PREIMAGE_CAPABILITY_HEADER,
883
883
  CONTRACT_PREIMAGE_SCHEMA,
884
+ CONTRACT_PREIMAGE_SCHEMA_V2,
885
+ CONTRACT_PREIMAGE_CAPABILITIES,
886
+ SUPPORTED_CONTRACT_PREIMAGE_SCHEMAS,
887
+ isSupportedContractPreimageSchema,
884
888
  ContractCatalogValidationError,
885
889
  contractComponentsFromFragments,
886
890
  contractHash,
@@ -928,6 +932,7 @@ export type {
928
932
  ContractIdentityPin,
929
933
  ContractPolicyInput,
930
934
  ContractPreimage,
935
+ ContractPreimageSchema,
931
936
  ContractPropMappingInput,
932
937
  ContractStampPin,
933
938
  ContractStampRecord,
@@ -1129,6 +1134,9 @@ export {
1129
1134
  EVALUATION_RECEIPT_STRING_MAX_BYTES_V1,
1130
1135
  EVALUATOR_VERSION,
1131
1136
  EVALUATOR_VERSION_V2,
1137
+ SOURCE_IDENTITY_EVALUATOR_VERSION_V2,
1138
+ evaluatorVersionForContractSchema,
1139
+ LEGACY_EVALUATOR_VERSION_V2,
1132
1140
  effectiveEvaluationWaiverV2Schema,
1133
1141
  evaluate,
1134
1142
  evaluationInputDigestV2,
@@ -1181,3 +1189,24 @@ export {
1181
1189
  APPROVED_CONTRACT_TOKEN_MAX_NAME_LENGTH,
1182
1190
  isApprovedContractTokenNames,
1183
1191
  } from "./approved-contract-tokens.js";
1192
+
1193
+ export {
1194
+ contractComponentSourceKey,
1195
+ contractComponentExportAddresses,
1196
+ approvedComponentGovernanceByteLimit,
1197
+ APPROVED_COMPONENT_GOVERNANCE_LEGACY_MAX_BYTES,
1198
+ APPROVED_COMPONENT_GOVERNANCE_QUALIFIED_MAX_BYTES,
1199
+ contractComponentReplacementImport,
1200
+ contractSourceFromFragment,
1201
+ normalizeContractComponentSource,
1202
+ normalizeContractSourcePath,
1203
+ contractComponentsFromFragmentsV2,
1204
+ projectContractPreimageV2,
1205
+ } from "./contract/source-identity.js";
1206
+ export type {
1207
+ ContractComponentSource,
1208
+ ContractComponentExport,
1209
+ ContractComponentInputV2,
1210
+ ContractCanonicalMappingInputV2,
1211
+ ContractCatalogInputV2,
1212
+ } from "./contract/source-identity.js";
@@ -0,0 +1,354 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ compileGlobalGovernanceFacts,
4
+ FactIndex,
5
+ makeClassNameDynamicFact,
6
+ makeUsageNodeFact,
7
+ makeUsageImportFact,
8
+ ruleComponentsPreferLibrary,
9
+ } from "../index.js";
10
+
11
+ function scan(options: Record<string, unknown>) {
12
+ const ix = new FactIndex();
13
+ ix.addMany(
14
+ compileGlobalGovernanceFacts({
15
+ rules: {
16
+ "components/prefer-library": { enabled: true, severity: "error", options },
17
+ },
18
+ })
19
+ );
20
+ const location = { file: "src/App.tsx", line: 2, column: 1 };
21
+ const node = makeUsageNodeFact({ file: location.file, nodePath: "0", element: "div", location });
22
+ ix.add(node);
23
+ ix.add(
24
+ makeClassNameDynamicFact({
25
+ file: location.file,
26
+ nodeId: node.id,
27
+ attr: "className",
28
+ reason: "identifier",
29
+ snippet: "styles.alert",
30
+ originPath: "0",
31
+ location,
32
+ })
33
+ );
34
+ return ruleComponentsPreferLibrary(ix);
35
+ }
36
+ const mappings = ["first", "second"].map((folder) => ({
37
+ name: "Alert",
38
+ canonical: "Alert",
39
+ importPath: `@acme/${folder}`,
40
+ source: { kind: "repository", path: `${folder}/Alert.tsx`, symbol: "Alert" },
41
+ exportAddresses: [{ kind: "package", importPath: `@acme/${folder}`, exportName: "Alert" }],
42
+ }));
43
+ const qualified = { sourceIdentitySchema: "component-source:v1" };
44
+
45
+ describe("source-qualified class-name advice", () => {
46
+ it("preserves legacy authored-order import advice", () => {
47
+ const forward = scan({ canonicalMappings: mappings });
48
+ const reverse = scan({ canonicalMappings: [...mappings].reverse() });
49
+ expect(forward[0]?.attributes?.suggestedImport).toBe("@acme/first");
50
+ expect(reverse[0]?.attributes?.suggestedImport).toBe("@acme/second");
51
+ });
52
+ it("retains all same-name alternatives without choosing an import or replacement", () => {
53
+ const forward = scan({ ...qualified, canonicalMappings: mappings });
54
+ const reverse = scan({ ...qualified, canonicalMappings: [...mappings].reverse() });
55
+ const withoutEvidence = (value: typeof forward) =>
56
+ value.map(({ evidence: _, ...rest }) => rest);
57
+ expect(withoutEvidence(forward)).toEqual(withoutEvidence(reverse));
58
+ expect(forward).toHaveLength(1);
59
+ expect(forward[0]?.level).toBe("warn");
60
+ expect(forward[0]?.attributes?.advisory).toBe(true);
61
+ expect(forward[0]?.attributes?.canonicalAlternatives).toHaveLength(2);
62
+ expect(forward[0]?.attributes?.ambiguousCanonicalTarget).toBe(true);
63
+ expect(forward[0]?.attributes).not.toHaveProperty("suggestedComponent");
64
+ expect(forward[0]?.attributes).not.toHaveProperty("suggestedImport");
65
+ expect(forward[0]?.fix).toBeUndefined();
66
+ });
67
+ it("uses a proven barrel alias instead of the implementation name", () => {
68
+ const findings = scan({
69
+ ...qualified,
70
+ canonicalMappings: [
71
+ {
72
+ ...mappings[0],
73
+ exportAddresses: [
74
+ { kind: "package", importPath: "@acme/public", exportName: "PublicAlert" },
75
+ ],
76
+ },
77
+ ],
78
+ });
79
+ expect(findings[0]?.attributes).toMatchObject({
80
+ suggestedComponent: "PublicAlert",
81
+ suggestedImport: "@acme/public",
82
+ });
83
+ });
84
+ it("does not grant guessed package labels public export authority", () => {
85
+ const findings = scan({
86
+ ...qualified,
87
+ canonicalMappings: [{ ...mappings[0], exportAddresses: [] }],
88
+ });
89
+ expect(findings[0]?.attributes?.unresolvedCanonicalExport).toBe(true);
90
+ expect(findings[0]?.attributes).not.toHaveProperty("suggestedImport");
91
+ expect(findings[0]?.attributes).not.toHaveProperty("suggestedComponent");
92
+ });
93
+ it("recognizes a package inventory entry already proven by a repository export", () => {
94
+ const findings = scan({
95
+ ...qualified,
96
+ canonicalMappings: [mappings[0]],
97
+ canonicalSources: [{ kind: "npm", specifier: "@acme/first", include: ["Alert"] }],
98
+ });
99
+ expect(findings[0]?.attributes?.suggestedImport).toBe("@acme/first");
100
+ expect(findings[0]?.attributes).not.toHaveProperty("ambiguousCanonicalTarget");
101
+ });
102
+ it("does not select one of two adopted packages with the same public name", () => {
103
+ const findings = scan({
104
+ ...qualified,
105
+ canonicalSources: ["first", "second"].map((folder) => ({
106
+ kind: "npm",
107
+ specifier: `@acme/${folder}`,
108
+ include: ["Alert"],
109
+ })),
110
+ });
111
+ expect(findings[0]?.attributes?.canonicalAlternatives).toHaveLength(2);
112
+ expect(findings[0]?.attributes).not.toHaveProperty("suggestedImport");
113
+ });
114
+ });
115
+
116
+ function componentScan(
117
+ options: Record<string, unknown>,
118
+ imported?: { source: string; name: string }
119
+ ) {
120
+ const ix = new FactIndex();
121
+ ix.addMany(
122
+ compileGlobalGovernanceFacts({
123
+ rules: {
124
+ "components/prefer-library": { enabled: true, severity: "error", options },
125
+ },
126
+ })
127
+ );
128
+ const location = { file: "src/App.tsx", line: 2, column: 1 };
129
+ ix.add(
130
+ makeUsageNodeFact({
131
+ file: location.file,
132
+ nodePath: "0",
133
+ element: imported ? "Button" : "button",
134
+ location,
135
+ })
136
+ );
137
+ if (imported)
138
+ ix.add(
139
+ makeUsageImportFact({
140
+ file: location.file,
141
+ local: "Button",
142
+ imported: imported.name,
143
+ source: imported.source,
144
+ location,
145
+ })
146
+ );
147
+ return ruleComponentsPreferLibrary(ix);
148
+ }
149
+
150
+ describe("qualified suggestion path consistency", () => {
151
+ const sources = ["first", "second"].map((name) => ({
152
+ kind: "npm",
153
+ specifier: `@acme/${name}`,
154
+ include: ["Button"],
155
+ }));
156
+ it("keeps legacy built-in source order but leaves qualified choices advisory and unresolved", () => {
157
+ expect(componentScan({ canonicalSources: sources })[0]?.attributes?.suggestedImport).toBe(
158
+ "@acme/first"
159
+ );
160
+ const forward = componentScan({ ...qualified, canonicalSources: sources });
161
+ const reverse = componentScan({ ...qualified, canonicalSources: [...sources].reverse() });
162
+ expect(forward[0]?.attributes).toEqual(reverse[0]?.attributes);
163
+ expect(forward[0]?.level).toBe("warn");
164
+ expect(forward[0]?.attributes).toMatchObject({
165
+ advisory: true,
166
+ ambiguousCanonicalTarget: true,
167
+ });
168
+ expect(forward[0]?.attributes?.canonicalAlternatives).toHaveLength(2);
169
+ expect(forward[0]?.attributes).not.toHaveProperty("suggestedImport");
170
+ expect(forward[0]?.fix).toBeUndefined();
171
+ });
172
+ it("does not select between public addresses of the same definition using inventory order or package metadata", () => {
173
+ const addresses = ["first", "second"].map((name) => ({
174
+ kind: "package",
175
+ importPath: `@acme/${name}`,
176
+ exportName: "Alert",
177
+ }));
178
+ const mapping = { ...mappings[0], exportAddresses: addresses };
179
+ const inventories = sources.map((source) => ({ ...source, include: ["Alert"] }));
180
+ const first = scan({
181
+ ...qualified,
182
+ canonicalMappings: [mapping],
183
+ canonicalSources: inventories,
184
+ });
185
+ const second = scan({
186
+ ...qualified,
187
+ canonicalMappings: [{ ...mapping, exportAddresses: [...addresses].reverse() }],
188
+ canonicalSources: [...inventories].reverse(),
189
+ });
190
+ expect(first[0]?.attributes).toEqual(second[0]?.attributes);
191
+ expect(first[0]?.attributes?.canonicalAlternatives).toHaveLength(2);
192
+ expect(first[0]?.attributes).not.toHaveProperty("suggestedImport");
193
+ expect(first[0]?.attributes).not.toHaveProperty("suggestedComponent");
194
+ expect(first[0]?.fix).toBeUndefined();
195
+ });
196
+ it("deduplicates repeated evidence for the same public address", () => {
197
+ const address = mappings[0]!.exportAddresses[0]!;
198
+ const findings = scan({
199
+ ...qualified,
200
+ canonicalMappings: [{ ...mappings[0], exportAddresses: [address, address] }],
201
+ });
202
+ expect(findings[0]?.attributes).toMatchObject({
203
+ suggestedImport: "@acme/first",
204
+ suggestedComponent: "Alert",
205
+ });
206
+ expect(findings[0]?.attributes).not.toHaveProperty("ambiguousCanonicalTarget");
207
+ });
208
+ it.each([
209
+ { source: "@acme/first", name: "Wrong" },
210
+ { source: "@acme/first/unproven-subpath", name: "Button" },
211
+ ])("does not let legacy root/name matching hide ambiguity for $source#$name", (imported) => {
212
+ const canonicalMappings = sources.map((source) => ({
213
+ name: "Button",
214
+ canonical: "Button",
215
+ importPath: source.specifier,
216
+ source: { kind: "package", importPath: source.specifier, exportName: "Button" },
217
+ }));
218
+ expect(componentScan({ canonicalMappings, canonicalSources: sources }, imported)).toEqual([]);
219
+ const findings = componentScan(
220
+ { ...qualified, canonicalMappings, canonicalSources: sources },
221
+ imported
222
+ );
223
+ expect(findings).toHaveLength(1);
224
+ expect(findings[0]?.attributes?.ambiguousCanonicalTarget).toBe(true);
225
+ expect(findings[0]?.attributes).not.toHaveProperty("suggestedImport");
226
+ expect(findings[0]?.fix).toBeUndefined();
227
+ });
228
+ it("accepts both exact approved public exports", () => {
229
+ for (const source of sources)
230
+ expect(
231
+ componentScan(
232
+ { ...qualified, canonicalSources: sources },
233
+ { source: source.specifier, name: "Button" }
234
+ )
235
+ ).toEqual([]);
236
+ });
237
+ });
238
+
239
+ describe("qualified owned-package aliases", () => {
240
+ const spellings = ["@usefragments/ui", "@fragments-sdk/ui"];
241
+ const addresses = spellings.map((importPath) => ({
242
+ kind: "package",
243
+ importPath,
244
+ exportName: "Alert",
245
+ }));
246
+ it("joins an owned alias inventory to the same proven repository export", () => {
247
+ const findings = scan({
248
+ ...qualified,
249
+ canonicalMappings: [{ ...mappings[0], exportAddresses: [addresses[0]] }],
250
+ canonicalSources: [{ kind: "npm", specifier: spellings[1], include: ["Alert"] }],
251
+ });
252
+ expect(findings[0]?.attributes?.suggestedImport).toBe("@usefragments/ui");
253
+ expect(findings[0]?.attributes).not.toHaveProperty("ambiguousCanonicalTarget");
254
+ });
255
+ it("deduplicates equivalent exports before selecting canonical spelling in either order", () => {
256
+ for (const exportAddresses of [addresses, [...addresses].reverse()]) {
257
+ const findings = scan({
258
+ ...qualified,
259
+ canonicalMappings: [{ ...mappings[0], exportAddresses }],
260
+ });
261
+ expect(findings[0]?.attributes?.suggestedImport).toBe("@usefragments/ui");
262
+ expect(findings[0]?.attributes).not.toHaveProperty("ambiguousCanonicalTarget");
263
+ }
264
+ });
265
+ it("normalizes built-in fallback guidance independently of inventory order", () => {
266
+ const inventories = spellings.map((specifier) => ({
267
+ kind: "npm",
268
+ specifier,
269
+ include: ["Button"],
270
+ }));
271
+ for (const canonicalSources of [inventories, [...inventories].reverse()]) {
272
+ const findings = componentScan({ ...qualified, canonicalSources });
273
+ expect(findings[0]?.attributes?.suggestedImport).toBe("@usefragments/ui");
274
+ expect(findings[0]?.attributes).not.toHaveProperty("ambiguousCanonicalTarget");
275
+ }
276
+ });
277
+ });
278
+
279
+ describe("qualified JSX replacement names", () => {
280
+ it("recognizes an existing exact default import using its real local binding", () => {
281
+ expect(
282
+ componentScan(
283
+ {
284
+ ...qualified,
285
+ canonicalMappings: [
286
+ {
287
+ name: "Button",
288
+ canonical: "Button",
289
+ htmlEquivalent: "button",
290
+ source: { kind: "repository", path: "src/Button.tsx", symbol: "Button" },
291
+ exportAddresses: [
292
+ { kind: "package", importPath: "@acme/ui/button", exportName: "default" },
293
+ ],
294
+ },
295
+ ],
296
+ },
297
+ { source: "@acme/ui/button", name: "default" }
298
+ )
299
+ ).toEqual([]);
300
+ });
301
+ it.each(["default", "lowercase", "not-an-identifier"])(
302
+ "keeps %s unresolved without inventing a local binding",
303
+ (exportName) => {
304
+ const canonicalMappings = [
305
+ {
306
+ name: "Button",
307
+ canonical: "Button",
308
+ htmlEquivalent: "button",
309
+ source: { kind: "repository", path: "src/Button.tsx", symbol: "Button" },
310
+ exportAddresses: [{ kind: "package", importPath: "@acme/ui", exportName }],
311
+ },
312
+ ];
313
+ const findings = componentScan({ ...qualified, canonicalMappings });
314
+ expect(findings).toHaveLength(1);
315
+ expect(findings[0]?.level).toBe("error");
316
+ expect(findings[0]?.attributes?.unresolvedCanonicalExport).toBe(true);
317
+ expect(findings[0]?.attributes).not.toHaveProperty("suggestedComponent");
318
+ expect(findings[0]?.attributes).not.toHaveProperty("suggestedImport");
319
+ expect(findings[0]?.fix).toBeUndefined();
320
+ const classFindings = scan({
321
+ ...qualified,
322
+ canonicalMappings: canonicalMappings.map((mapping) => ({
323
+ ...mapping,
324
+ name: "Alert",
325
+ canonical: "Alert",
326
+ htmlEquivalent: undefined,
327
+ })),
328
+ });
329
+ expect(classFindings[0]?.attributes?.advisory).toBe(true);
330
+ expect(classFindings[0]?.attributes).not.toHaveProperty("suggestedComponent");
331
+ expect(classFindings[0]?.attributes).not.toHaveProperty("suggestedImport");
332
+ expect(classFindings[0]?.fix).toBeUndefined();
333
+ }
334
+ );
335
+ it.each(["Button", "PublicButton"])(
336
+ "keeps the proven %s named export actionable",
337
+ (exportName) => {
338
+ const findings = componentScan({
339
+ ...qualified,
340
+ canonicalMappings: [
341
+ {
342
+ name: "Button",
343
+ canonical: "Button",
344
+ htmlEquivalent: "button",
345
+ source: { kind: "repository", path: "src/Button.tsx", symbol: "Button" },
346
+ exportAddresses: [{ kind: "package", importPath: "@acme/ui", exportName }],
347
+ },
348
+ ],
349
+ });
350
+ expect(findings[0]?.attributes?.suggestedComponent).toBe(exportName);
351
+ expect(findings[0]?.fix).toMatchObject({ kind: "replaceComponent", to: exportName });
352
+ }
353
+ );
354
+ });