@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.
@@ -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 reimplTargets = canonicalReimplTargets(sources, mappings);
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 importPath = mapped.importPath;
353
- const suggestedComponent = mapped.name;
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: `<${node.element} class="${reimpl.matchedClass}"> looks like a hand-rolled <${reimpl.canonical}>. Use <${reimpl.canonical}> from ${suggestedImport} instead of restyling a <${node.element}>.`,
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
- ): CanonicalMappingOption | undefined {
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 = mappings.find(
1074
- (mapping) =>
1075
- sameHtmlTag(mapping.htmlEquivalent, "input") &&
1076
- mapping.htmlType !== undefined &&
1077
- mapping.htmlType.toLowerCase() === nodeInputType.toLowerCase()
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 mappings.find((mapping) => {
1083
- if (
1084
- sameHtmlTag(mapping.htmlEquivalent, node.element) &&
1085
- // Match enforceability against the EMITTED `mapping.name` (see
1086
- // shouldSuggestMapped): for ambiguous tags this requires the suggested
1087
- // component to BE the tag's curated canonical, so a disagreeing
1088
- // `<button>` Chip mapping never matches in the first place.
1089
- isEnforceableHtmlEquivalent({
1090
- tag: node.element,
1091
- canonical: mapping.name,
1092
- inputType: nodeInputType,
1093
- explicit: isExplicitTagDeclaration(mapping),
1094
- })
1095
- ) {
1096
- // A type-constrained mapping only matches the matching input type; an
1097
- // unconstrained one matches any element with that tag (back-compat).
1098
- // Gated so an inferred `div`/`span` primitive (or a `button` → specialized
1099
- // component) never blanket-flags every container see
1100
- // isEnforceableHtmlEquivalent.
1101
- return mapping.htmlType === undefined || matchesInputType(mapping, nodeInputType);
1102
- }
1103
- if (mapping.name === node.element) return true;
1104
- // Deliberately NOT matching on `mapping.canonical` (the vocabulary id): see
1105
- // shouldSuggestMapped. Selecting a mapping purely because the element name
1106
- // equals a canonical vocab id (Dialog/Tooltip/Card…) produced divergent swaps
1107
- // and cross-mapping hijacking (audit #1/#2). A real name match (above), an
1108
- // enforceable htmlEquivalent (above), or an ARIA-role match (below) remain.
1109
- // Semantic match: a bespoke element whose declared ARIA role equals the
1110
- // canonical primitive's role (e.g. `<div role="status">` Toast). Keyed on
1111
- // normalized `usage_node.role`, so it is framework-agnostic.
1112
- return node.role !== undefined && mapping.ariaRole === node.role;
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(usage: UsageImportFact, mapping: CanonicalMappingOption): boolean {
1137
- return Boolean(mapping.importPath && ownedImportsEqual(usage.source, mapping.importPath));
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
- ): CanonicalSource | undefined {
1163
- return sources.find(
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
  );