@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.
- 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 +1438 -1426
- package/dist/index.js +5074 -4583
- 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 +398 -0
- package/src/evaluation/evaluate.ts +84 -11
- package/src/evaluation/evaluation-v2-receipt-v1.test.ts +28 -2
- package/src/evaluation/index.ts +7 -1
- package/src/evaluation/receipt.ts +17 -2
- package/src/evaluation/types.ts +19 -4
- package/src/index.ts +29 -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
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contractComponentSourceKey,
|
|
3
|
+
contractComponentExportAddresses as qualifiedMappingExports,
|
|
4
|
+
contractComponentReplacementImport as qualifiedMappingImport,
|
|
5
|
+
type ContractComponentSource,
|
|
6
|
+
type ContractComponentExport,
|
|
7
|
+
} from "../contract/source-identity.js";
|
|
1
8
|
import type { CanonicalSource, GovernanceSeverity } from "../governance.js";
|
|
2
9
|
import type {
|
|
3
10
|
FactId,
|
|
@@ -17,6 +24,7 @@ import {
|
|
|
17
24
|
type RawHtmlPrecisionTier,
|
|
18
25
|
} from "../raw-html-canonical.js";
|
|
19
26
|
import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
|
|
27
|
+
import { canonicalizeOwnedImport } from "../package-identity.js";
|
|
20
28
|
import { indexComponentIdentityUsage } from "../identity/usage.js";
|
|
21
29
|
import { canonicalSourceContainsFile, canonicalSourceIncludesExport } from "../canonical-source.js";
|
|
22
30
|
import {
|
|
@@ -33,6 +41,8 @@ export const RULE_ID = "components/prefer-library";
|
|
|
33
41
|
export const RULE_VERSION = "1";
|
|
34
42
|
|
|
35
43
|
interface CanonicalMappingOption {
|
|
44
|
+
source?: ContractComponentSource;
|
|
45
|
+
exportAddresses?: readonly ContractComponentExport[];
|
|
36
46
|
name: string;
|
|
37
47
|
/** Internal conform-boundary assertion; only the conform policy builder sets it. */
|
|
38
48
|
conformStatus?: "confirmed";
|
|
@@ -183,13 +193,17 @@ const GENERIC_CLASSNAME_DENYLIST: ReadonlySet<string> = new Set([
|
|
|
183
193
|
interface ReimplTarget {
|
|
184
194
|
name: string;
|
|
185
195
|
import?: string;
|
|
196
|
+
unresolved?: boolean;
|
|
197
|
+
alternatives?: string[];
|
|
186
198
|
}
|
|
187
199
|
|
|
188
200
|
/** lowercased class token → the canonical component it likely reimplements. */
|
|
189
201
|
function canonicalReimplTargets(
|
|
190
202
|
sources: readonly CanonicalSource[],
|
|
191
|
-
mappings: readonly CanonicalMappingOption[]
|
|
203
|
+
mappings: readonly CanonicalMappingOption[],
|
|
204
|
+
sourceQualified = false
|
|
192
205
|
): Map<string, ReimplTarget> {
|
|
206
|
+
if (sourceQualified) return qualifiedReimplTargets(sources, mappings);
|
|
193
207
|
const out = new Map<string, ReimplTarget>();
|
|
194
208
|
const add = (name: string | undefined, importPath: string | undefined) => {
|
|
195
209
|
if (!name || !isIdentifier(name)) return;
|
|
@@ -205,6 +219,80 @@ function canonicalReimplTargets(
|
|
|
205
219
|
return out;
|
|
206
220
|
}
|
|
207
221
|
|
|
222
|
+
function qualifiedReimplTargets(
|
|
223
|
+
sources: readonly CanonicalSource[],
|
|
224
|
+
mappings: readonly CanonicalMappingOption[]
|
|
225
|
+
): Map<string, ReimplTarget> {
|
|
226
|
+
const choices = new Map<string, Map<string, ReimplTarget>>();
|
|
227
|
+
const exportOwners = new Map<string, Set<string>>();
|
|
228
|
+
const add = (name: string, identity: string, target: ReimplTarget) => {
|
|
229
|
+
const key = name.toLowerCase();
|
|
230
|
+
if (!isIdentifier(name) || GENERIC_CLASSNAME_DENYLIST.has(key)) return;
|
|
231
|
+
const matches = choices.get(key) ?? new Map<string, ReimplTarget>();
|
|
232
|
+
// Source identity and public export are separate choices. Two addresses of
|
|
233
|
+
// the same definition must not overwrite each other in authored order.
|
|
234
|
+
matches.set(JSON.stringify([identity, target.name, target.import ?? null]), target);
|
|
235
|
+
choices.set(key, matches);
|
|
236
|
+
};
|
|
237
|
+
for (const mapping of mappings) {
|
|
238
|
+
const identity = mapping.source
|
|
239
|
+
? contractComponentSourceKey(mapping.source)
|
|
240
|
+
: JSON.stringify([mapping.importPath, mapping.name]);
|
|
241
|
+
const addresses = qualifiedMappingExports(mapping);
|
|
242
|
+
for (const address of addresses) {
|
|
243
|
+
const key = contractComponentSourceKey({ kind: "package", ...address });
|
|
244
|
+
const owners = exportOwners.get(key) ?? new Set<string>();
|
|
245
|
+
owners.add(identity);
|
|
246
|
+
exportOwners.set(key, owners);
|
|
247
|
+
}
|
|
248
|
+
const selected = qualifiedMappingImport(mapping);
|
|
249
|
+
const targets = selected ? [selected] : addresses;
|
|
250
|
+
if (targets.length === 0) add(mapping.name, identity, { name: mapping.name, unresolved: true });
|
|
251
|
+
for (const target of targets)
|
|
252
|
+
add(mapping.name, identity, {
|
|
253
|
+
name: target.exportName,
|
|
254
|
+
import: target.importPath,
|
|
255
|
+
...(!isComponentBinding(target.exportName) ? { unresolved: true } : {}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
for (const source of sources) {
|
|
259
|
+
for (const name of source.include ?? []) {
|
|
260
|
+
if (source.exclude?.includes(name)) continue;
|
|
261
|
+
const identity = qualifiedSourceIdentity(source, name);
|
|
262
|
+
const owners = exportOwners.get(identity) ?? new Set([identity]);
|
|
263
|
+
for (const owner of owners)
|
|
264
|
+
add(name, owner, {
|
|
265
|
+
name,
|
|
266
|
+
...(canonicalImportPath(source) && isComponentBinding(name)
|
|
267
|
+
? { import: canonicalizeOwnedImport(canonicalImportPath(source)!) }
|
|
268
|
+
: { unresolved: true }),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return new Map(
|
|
273
|
+
[...choices].map(([key, matches]) => {
|
|
274
|
+
const identities = [...matches.keys()].sort();
|
|
275
|
+
const target = matches.get(identities[0]!)!;
|
|
276
|
+
return [
|
|
277
|
+
key,
|
|
278
|
+
identities.length === 1
|
|
279
|
+
? target
|
|
280
|
+
: { name: key, unresolved: true, alternatives: identities },
|
|
281
|
+
];
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function qualifiedSourceIdentity(source: CanonicalSource, name: string): string {
|
|
287
|
+
return source.kind === "npm"
|
|
288
|
+
? contractComponentSourceKey({
|
|
289
|
+
kind: "package",
|
|
290
|
+
importPath: source.specifier,
|
|
291
|
+
exportName: name,
|
|
292
|
+
})
|
|
293
|
+
: JSON.stringify([source.kind, sourceLabel(source), name]);
|
|
294
|
+
}
|
|
295
|
+
|
|
208
296
|
/** The CSS-module member of a `styles.card` / `styles["card"]` snippet, or null. */
|
|
209
297
|
function classMemberFromSnippet(snippet: string): string | null {
|
|
210
298
|
const bracket = /\[\s*["'`]([A-Za-z0-9_-]+)["'`]\s*\]\s*$/.exec(snippet);
|
|
@@ -238,6 +326,8 @@ interface ClassNameReimplMatch {
|
|
|
238
326
|
canonical: string;
|
|
239
327
|
matchedClass: string;
|
|
240
328
|
import?: string;
|
|
329
|
+
unresolved?: boolean;
|
|
330
|
+
alternatives?: string[];
|
|
241
331
|
}
|
|
242
332
|
|
|
243
333
|
function findClassNameReimpl(
|
|
@@ -253,7 +343,9 @@ function findClassNameReimpl(
|
|
|
253
343
|
return {
|
|
254
344
|
canonical: target.name,
|
|
255
345
|
matchedClass: token,
|
|
256
|
-
...(target.import ? { import: target.import } : {}),
|
|
346
|
+
...(target.import && !target.unresolved ? { import: target.import } : {}),
|
|
347
|
+
...(target.unresolved ? { unresolved: true } : {}),
|
|
348
|
+
...(target.alternatives ? { alternatives: target.alternatives } : {}),
|
|
257
349
|
};
|
|
258
350
|
}
|
|
259
351
|
}
|
|
@@ -282,7 +374,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
282
374
|
const propsByNode = indexPropsByNodeId(ix);
|
|
283
375
|
const textByNode = indexTextChildrenByNodeId(ix);
|
|
284
376
|
const classTokensByNode = indexClassTokensByNode(ix);
|
|
285
|
-
const
|
|
377
|
+
const sourceQualified = policy.options?.sourceIdentitySchema === "component-source:v1";
|
|
378
|
+
const reimplTargets = canonicalReimplTargets(sources, mappings, sourceQualified);
|
|
286
379
|
// #9e — nodes whose local import a Node-side module resolver already proved
|
|
287
380
|
// resolves INSIDE a directory canonical source (the extractor emitted a
|
|
288
381
|
// `usage_component` whose id points at that directory). These are the genuine
|
|
@@ -333,8 +426,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
333
426
|
const imported = importsForFile?.get(node.element);
|
|
334
427
|
const nodeInputType =
|
|
335
428
|
node.element === "input" ? readStaticStringProp(props, "type") : undefined;
|
|
336
|
-
const mapped = findMapping(node, nodeInputType, mappings);
|
|
337
|
-
if (mapped && imported && isMappedCanonicalImport(imported, mapped)) continue;
|
|
429
|
+
const mapped = findMapping(node, nodeInputType, mappings, sourceQualified, imported);
|
|
430
|
+
if (mapped && imported && isMappedCanonicalImport(imported, mapped, sourceQualified)) continue;
|
|
338
431
|
if (
|
|
339
432
|
imported &&
|
|
340
433
|
isCanonicalImport(
|
|
@@ -342,20 +435,92 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
342
435
|
node.element,
|
|
343
436
|
approvedLocalDefinitionKeys === undefined
|
|
344
437
|
? sources
|
|
345
|
-
: sources.filter((source) => source.kind !== "directory")
|
|
438
|
+
: sources.filter((source) => source.kind !== "directory"),
|
|
439
|
+
sourceQualified
|
|
346
440
|
)
|
|
347
441
|
)
|
|
348
442
|
continue;
|
|
349
443
|
|
|
444
|
+
if (mapped === null) {
|
|
445
|
+
const preciseIntrinsic =
|
|
446
|
+
!imported &&
|
|
447
|
+
mappings.some(
|
|
448
|
+
(mapping) =>
|
|
449
|
+
sameHtmlTag(mapping.htmlEquivalent, node.element) &&
|
|
450
|
+
isEnforceableHtmlEquivalent({
|
|
451
|
+
tag: node.element,
|
|
452
|
+
canonical: mapping.name,
|
|
453
|
+
inputType: nodeInputType,
|
|
454
|
+
explicit: isExplicitTagDeclaration(mapping),
|
|
455
|
+
})
|
|
456
|
+
);
|
|
457
|
+
findings.push(
|
|
458
|
+
makeFinding({
|
|
459
|
+
ruleId: RULE_ID,
|
|
460
|
+
ruleVersion: RULE_VERSION,
|
|
461
|
+
severity: preciseIntrinsic
|
|
462
|
+
? (policy.severity ?? "warn")
|
|
463
|
+
: capAdvisorySeverity(policy.severity),
|
|
464
|
+
message: `<${node.element}> has multiple approved component sources. Choose the intended source before applying a replacement.`,
|
|
465
|
+
location: node.location,
|
|
466
|
+
evidence: ix.evidence(
|
|
467
|
+
imported ? [node.id, imported.id, policy.id] : [node.id, policy.id]
|
|
468
|
+
),
|
|
469
|
+
fingerprintIdentity: {
|
|
470
|
+
file: node.file,
|
|
471
|
+
nodePath: node.nodePath,
|
|
472
|
+
element: node.element,
|
|
473
|
+
ambiguity: "approved-component-sources",
|
|
474
|
+
},
|
|
475
|
+
attributes: {
|
|
476
|
+
...(!preciseIntrinsic ? { advisory: true } : {}),
|
|
477
|
+
ambiguousCanonicalTarget: true,
|
|
478
|
+
contractProof: "missing",
|
|
479
|
+
},
|
|
480
|
+
})
|
|
481
|
+
);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
350
485
|
if (mapped) {
|
|
351
486
|
if (!shouldSuggestMapped(node, nodeInputType, imported, mapped)) continue;
|
|
352
|
-
const
|
|
353
|
-
const
|
|
487
|
+
const target = sourceQualified ? qualifiedMappingImport(mapped) : undefined;
|
|
488
|
+
const roleMatch = isRoleMatch(node, mapped);
|
|
489
|
+
const precisionTier: RawHtmlPrecisionTier = roleMatch ? "role-reimpl" : "exact-html";
|
|
490
|
+
if (sourceQualified && !target) {
|
|
491
|
+
findings.push(
|
|
492
|
+
makeFinding({
|
|
493
|
+
ruleId: RULE_ID,
|
|
494
|
+
ruleVersion: RULE_VERSION,
|
|
495
|
+
severity: imported
|
|
496
|
+
? capAdvisorySeverity(policy.severity)
|
|
497
|
+
: severityForTier(policy.severity, precisionTier),
|
|
498
|
+
message: `Choose a verified export of ${mapped.name} before replacing <${node.element}>.`,
|
|
499
|
+
location: node.location,
|
|
500
|
+
evidence: ix.evidence(
|
|
501
|
+
imported ? [node.id, imported.id, policy.id] : [node.id, policy.id]
|
|
502
|
+
),
|
|
503
|
+
fingerprintIdentity: {
|
|
504
|
+
file: node.file,
|
|
505
|
+
nodePath: node.nodePath,
|
|
506
|
+
element: node.element,
|
|
507
|
+
ambiguity: "approved-component-export",
|
|
508
|
+
},
|
|
509
|
+
attributes: {
|
|
510
|
+
...(imported || isRawHtmlAdvisoryTier(precisionTier) ? { advisory: true } : {}),
|
|
511
|
+
precisionTier,
|
|
512
|
+
unresolvedCanonicalExport: true,
|
|
513
|
+
contractProof: "missing",
|
|
514
|
+
},
|
|
515
|
+
})
|
|
516
|
+
);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const importPath = sourceQualified ? target?.importPath : mapped.importPath;
|
|
520
|
+
const suggestedComponent = target?.exportName ?? mapped.name;
|
|
354
521
|
const suggestedImport = importPath ?? "the canonical library";
|
|
355
522
|
const propMapping = mapped.propMapping ?? [];
|
|
356
523
|
const propCompatibility = assessMappedPropCompatibility(props, propMapping);
|
|
357
|
-
const roleMatch = isRoleMatch(node, mapped);
|
|
358
|
-
const precisionTier: RawHtmlPrecisionTier = roleMatch ? "role-reimpl" : "exact-html";
|
|
359
524
|
|
|
360
525
|
if (
|
|
361
526
|
imported &&
|
|
@@ -494,20 +659,29 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
494
659
|
ruleId: RULE_ID,
|
|
495
660
|
ruleVersion: RULE_VERSION,
|
|
496
661
|
severity: capAdvisorySeverity(policy.severity),
|
|
497
|
-
message:
|
|
662
|
+
message: reimpl.unresolved
|
|
663
|
+
? `<${node.element} class="${reimpl.matchedClass}"> resembles an approved component. Choose an exact implementation and public export before replacing it.`
|
|
664
|
+
: `<${node.element} class="${reimpl.matchedClass}"> looks like a hand-rolled <${reimpl.canonical}>. Use <${reimpl.canonical}> from ${suggestedImport} instead of restyling a <${node.element}>.`,
|
|
498
665
|
location: node.location,
|
|
499
666
|
evidence: ix.evidence([node.id, policy.id]),
|
|
500
667
|
fingerprintIdentity: {
|
|
501
668
|
file: node.file,
|
|
502
669
|
nodePath: node.nodePath,
|
|
503
670
|
element: node.element,
|
|
504
|
-
suggestedComponent: reimpl.canonical,
|
|
671
|
+
...(reimpl.unresolved ? {} : { suggestedComponent: reimpl.canonical }),
|
|
505
672
|
matchedClass: reimpl.matchedClass,
|
|
506
673
|
},
|
|
507
674
|
attributes: {
|
|
508
675
|
rawValue: node.element,
|
|
509
|
-
suggestedComponent: reimpl.canonical,
|
|
676
|
+
...(reimpl.unresolved ? {} : { suggestedComponent: reimpl.canonical }),
|
|
510
677
|
...(reimpl.import ? { suggestedImport: reimpl.import } : {}),
|
|
678
|
+
...(reimpl.unresolved ? { unresolvedCanonicalExport: true } : {}),
|
|
679
|
+
...(reimpl.alternatives
|
|
680
|
+
? {
|
|
681
|
+
ambiguousCanonicalTarget: true,
|
|
682
|
+
canonicalAlternatives: reimpl.alternatives,
|
|
683
|
+
}
|
|
684
|
+
: {}),
|
|
511
685
|
precisionTier: "classname-reimpl",
|
|
512
686
|
matchedClass: reimpl.matchedClass,
|
|
513
687
|
advisory: true,
|
|
@@ -521,9 +695,38 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
|
|
|
521
695
|
const builtIn = findBuiltInRawHtmlMatch(node, props, textChildren, nodeInputType);
|
|
522
696
|
const suggestedComponent =
|
|
523
697
|
builtIn?.canonical ?? (node.element === "button" ? "Button" : node.element);
|
|
524
|
-
const suggestion = findSuggestionSource(suggestedComponent, sources);
|
|
525
|
-
if (!suggestion) continue;
|
|
526
698
|
if (!builtIn && !shouldSuggest(node, imported)) continue;
|
|
699
|
+
const suggestion = findSuggestionSource(suggestedComponent, sources, sourceQualified);
|
|
700
|
+
if (suggestion === null) {
|
|
701
|
+
findings.push(
|
|
702
|
+
makeFinding({
|
|
703
|
+
ruleId: RULE_ID,
|
|
704
|
+
ruleVersion: RULE_VERSION,
|
|
705
|
+
severity: capAdvisorySeverity(policy.severity),
|
|
706
|
+
message: `<${node.element}> has multiple approved component sources. Choose the intended source before applying a replacement.`,
|
|
707
|
+
location: node.location,
|
|
708
|
+
evidence: ix.evidence(
|
|
709
|
+
imported ? [node.id, imported.id, policy.id] : [node.id, policy.id]
|
|
710
|
+
),
|
|
711
|
+
fingerprintIdentity: {
|
|
712
|
+
file: node.file,
|
|
713
|
+
nodePath: node.nodePath,
|
|
714
|
+
element: node.element,
|
|
715
|
+
ambiguity: "approved-component-sources",
|
|
716
|
+
},
|
|
717
|
+
attributes: {
|
|
718
|
+
advisory: true,
|
|
719
|
+
ambiguousCanonicalTarget: true,
|
|
720
|
+
contractProof: "missing",
|
|
721
|
+
canonicalAlternatives: suggestionSources(suggestedComponent, sources)
|
|
722
|
+
.map((source) => qualifiedSourceIdentity(source, suggestedComponent))
|
|
723
|
+
.sort(),
|
|
724
|
+
},
|
|
725
|
+
})
|
|
726
|
+
);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (!suggestion) continue;
|
|
527
730
|
|
|
528
731
|
// An exact component-name match is either already canonical (handled near
|
|
529
732
|
// the top of the loop), a safely redirectable local component import, or an
|
|
@@ -1063,54 +1266,78 @@ function hasValidComponentList(value: unknown): boolean {
|
|
|
1063
1266
|
function findMapping(
|
|
1064
1267
|
node: UsageNodeFact,
|
|
1065
1268
|
nodeInputType: string | undefined,
|
|
1066
|
-
mappings: readonly CanonicalMappingOption[]
|
|
1067
|
-
|
|
1269
|
+
mappings: readonly CanonicalMappingOption[],
|
|
1270
|
+
sourceQualified = false,
|
|
1271
|
+
imported?: UsageImportFact
|
|
1272
|
+
): CanonicalMappingOption | null | undefined {
|
|
1273
|
+
const choose = (matches: CanonicalMappingOption[]) => {
|
|
1274
|
+
if (!sourceQualified) return matches[0];
|
|
1275
|
+
const exact = imported
|
|
1276
|
+
? matches.filter((mapping) => isMappedCanonicalImport(imported, mapping, true))
|
|
1277
|
+
: [];
|
|
1278
|
+
const candidates = exact.length > 0 ? exact : matches;
|
|
1279
|
+
const identities = new Map(
|
|
1280
|
+
candidates.map((mapping) => [
|
|
1281
|
+
mapping.source
|
|
1282
|
+
? contractComponentSourceKey(mapping.source)
|
|
1283
|
+
: JSON.stringify([mapping.importPath, mapping.name]),
|
|
1284
|
+
mapping,
|
|
1285
|
+
])
|
|
1286
|
+
);
|
|
1287
|
+
return identities.size > 1 ? null : candidates[0];
|
|
1288
|
+
};
|
|
1068
1289
|
// A type-specific input mapping wins over a generic tag-level one, so a
|
|
1069
1290
|
// library mapping `<input type="checkbox">` → Checkbox is never shadowed by a
|
|
1070
1291
|
// catch-all `<input>` → TextField entry. Normalized to lower-case to mirror
|
|
1071
1292
|
// the resolved `type` prop value the extractor records.
|
|
1072
1293
|
if (node.element === "input" && nodeInputType) {
|
|
1073
|
-
const typed =
|
|
1074
|
-
(
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1294
|
+
const typed = choose(
|
|
1295
|
+
mappings.filter(
|
|
1296
|
+
(mapping) =>
|
|
1297
|
+
sameHtmlTag(mapping.htmlEquivalent, "input") &&
|
|
1298
|
+
mapping.htmlType !== undefined &&
|
|
1299
|
+
mapping.htmlType.toLowerCase() === nodeInputType.toLowerCase()
|
|
1300
|
+
)
|
|
1078
1301
|
);
|
|
1079
|
-
if (typed) return typed;
|
|
1302
|
+
if (typed !== undefined) return typed;
|
|
1080
1303
|
}
|
|
1081
1304
|
|
|
1082
|
-
return
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
tag
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1305
|
+
return choose(
|
|
1306
|
+
mappings.filter((mapping) => {
|
|
1307
|
+
if (sourceQualified && imported && isMappedCanonicalImport(imported, mapping, true))
|
|
1308
|
+
return true;
|
|
1309
|
+
if (
|
|
1310
|
+
sameHtmlTag(mapping.htmlEquivalent, node.element) &&
|
|
1311
|
+
// Match enforceability against the EMITTED `mapping.name` (see
|
|
1312
|
+
// shouldSuggestMapped): for ambiguous tags this requires the suggested
|
|
1313
|
+
// component to BE the tag's curated canonical, so a disagreeing
|
|
1314
|
+
// `<button>` → Chip mapping never matches in the first place.
|
|
1315
|
+
isEnforceableHtmlEquivalent({
|
|
1316
|
+
tag: node.element,
|
|
1317
|
+
canonical: mapping.name,
|
|
1318
|
+
inputType: nodeInputType,
|
|
1319
|
+
explicit: isExplicitTagDeclaration(mapping),
|
|
1320
|
+
})
|
|
1321
|
+
) {
|
|
1322
|
+
// A type-constrained mapping only matches the matching input type; an
|
|
1323
|
+
// unconstrained one matches any element with that tag (back-compat).
|
|
1324
|
+
// Gated so an inferred `div`/`span` primitive (or a `button` → specialized
|
|
1325
|
+
// component) never blanket-flags every container — see
|
|
1326
|
+
// isEnforceableHtmlEquivalent.
|
|
1327
|
+
return mapping.htmlType === undefined || matchesInputType(mapping, nodeInputType);
|
|
1328
|
+
}
|
|
1329
|
+
if (mapping.name === node.element) return true;
|
|
1330
|
+
// Deliberately NOT matching on `mapping.canonical` (the vocabulary id): see
|
|
1331
|
+
// shouldSuggestMapped. Selecting a mapping purely because the element name
|
|
1332
|
+
// equals a canonical vocab id (Dialog/Tooltip/Card…) produced divergent swaps
|
|
1333
|
+
// and cross-mapping hijacking (audit #1/#2). A real name match (above), an
|
|
1334
|
+
// enforceable htmlEquivalent (above), or an ARIA-role match (below) remain.
|
|
1335
|
+
// Semantic match: a bespoke element whose declared ARIA role equals the
|
|
1336
|
+
// canonical primitive's role (e.g. `<div role="status">` → Toast). Keyed on
|
|
1337
|
+
// normalized `usage_node.role`, so it is framework-agnostic.
|
|
1338
|
+
return node.role !== undefined && mapping.ariaRole === node.role;
|
|
1339
|
+
})
|
|
1340
|
+
);
|
|
1114
1341
|
}
|
|
1115
1342
|
|
|
1116
1343
|
function matchesInputType(
|
|
@@ -1133,15 +1360,50 @@ function isRoleMatch(node: UsageNodeFact, mapping: CanonicalMappingOption): bool
|
|
|
1133
1360
|
);
|
|
1134
1361
|
}
|
|
1135
1362
|
|
|
1136
|
-
function isMappedCanonicalImport(
|
|
1137
|
-
|
|
1363
|
+
function isMappedCanonicalImport(
|
|
1364
|
+
usage: UsageImportFact,
|
|
1365
|
+
mapping: CanonicalMappingOption,
|
|
1366
|
+
sourceQualified = false
|
|
1367
|
+
): boolean {
|
|
1368
|
+
if (!sourceQualified)
|
|
1369
|
+
return Boolean(mapping.importPath && ownedImportsEqual(usage.source, mapping.importPath));
|
|
1370
|
+
// A public barrel alias has its own export name; the implementation symbol
|
|
1371
|
+
// does not authorize arbitrary names at the package's root.
|
|
1372
|
+
if (
|
|
1373
|
+
mapping.exportAddresses?.some(
|
|
1374
|
+
(address) =>
|
|
1375
|
+
address.kind === "package" &&
|
|
1376
|
+
ownedImportsEqual(usage.source, address.importPath) &&
|
|
1377
|
+
usage.imported === address.exportName
|
|
1378
|
+
)
|
|
1379
|
+
)
|
|
1380
|
+
return true;
|
|
1381
|
+
return Boolean(
|
|
1382
|
+
mapping.source?.kind === "package" &&
|
|
1383
|
+
ownedImportsEqual(usage.source, mapping.source.importPath) &&
|
|
1384
|
+
usage.imported === mapping.source.exportName
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** Current replacement fixes need a component binding, not default-import syntax. */
|
|
1389
|
+
function isComponentBinding(name: string): boolean {
|
|
1390
|
+
return isIdentifier(name) && !/^[a-z]/.test(name);
|
|
1138
1391
|
}
|
|
1139
1392
|
|
|
1140
1393
|
function isCanonicalImport(
|
|
1141
1394
|
usage: UsageImportFact,
|
|
1142
1395
|
componentName: string,
|
|
1143
|
-
sources: readonly CanonicalSource[]
|
|
1396
|
+
sources: readonly CanonicalSource[],
|
|
1397
|
+
sourceQualified = false
|
|
1144
1398
|
): boolean {
|
|
1399
|
+
if (sourceQualified)
|
|
1400
|
+
return sources.some(
|
|
1401
|
+
(source) =>
|
|
1402
|
+
canonicalImportPath(source) !== undefined &&
|
|
1403
|
+
source.include?.includes(usage.imported) &&
|
|
1404
|
+
!source.exclude?.includes(usage.imported) &&
|
|
1405
|
+
ownedImportsEqual(usage.source, canonicalImportPath(source)!)
|
|
1406
|
+
);
|
|
1145
1407
|
return sources.some((source) => {
|
|
1146
1408
|
if (!sourceIncludesComponent(source, componentName)) return false;
|
|
1147
1409
|
if (source.exclude?.includes(componentName)) return false;
|
|
@@ -1158,9 +1420,26 @@ function isCanonicalImport(
|
|
|
1158
1420
|
|
|
1159
1421
|
function findSuggestionSource(
|
|
1160
1422
|
componentName: string,
|
|
1161
|
-
sources: readonly CanonicalSource[]
|
|
1162
|
-
|
|
1163
|
-
|
|
1423
|
+
sources: readonly CanonicalSource[],
|
|
1424
|
+
sourceQualified = false
|
|
1425
|
+
): CanonicalSource | null | undefined {
|
|
1426
|
+
const matches = suggestionSources(componentName, sources);
|
|
1427
|
+
if (!sourceQualified) return matches[0];
|
|
1428
|
+
const identities = new Set(
|
|
1429
|
+
matches.map((source) => qualifiedSourceIdentity(source, componentName))
|
|
1430
|
+
);
|
|
1431
|
+
if (identities.size > 1) return null;
|
|
1432
|
+
const source = matches[0];
|
|
1433
|
+
if (source?.kind === "npm")
|
|
1434
|
+
return { ...source, specifier: canonicalizeOwnedImport(source.specifier) };
|
|
1435
|
+
if (source?.kind === "registry" && source.importPath) {
|
|
1436
|
+
return { ...source, importPath: canonicalizeOwnedImport(source.importPath) };
|
|
1437
|
+
}
|
|
1438
|
+
return source;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function suggestionSources(componentName: string, sources: readonly CanonicalSource[]) {
|
|
1442
|
+
return sources.filter(
|
|
1164
1443
|
(source) =>
|
|
1165
1444
|
sourceIncludesComponent(source, componentName) && !source.exclude?.includes(componentName)
|
|
1166
1445
|
);
|