@usefragments/core 2.0.2 → 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.
- package/dist/codes/index.d.ts +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{index-C8bcXVav.d.ts → index-xn5CNdf4.d.ts} +451 -451
- package/dist/index.d.ts +1433 -1423
- package/dist/index.js +5028 -4564
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +24 -24
- package/dist/preview-runtime.d.ts +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +380 -380
- package/dist/schemas/index.d.ts +1 -1
- package/dist/{governance-hOPXGbbs.d.ts → source-identity-DvOBz2yJ.d.ts} +675 -610
- package/dist/storyAdapter.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/conform.ts +10 -0
- package/src/contract/index.ts +24 -0
- package/src/contract/preimage.ts +17 -3
- package/src/contract/source-identity.test.ts +167 -0
- package/src/contract/source-identity.ts +303 -0
- package/src/evaluation/canonical-facts-trust.test.ts +67 -6
- package/src/evaluation/evaluate.ts +19 -3
- package/src/evaluation/evaluation-v2-receipt-v1.test.ts +27 -1
- package/src/evaluation/index.ts +7 -1
- package/src/evaluation/receipt.ts +17 -2
- package/src/evaluation/types.ts +14 -1
- package/src/index.ts +28 -0
- package/src/rules/components-prefer-library-classnames.test.ts +354 -0
- package/src/rules/components-prefer-library.test.ts +246 -0
- package/src/rules/components-prefer-library.ts +340 -61
|
@@ -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
|
+
});
|
|
@@ -344,3 +344,249 @@ describe("components/prefer-library — #9e directory import-path identity", ()
|
|
|
344
344
|
expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
|
|
345
345
|
});
|
|
346
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("source-qualified canonical mapping execution", () => {
|
|
349
|
+
const mappings = ["first", "second"].map((name) => ({
|
|
350
|
+
name: "Button",
|
|
351
|
+
canonical: "Button",
|
|
352
|
+
htmlEquivalent: "button",
|
|
353
|
+
importPath: `@acme/${name}`,
|
|
354
|
+
source: { kind: "package", importPath: `@acme/${name}`, exportName: "Button" },
|
|
355
|
+
propMapping: [{ rawProp: "title", canonicalProp: name === "first" ? "label" : "caption" }],
|
|
356
|
+
}));
|
|
357
|
+
function scan(input: { reverse?: boolean; sourceQualified?: boolean; imported?: string }) {
|
|
358
|
+
const ix = new FactIndex();
|
|
359
|
+
ix.addMany(
|
|
360
|
+
compileGlobalGovernanceFacts({
|
|
361
|
+
rules: {
|
|
362
|
+
"components/prefer-library": {
|
|
363
|
+
enabled: true,
|
|
364
|
+
severity: "error",
|
|
365
|
+
options: {
|
|
366
|
+
...(input.sourceQualified ? { sourceIdentitySchema: "component-source:v1" } : {}),
|
|
367
|
+
canonicalMappings: input.reverse ? [...mappings].reverse() : mappings,
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
);
|
|
373
|
+
const element = input.imported ? "Button" : "button";
|
|
374
|
+
ix.add(
|
|
375
|
+
makeUsageNodeFact({
|
|
376
|
+
file: "src/App.tsx",
|
|
377
|
+
nodePath: "0",
|
|
378
|
+
element,
|
|
379
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
380
|
+
})
|
|
381
|
+
);
|
|
382
|
+
if (input.imported)
|
|
383
|
+
ix.add(
|
|
384
|
+
makeUsageImportFact({
|
|
385
|
+
file: "src/App.tsx",
|
|
386
|
+
local: "Button",
|
|
387
|
+
imported: "Button",
|
|
388
|
+
source: input.imported,
|
|
389
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
390
|
+
})
|
|
391
|
+
);
|
|
392
|
+
return ruleComponentsPreferLibrary(ix);
|
|
393
|
+
}
|
|
394
|
+
it("retains legacy first-target guidance for unmarked policy", () => {
|
|
395
|
+
expect(scan({})[0]?.attributes?.suggestedImport).toBe("@acme/first");
|
|
396
|
+
});
|
|
397
|
+
it("does not arbitrarily select props or an import when multiple approved targets fit", () => {
|
|
398
|
+
const forward = scan({ sourceQualified: true });
|
|
399
|
+
const withoutCapturedPolicy = (findings: ReturnType<typeof scan>) =>
|
|
400
|
+
findings.map(({ evidence: _evidence, ...finding }) => finding);
|
|
401
|
+
expect(withoutCapturedPolicy(forward)).toEqual(
|
|
402
|
+
withoutCapturedPolicy(scan({ sourceQualified: true, reverse: true }))
|
|
403
|
+
);
|
|
404
|
+
expect(forward).toHaveLength(1);
|
|
405
|
+
expect(forward[0]?.attributes?.ambiguousCanonicalTarget).toBe(true);
|
|
406
|
+
expect(forward[0]?.fix).toBeUndefined();
|
|
407
|
+
expect(forward[0]?.attributes).not.toHaveProperty("propMapping");
|
|
408
|
+
});
|
|
409
|
+
it.each(["@acme/first", "@acme/second"])(
|
|
410
|
+
"recognizes the exact approved import %s in either catalog order",
|
|
411
|
+
(imported) => {
|
|
412
|
+
expect(scan({ sourceQualified: true, imported })).toEqual([]);
|
|
413
|
+
expect(scan({ sourceQualified: true, imported, reverse: true })).toEqual([]);
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
function barrelScan(source: string, imported: string) {
|
|
417
|
+
const ix = new FactIndex();
|
|
418
|
+
ix.addMany(
|
|
419
|
+
compileGlobalGovernanceFacts({
|
|
420
|
+
rules: {
|
|
421
|
+
"components/prefer-library": {
|
|
422
|
+
enabled: true,
|
|
423
|
+
severity: "error",
|
|
424
|
+
options: {
|
|
425
|
+
sourceIdentitySchema: "component-source:v1",
|
|
426
|
+
canonicalMappings: ["first", "second"].map((folder) => ({
|
|
427
|
+
name: "Button",
|
|
428
|
+
canonical: "Button",
|
|
429
|
+
htmlEquivalent: "button",
|
|
430
|
+
importPath: `@acme/${folder}`,
|
|
431
|
+
source: { kind: "repository", path: `${folder}/Button.tsx`, symbol: "Button" },
|
|
432
|
+
exportAddresses: [
|
|
433
|
+
{ kind: "package", importPath: "@acme/public", exportName: `${folder}Button` },
|
|
434
|
+
],
|
|
435
|
+
})),
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
);
|
|
441
|
+
ix.add(
|
|
442
|
+
makeUsageNodeFact({
|
|
443
|
+
file: "src/App.tsx",
|
|
444
|
+
nodePath: "0",
|
|
445
|
+
element: "Button",
|
|
446
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
447
|
+
})
|
|
448
|
+
);
|
|
449
|
+
ix.add(
|
|
450
|
+
makeUsageImportFact({
|
|
451
|
+
file: "src/App.tsx",
|
|
452
|
+
local: "Button",
|
|
453
|
+
imported,
|
|
454
|
+
source,
|
|
455
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
456
|
+
})
|
|
457
|
+
);
|
|
458
|
+
return ruleComponentsPreferLibrary(ix);
|
|
459
|
+
}
|
|
460
|
+
it("recognizes a proven barrel export independently of its implementation symbol", () => {
|
|
461
|
+
expect(barrelScan("@acme/public", "secondButton")).toEqual([]);
|
|
462
|
+
});
|
|
463
|
+
it("does not authorize the definition symbol at an unproven package address", () => {
|
|
464
|
+
expect(barrelScan("@acme/second", "Button")[0]?.attributes?.ambiguousCanonicalTarget).toBe(
|
|
465
|
+
true
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
it("suggests the actual proven public export name while preserving the component identity", () => {
|
|
469
|
+
const ix = new FactIndex();
|
|
470
|
+
ix.addMany(
|
|
471
|
+
compileGlobalGovernanceFacts({
|
|
472
|
+
rules: {
|
|
473
|
+
"components/prefer-library": {
|
|
474
|
+
enabled: true,
|
|
475
|
+
severity: "error",
|
|
476
|
+
options: {
|
|
477
|
+
sourceIdentitySchema: "component-source:v1",
|
|
478
|
+
canonicalMappings: [
|
|
479
|
+
{
|
|
480
|
+
name: "Button",
|
|
481
|
+
canonical: "Button",
|
|
482
|
+
htmlEquivalent: "button",
|
|
483
|
+
importPath: "@acme/unproven",
|
|
484
|
+
source: { kind: "repository", path: "src/Button.tsx", symbol: "Button" },
|
|
485
|
+
exportAddresses: [
|
|
486
|
+
{ kind: "package", importPath: "@acme/public", exportName: "PublicButton" },
|
|
487
|
+
],
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
})
|
|
494
|
+
);
|
|
495
|
+
ix.add(
|
|
496
|
+
makeUsageNodeFact({
|
|
497
|
+
file: "src/App.tsx",
|
|
498
|
+
nodePath: "0",
|
|
499
|
+
element: "button",
|
|
500
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
501
|
+
})
|
|
502
|
+
);
|
|
503
|
+
expect(ruleComponentsPreferLibrary(ix)[0]?.attributes).toMatchObject({
|
|
504
|
+
suggestedComponent: "PublicButton",
|
|
505
|
+
suggestedImport: "@acme/public",
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
it("does not fabricate a JSX replacement when one source has several public aliases", () => {
|
|
509
|
+
const ix = new FactIndex();
|
|
510
|
+
ix.addMany(
|
|
511
|
+
compileGlobalGovernanceFacts({
|
|
512
|
+
rules: {
|
|
513
|
+
"components/prefer-library": {
|
|
514
|
+
enabled: true,
|
|
515
|
+
severity: "error",
|
|
516
|
+
options: {
|
|
517
|
+
sourceIdentitySchema: "component-source:v1",
|
|
518
|
+
canonicalMappings: [
|
|
519
|
+
{
|
|
520
|
+
name: "Button",
|
|
521
|
+
canonical: "Button",
|
|
522
|
+
htmlEquivalent: "button",
|
|
523
|
+
source: { kind: "repository", path: "src/Button.tsx", symbol: "Button" },
|
|
524
|
+
exportAddresses: ["PrimaryButton", "SecondaryButton"].map((exportName) => ({
|
|
525
|
+
kind: "package",
|
|
526
|
+
importPath: "@acme/public",
|
|
527
|
+
exportName,
|
|
528
|
+
})),
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
})
|
|
535
|
+
);
|
|
536
|
+
ix.add(
|
|
537
|
+
makeUsageNodeFact({
|
|
538
|
+
file: "src/App.tsx",
|
|
539
|
+
nodePath: "0",
|
|
540
|
+
element: "button",
|
|
541
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
542
|
+
})
|
|
543
|
+
);
|
|
544
|
+
const finding = ruleComponentsPreferLibrary(ix)[0];
|
|
545
|
+
expect(finding?.attributes?.unresolvedCanonicalExport).toBe(true);
|
|
546
|
+
expect(finding?.fix).toBeUndefined();
|
|
547
|
+
expect(finding?.attributes).not.toHaveProperty("suggestedComponent");
|
|
548
|
+
expect(finding?.attributes).not.toHaveProperty("propMapping");
|
|
549
|
+
});
|
|
550
|
+
it("keeps role-only unresolved export guidance advisory", () => {
|
|
551
|
+
const ix = new FactIndex();
|
|
552
|
+
ix.addMany(
|
|
553
|
+
compileGlobalGovernanceFacts({
|
|
554
|
+
rules: {
|
|
555
|
+
"components/prefer-library": {
|
|
556
|
+
enabled: true,
|
|
557
|
+
severity: "error",
|
|
558
|
+
options: {
|
|
559
|
+
sourceIdentitySchema: "component-source:v1",
|
|
560
|
+
canonicalMappings: [
|
|
561
|
+
{
|
|
562
|
+
name: "Toast",
|
|
563
|
+
canonical: "Toast",
|
|
564
|
+
ariaRole: "status",
|
|
565
|
+
source: { kind: "repository", path: "src/Toast.tsx", symbol: "Toast" },
|
|
566
|
+
exportAddresses: [],
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
})
|
|
573
|
+
);
|
|
574
|
+
ix.add(
|
|
575
|
+
makeUsageNodeFact({
|
|
576
|
+
file: "src/App.tsx",
|
|
577
|
+
nodePath: "0",
|
|
578
|
+
element: "div",
|
|
579
|
+
role: "status",
|
|
580
|
+
location: { file: "src/App.tsx", line: 1, column: 1 },
|
|
581
|
+
})
|
|
582
|
+
);
|
|
583
|
+
const finding = ruleComponentsPreferLibrary(ix)[0];
|
|
584
|
+
expect(finding?.severity).toBe("moderate");
|
|
585
|
+
expect(finding?.attributes).toMatchObject({
|
|
586
|
+
unresolvedCanonicalExport: true,
|
|
587
|
+
advisory: true,
|
|
588
|
+
precisionTier: "role-reimpl",
|
|
589
|
+
});
|
|
590
|
+
expect(finding?.fix).toBeUndefined();
|
|
591
|
+
});
|
|
592
|
+
});
|