@kerfjs/ui 5.0.0-beta.69 → 5.0.0-beta.70

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.
Files changed (63) hide show
  1. package/ai/component-catalog.json +11 -1
  2. package/ai/component-catalog.schema.json +1 -1
  3. package/ai/component-composition.json +1 -0
  4. package/ai/component-composition.schema.json +1 -1
  5. package/ai/components/lucide-icon.md +12 -0
  6. package/ai/public-api-signatures-v1.md +7 -3
  7. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  8. package/analyzer/component-ownership.d.mts +16 -0
  9. package/analyzer/component-ownership.mjs +87 -4
  10. package/analyzer/index.d.mts +2 -0
  11. package/analyzer/index.mjs +490 -9
  12. package/dist/catalog.js +3 -3
  13. package/dist/{chunk-2ZOLX3IE.js → chunk-DB5ADJKF.js} +2 -2
  14. package/dist/{chunk-2ZOLX3IE.js.map → chunk-DB5ADJKF.js.map} +1 -1
  15. package/dist/{chunk-X3X4KRDK.js → chunk-DC6CN2DS.js} +2 -2
  16. package/dist/{chunk-X3X4KRDK.js.map → chunk-DC6CN2DS.js.map} +1 -1
  17. package/dist/{chunk-AUVG54KC.js → chunk-G4S2QU6V.js} +2 -2
  18. package/dist/{chunk-AUVG54KC.js.map → chunk-G4S2QU6V.js.map} +1 -1
  19. package/dist/{chunk-CRVDF4A6.js → chunk-K67Y2JNF.js} +2 -2
  20. package/dist/{chunk-CRVDF4A6.js.map → chunk-K67Y2JNF.js.map} +1 -1
  21. package/dist/{chunk-H7IODXGY.js → chunk-KCCEH7RF.js} +3 -3
  22. package/dist/{chunk-H7IODXGY.js.map → chunk-KCCEH7RF.js.map} +1 -1
  23. package/dist/{chunk-3BCJGM36.js → chunk-M6DEEBEF.js} +2 -2
  24. package/dist/{chunk-3BCJGM36.js.map → chunk-M6DEEBEF.js.map} +1 -1
  25. package/dist/{chunk-X3TPECWG.js → chunk-OXO2QJWV.js} +3 -1
  26. package/dist/chunk-OXO2QJWV.js.map +1 -0
  27. package/dist/{chunk-B3XWUX3A.js → chunk-RNQNEENY.js} +2 -2
  28. package/dist/{chunk-B3XWUX3A.js.map → chunk-RNQNEENY.js.map} +1 -1
  29. package/dist/{chunk-PQZPVBP4.js → chunk-RVERAFFY.js} +2 -2
  30. package/dist/{chunk-PQZPVBP4.js.map → chunk-RVERAFFY.js.map} +1 -1
  31. package/dist/{chunk-UA6JW52D.js → chunk-T2E5WBCZ.js} +110 -5
  32. package/dist/chunk-T2E5WBCZ.js.map +1 -0
  33. package/dist/{chunk-MOSIE4Z2.js → chunk-WTSNV5RG.js} +3 -2
  34. package/dist/chunk-WTSNV5RG.js.map +1 -0
  35. package/dist/collapsible-panel.js +3 -3
  36. package/dist/disclosure-arrow.js +1 -1
  37. package/dist/index.js +8 -8
  38. package/dist/list-header.js +1 -1
  39. package/dist/lucide-icon.d.ts +3 -1
  40. package/dist/lucide-icon.js +1 -1
  41. package/dist/nav-stack.js +1 -1
  42. package/dist/select.js +1 -1
  43. package/dist/split-view.js +1 -1
  44. package/dist/styles/app-tab.css +4 -0
  45. package/dist/styles/tab-bar.css +8 -0
  46. package/dist/styles/toolbar-control-group.css +1 -1
  47. package/dist/tab-bar.d.ts +3 -1
  48. package/dist/tab-bar.js +1 -1
  49. package/dist/token-search-field.js +1 -1
  50. package/dist/wire-tab-bars.js +1 -1
  51. package/dist/wire-token-search-fields.js +1 -1
  52. package/dist/workbench.js +1 -1
  53. package/docs/component-contract.md +16 -3
  54. package/docs/component-selection.md +8 -1
  55. package/docs/ui-analyzer.md +30 -9
  56. package/docs/ui-doctor.md +15 -6
  57. package/doctor/config.schema.json +2 -0
  58. package/doctor/index.d.mts +2 -0
  59. package/doctor/index.mjs +21 -0
  60. package/package.json +1 -1
  61. package/dist/chunk-MOSIE4Z2.js.map +0 -1
  62. package/dist/chunk-UA6JW52D.js.map +0 -1
  63. package/dist/chunk-X3TPECWG.js.map +0 -1
@@ -18,6 +18,7 @@ import {
18
18
  componentLabel,
19
19
  componentName,
20
20
  componentOwnershipFacts,
21
+ contextualComponents,
21
22
  configurationFor,
22
23
  privateVariableOwner,
23
24
  reportGap,
@@ -80,6 +81,10 @@ export const UI_ANALYSIS_RULES = Object.freeze({
80
81
  severity: 'error',
81
82
  title: "Hook class restyles a cataloged component's root",
82
83
  },
84
+ 'KUI-L023': {
85
+ severity: 'error',
86
+ title: "Another component's public class is rendered as markup",
87
+ },
83
88
  });
84
89
 
85
90
  const ownershipAction =
@@ -92,6 +97,7 @@ export const UI_ANALYSIS_ACTIONS = Object.freeze({
92
97
  'KUI-L021':
93
98
  'Set the typed prop the diagnostic names on the component instead of overriding its token.',
94
99
  'KUI-L022': ownershipAction,
100
+ 'KUI-L023': ownershipAction,
95
101
  });
96
102
 
97
103
  const adoptionRules = new Set([
@@ -104,6 +110,7 @@ const adoptionRules = new Set([
104
110
  'KUI-L020',
105
111
  'KUI-L021',
106
112
  'KUI-L022',
113
+ 'KUI-L023',
107
114
  ]);
108
115
 
109
116
  const sourceExtensions = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']);
@@ -243,7 +250,7 @@ function reachableStyleFiles(file, imports, styleFacts) {
243
250
  return visited;
244
251
  }
245
252
 
246
- async function loadCatalogs(profileResult) {
253
+ async function loadCatalogs(profileResult, ownership) {
247
254
  const entries = [];
248
255
  for (const catalog of profileResult.profile?.catalogs ?? []) {
249
256
  const owner = profileResult.provenance[`$catalogs.${catalog.package}`];
@@ -256,8 +263,10 @@ async function loadCatalogs(profileResult) {
256
263
  ),
257
264
  );
258
265
  const selection = await loadSelectionFacts(owner, catalog);
266
+ const joined = new Set();
259
267
  for (const entry of artifact.entries ?? []) {
260
268
  const selected = selection.get(entry.key);
269
+ joined.add(entry.key);
261
270
  const componentSource =
262
271
  selected?.componentSource ??
263
272
  (/\.[cm]?[jt]sx?$/.test(entry.source) ? entry.source : undefined);
@@ -269,10 +278,35 @@ async function loadCatalogs(profileResult) {
269
278
  moduleImports: selected.moduleImports,
270
279
  componentSource,
271
280
  styleSources: entry.styleSources ?? selected.styleSources,
281
+ catalogDirectory: dirname(owner),
272
282
  }
273
- : { ...entry, componentSource },
283
+ : { ...entry, componentSource, catalogDirectory: dirname(owner) },
274
284
  );
275
285
  }
286
+ for (const [key, selected] of selection) {
287
+ if (
288
+ ownership !== 'component' ||
289
+ joined.has(key) ||
290
+ !selected.componentSource
291
+ )
292
+ continue;
293
+ entries.push({
294
+ key,
295
+ package: catalog.package,
296
+ id: selected.id,
297
+ name: selected.name,
298
+ kind: selected.kind,
299
+ componentSource: selected.componentSource,
300
+ styleSources: selected.styleSources,
301
+ moduleImports: selected.moduleImports,
302
+ publicExports: selected.publicExports,
303
+ boundaries: {
304
+ publicClasses: selected.publicClasses ?? [],
305
+ rootElement: selected.rootElement,
306
+ },
307
+ catalogDirectory: dirname(owner),
308
+ });
309
+ }
276
310
  } catch {
277
311
  // Profile validation reports the precise catalog load failure.
278
312
  }
@@ -299,7 +333,12 @@ async function loadSelectionFacts(owner, catalog) {
299
333
  for (const entry of artifact.entries ?? []) {
300
334
  const delivery = entry.delivery ?? {};
301
335
  facts.set(`${packageName}:${entry.id}`, {
336
+ id: entry.id,
337
+ name: entry.name,
338
+ kind: entry.kind,
302
339
  publicExports: entry.publicExports,
340
+ publicClasses: entry.publicClasses,
341
+ rootElement: entry.rootElement,
303
342
  componentSource: entry.source,
304
343
  styleSources: entry.styleSources,
305
344
  moduleImports: [delivery.browserImport, delivery.moduleImport].filter(
@@ -429,6 +468,7 @@ function catalogFacts(entries) {
429
468
  }
430
469
  }
431
470
  return {
471
+ entries,
432
472
  publicClasses,
433
473
  publicTokens,
434
474
  publicParts,
@@ -438,6 +478,129 @@ function catalogFacts(entries) {
438
478
  };
439
479
  }
440
480
 
481
+ const bemBlock = (name) => name.split(/__|--/, 1)[0];
482
+
483
+ function renderedLiteralClasses(file, source) {
484
+ const names = new Set();
485
+ const parsed = ts.createSourceFile(
486
+ file,
487
+ source,
488
+ ts.ScriptTarget.Latest,
489
+ true,
490
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.JSX,
491
+ );
492
+ const visit = (node) => {
493
+ if (
494
+ ts.isJsxAttribute(node) &&
495
+ ['class', 'className'].includes(node.name.text)
496
+ ) {
497
+ const value =
498
+ node.initializer && ts.isStringLiteral(node.initializer)
499
+ ? node.initializer.text
500
+ : node.initializer &&
501
+ ts.isJsxExpression(node.initializer) &&
502
+ node.initializer.expression &&
503
+ ts.isStringLiteral(node.initializer.expression)
504
+ ? node.initializer.expression.text
505
+ : undefined;
506
+ for (const name of value?.split(/\s+/) ?? []) if (name) names.add(name);
507
+ }
508
+ ts.forEachChild(node, visit);
509
+ };
510
+ visit(parsed);
511
+ return names;
512
+ }
513
+
514
+ function inferOwnedClasses(entries, contents, imports) {
515
+ return entries.map((entry) => {
516
+ if (!entry.componentSource || !entry.catalogDirectory) return entry;
517
+ const sourceFile = resolve(entry.catalogDirectory, entry.componentSource);
518
+ const source = contents.get(sourceFile);
519
+ if (!source) return entry;
520
+ const styleFiles = entry.styleSources?.length
521
+ ? entry.styleSources.map((path) => resolve(entry.catalogDirectory, path))
522
+ : [...(imports.get(sourceFile) ?? [])];
523
+ const styleSources = styleFiles.map((file) =>
524
+ relative(entry.catalogDirectory, file).replaceAll('\\', '/'),
525
+ );
526
+ const styledBlocks = new Set();
527
+ for (const style of styleFiles) {
528
+ const css = contents.get(style);
529
+ if (!css) continue;
530
+ try {
531
+ postcss.parse(css, { from: style }).walkRules((rule) => {
532
+ for (const name of subjectClasses(rule.selector))
533
+ styledBlocks.add(bemBlock(name));
534
+ });
535
+ } catch {
536
+ // The stylesheet parser reports malformed CSS in the normal pass.
537
+ }
538
+ }
539
+ const inferred = [...renderedLiteralClasses(sourceFile, source)].filter(
540
+ (name) => styledBlocks.has(bemBlock(name)),
541
+ );
542
+ if (!inferred.length) return { ...entry, styleSources };
543
+ return {
544
+ ...entry,
545
+ styleSources,
546
+ boundaries: {
547
+ ...entry.boundaries,
548
+ publicClasses: [
549
+ ...new Set([...(entry.boundaries?.publicClasses ?? []), ...inferred]),
550
+ ],
551
+ },
552
+ };
553
+ });
554
+ }
555
+
556
+ async function implicitOwnershipEntries(files, imports, root) {
557
+ const found = [];
558
+ const packages = new Map();
559
+ const packageAt = async (directory) => {
560
+ if (packages.has(directory)) return packages.get(directory);
561
+ let result;
562
+ try {
563
+ const manifest = JSON.parse(
564
+ await readFile(resolve(directory, 'package.json'), 'utf8'),
565
+ );
566
+ if (typeof manifest.name === 'string')
567
+ result = { name: manifest.name, directory };
568
+ } catch {
569
+ // Search the containing directory for a package manifest.
570
+ }
571
+ if (!result && directory !== root) {
572
+ const parent = dirname(directory);
573
+ if (!relative(root, parent).startsWith('..'))
574
+ result = await packageAt(parent);
575
+ }
576
+ packages.set(directory, result);
577
+ return result;
578
+ };
579
+ for (const file of files) {
580
+ if (!sourceExtensions.has(extname(file))) continue;
581
+ const styles = (imports.get(file) ?? []).filter((path) =>
582
+ files.includes(path),
583
+ );
584
+ if (!styles.length) continue;
585
+ const owner = await packageAt(dirname(file));
586
+ if (!owner) continue;
587
+ const source = relative(owner.directory, file).replaceAll('\\', '/');
588
+ const id = `module:${source}`;
589
+ found.push({
590
+ key: `${owner.name}:${id}`,
591
+ package: owner.name,
592
+ id,
593
+ name: basename(file).replace(/\.[^.]+$/, ''),
594
+ componentSource: source,
595
+ catalogDirectory: owner.directory,
596
+ styleSources: styles.map((style) =>
597
+ relative(owner.directory, style).replaceAll('\\', '/'),
598
+ ),
599
+ });
600
+ }
601
+ return found;
602
+ }
603
+
441
604
  function spacingValues(value) {
442
605
  const results = [];
443
606
  for (const match of value.matchAll(/(-?\d*\.?\d+)(px|rem)\b/g)) {
@@ -480,16 +643,20 @@ function inspectComponentOwnership(
480
643
  diagnostics,
481
644
  adoption,
482
645
  isForeign,
646
+ ownershipEvidence,
647
+ ownershipContext,
483
648
  ) {
484
649
  if (inKeyframes(rule)) return;
485
650
  const restyling = restylingDeclarations(rule);
486
651
  if (restyling.length === 0) return;
487
652
  const properties = [...new Set(restyling.map((decl) => decl.prop))];
488
- for (const { entry, via, name } of restyledComponents(
653
+ const restyled = restyledComponents(
489
654
  rule.selector,
490
655
  facts.ownership,
491
656
  isForeign,
492
- ))
657
+ ownershipEvidence,
658
+ );
659
+ for (const { entry, via, name } of restyled)
493
660
  diagnostics.push(
494
661
  diagnostic(
495
662
  'KUI-L019',
@@ -508,6 +675,38 @@ function inspectComponentOwnership(
508
675
  adoption,
509
676
  ),
510
677
  );
678
+ if (ownershipContext !== 'any' || !ownershipEvidence?.ownPackage) return;
679
+ for (const { entry, name, position } of contextualComponents(
680
+ rule.selector,
681
+ facts.ownership,
682
+ isForeign,
683
+ ownershipEvidence.ownPackage,
684
+ )) {
685
+ if (position === 'subject') continue;
686
+ if (
687
+ restyled.some(
688
+ (item) => item.entry.key === entry.key && item.name === name,
689
+ )
690
+ )
691
+ continue;
692
+ diagnostics.push(
693
+ diagnostic(
694
+ 'KUI-L019',
695
+ location(file, rule),
696
+ `\`${name}\` uses ${componentLabel(entry)} as ${position} selector context (${properties.join(', ')}); in strict component ownership, sibling classes cannot key this stylesheet. Configure the component through ${configurationFor(entry)}. ${reportGap(entry)}`,
697
+ {
698
+ selector: rule.selector,
699
+ component: entry.key,
700
+ via: 'context',
701
+ position,
702
+ target: name,
703
+ properties,
704
+ },
705
+ undefined,
706
+ adoption,
707
+ ),
708
+ );
709
+ }
511
710
  }
512
711
 
513
712
  function inspectVariableOwnership(
@@ -577,6 +776,8 @@ async function inspectCss(
577
776
  adoption = false,
578
777
  siblingOnLoud = [],
579
778
  isForeign = () => true,
779
+ ownershipEvidence,
780
+ ownershipContext = 'subject',
580
781
  ) {
581
782
  let root;
582
783
  try {
@@ -609,6 +810,8 @@ async function inspectCss(
609
810
  diagnostics,
610
811
  adoption,
611
812
  isForeign,
813
+ ownershipEvidence,
814
+ ownershipContext,
612
815
  );
613
816
  if (!inKeyframes(rule) && restylingDeclarations(rule).length > 0)
614
817
  for (const className of subjects) {
@@ -775,6 +978,164 @@ function literalClasses(attribute) {
775
978
  return { values: [], dynamic: true };
776
979
  }
777
980
 
981
+ function literalClassValues(expression) {
982
+ if (!expression) return [];
983
+ if (ts.isStringLiteralLike(expression))
984
+ return expression.text.split(/\s+/).filter(Boolean);
985
+ if (ts.isParenthesizedExpression(expression))
986
+ return literalClassValues(expression.expression);
987
+ if (ts.isConditionalExpression(expression))
988
+ return [
989
+ ...literalClassValues(expression.whenTrue),
990
+ ...literalClassValues(expression.whenFalse),
991
+ ];
992
+ return [];
993
+ }
994
+
995
+ function jsxOwnershipEvidence(style, facts, contents, root, compilerCache) {
996
+ const hooks = new Map();
997
+ const composedChildren = new Map();
998
+ const ambiguousHooks = new Set();
999
+ const ambiguousChildren = new Set();
1000
+ const record = (map, ambiguous, key, entry) => {
1001
+ if (ambiguous.has(key)) return;
1002
+ if (map.has(key) && map.get(key).key !== entry.key) {
1003
+ map.delete(key);
1004
+ ambiguous.add(key);
1005
+ } else map.set(key, entry);
1006
+ };
1007
+ for (const owner of facts.entries) {
1008
+ if (!owner.componentSource || !owner.catalogDirectory) continue;
1009
+ const relativeStyle = relative(owner.catalogDirectory, style).replaceAll(
1010
+ '\\',
1011
+ '/',
1012
+ );
1013
+ if (!owner.styleSources?.includes(relativeStyle)) continue;
1014
+ const file = resolve(owner.catalogDirectory, owner.componentSource);
1015
+ const sourceText = contents.get(file);
1016
+ if (!sourceText) continue;
1017
+ const source = ts.createSourceFile(
1018
+ file,
1019
+ sourceText,
1020
+ ts.ScriptTarget.Latest,
1021
+ true,
1022
+ ts.ScriptKind.TSX,
1023
+ );
1024
+ const imports = new Map();
1025
+ const namespaces = new Map();
1026
+ const compilerOptions = compilerOptionsFor(file, root, compilerCache);
1027
+ const aliasSource = (module) =>
1028
+ module && !module.startsWith('.') && compilerOptions?.paths
1029
+ ? ts.resolveModuleName(module, file, compilerOptions, ts.sys)
1030
+ .resolvedModule?.resolvedFileName
1031
+ : undefined;
1032
+ for (const statement of source.statements) {
1033
+ if (
1034
+ !ts.isImportDeclaration(statement) ||
1035
+ !ts.isStringLiteral(statement.moduleSpecifier)
1036
+ )
1037
+ continue;
1038
+ const module = statement.moduleSpecifier.text;
1039
+ const bindings = statement.importClause?.namedBindings;
1040
+ if (bindings && ts.isNamespaceImport(bindings)) {
1041
+ namespaces.set(bindings.name.text, module);
1042
+ continue;
1043
+ }
1044
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
1045
+ for (const item of bindings.elements) {
1046
+ const exported = item.propertyName?.text ?? item.name.text;
1047
+ const entry = resolveExportEntry(
1048
+ facts.exportEntries.get(exported),
1049
+ exported,
1050
+ module,
1051
+ file,
1052
+ owner.catalogDirectory,
1053
+ aliasSource(module),
1054
+ );
1055
+ if (entry) imports.set(item.name.text, entry);
1056
+ }
1057
+ }
1058
+ const openingOf = (node) =>
1059
+ ts.isJsxElement(node) ? node.openingElement : node;
1060
+ const entryFor = (opening) => {
1061
+ const tag = opening.tagName.getText(source);
1062
+ if (imports.has(tag)) return imports.get(tag);
1063
+ if (
1064
+ ts.isPropertyAccessExpression(opening.tagName) &&
1065
+ ts.isIdentifier(opening.tagName.expression)
1066
+ ) {
1067
+ const module = namespaces.get(opening.tagName.expression.text);
1068
+ return resolveExportEntry(
1069
+ facts.exportEntries.get(opening.tagName.name.text),
1070
+ opening.tagName.name.text,
1071
+ module,
1072
+ file,
1073
+ owner.catalogDirectory,
1074
+ aliasSource(module),
1075
+ );
1076
+ }
1077
+ return undefined;
1078
+ };
1079
+ const classesOf = (opening) =>
1080
+ literalClasses(
1081
+ opening.attributes.properties.find(
1082
+ (item) =>
1083
+ ts.isJsxAttribute(item) &&
1084
+ ['class', 'className'].includes(item.name.getText(source)),
1085
+ ),
1086
+ ).values;
1087
+ const visit = (node) => {
1088
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
1089
+ const opening = openingOf(node);
1090
+ const entry = entryFor(opening);
1091
+ if (entry)
1092
+ for (const name of classesOf(opening))
1093
+ record(hooks, ambiguousHooks, name, entry);
1094
+ if (ts.isJsxElement(node)) {
1095
+ const parentClasses = classesOf(opening);
1096
+ const possible = new Map();
1097
+ let dynamic = false;
1098
+ for (const child of node.children) {
1099
+ if (ts.isJsxExpression(child) && child.expression) dynamic = true;
1100
+ if (!ts.isJsxElement(child) && !ts.isJsxSelfClosingElement(child))
1101
+ continue;
1102
+ const childOpening = openingOf(child);
1103
+ const childEntry = entryFor(childOpening);
1104
+ const type =
1105
+ childEntry?.boundaries?.rootElement ??
1106
+ (childEntry
1107
+ ? undefined
1108
+ : childOpening.tagName.getText(source).toLowerCase());
1109
+ if (!type) continue;
1110
+ const candidates = possible.get(type) ?? [];
1111
+ candidates.push(childEntry);
1112
+ possible.set(type, candidates);
1113
+ }
1114
+ if (!dynamic)
1115
+ for (const [type, candidates] of possible)
1116
+ if (
1117
+ candidates.length > 0 &&
1118
+ candidates.every(
1119
+ (candidate) => candidate?.key === candidates[0]?.key,
1120
+ ) &&
1121
+ candidates[0]
1122
+ )
1123
+ for (const name of parentClasses)
1124
+ record(
1125
+ composedChildren,
1126
+ ambiguousChildren,
1127
+ `${name}|${type}`,
1128
+ candidates[0],
1129
+ );
1130
+ }
1131
+ }
1132
+ ts.forEachChild(node, visit);
1133
+ };
1134
+ visit(source);
1135
+ }
1136
+ return { componentMode: true, hooks, composedChildren };
1137
+ }
1138
+
778
1139
  function cssPreferred(contract) {
779
1140
  return (
780
1141
  [
@@ -1004,6 +1365,28 @@ function inspectTsx(
1004
1365
  }
1005
1366
  }
1006
1367
  const stack = [];
1368
+ const borrowed = (className, node, via) => {
1369
+ for (const owner of facts.classEntries.get(className) ?? []) {
1370
+ if (
1371
+ !isForeign(owner) ||
1372
+ owner.boundaries?.placeableClasses?.includes(className)
1373
+ )
1374
+ continue;
1375
+ diagnostics.push(
1376
+ diagnostic(
1377
+ 'KUI-L023',
1378
+ location(file, node, source),
1379
+ `\`.${className}\` is rendered in this module's ${via} but belongs to ${componentLabel(owner)}. Render that component or configure it through ${configurationFor(owner)}. ${reportGap(owner)}`,
1380
+ { className, component: owner.key, via },
1381
+ undefined,
1382
+ adoption,
1383
+ ),
1384
+ );
1385
+ }
1386
+ };
1387
+ const borrowedValues = (values, node, via) => {
1388
+ for (const className of new Set(values)) borrowed(className, node, via);
1389
+ };
1007
1390
  const visit = (node) => {
1008
1391
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
1009
1392
  const opening = ts.isJsxElement(node) ? node.openingElement : node;
@@ -1028,6 +1411,15 @@ function inspectTsx(
1028
1411
  );
1029
1412
  const classes = literalClasses(classAttribute);
1030
1413
  const at = location(file, opening, source);
1414
+ if (!entry && /^[a-z]/.test(tag))
1415
+ borrowedValues(
1416
+ classAttribute?.initializer &&
1417
+ ts.isJsxExpression(classAttribute.initializer)
1418
+ ? literalClassValues(classAttribute.initializer.expression)
1419
+ : classes.values,
1420
+ classAttribute ?? opening,
1421
+ 'JSX markup',
1422
+ );
1031
1423
  if (entry)
1032
1424
  inspectCssValues(
1033
1425
  file,
@@ -1126,6 +1518,36 @@ function inspectTsx(
1126
1518
  return;
1127
1519
  }
1128
1520
  if (ts.isCallExpression(node)) {
1521
+ if (ts.isPropertyAccessExpression(node.expression)) {
1522
+ const method = node.expression.name.text;
1523
+ const receiver = node.expression.expression;
1524
+ if (
1525
+ ts.isPropertyAccessExpression(receiver) &&
1526
+ receiver.name.text === 'classList'
1527
+ ) {
1528
+ const written =
1529
+ method === 'add'
1530
+ ? node.arguments
1531
+ : method === 'toggle'
1532
+ ? node.arguments.slice(0, 1)
1533
+ : method === 'replace'
1534
+ ? node.arguments.slice(1, 2)
1535
+ : [];
1536
+ for (const value of written)
1537
+ borrowedValues(literalClassValues(value), value, 'classList write');
1538
+ }
1539
+ if (
1540
+ method === 'setAttribute' &&
1541
+ node.arguments[0] &&
1542
+ ts.isStringLiteralLike(node.arguments[0]) &&
1543
+ node.arguments[0].text === 'class'
1544
+ )
1545
+ borrowedValues(
1546
+ literalClassValues(node.arguments[1]),
1547
+ node.arguments[1] ?? node,
1548
+ 'setAttribute write',
1549
+ );
1550
+ }
1129
1551
  const entry = ts.isIdentifier(node.expression)
1130
1552
  ? imports.get(node.expression.text)
1131
1553
  : ts.isPropertyAccessExpression(node.expression) &&
@@ -1150,6 +1572,25 @@ function inspectTsx(
1150
1572
  diagnostics,
1151
1573
  );
1152
1574
  }
1575
+ if (
1576
+ ts.isBinaryExpression(node) &&
1577
+ [ts.SyntaxKind.EqualsToken, ts.SyntaxKind.PlusEqualsToken].includes(
1578
+ node.operatorToken.kind,
1579
+ ) &&
1580
+ ts.isPropertyAccessExpression(node.left) &&
1581
+ node.left.name.text === 'className'
1582
+ )
1583
+ borrowedValues(
1584
+ literalClassValues(node.right),
1585
+ node.right,
1586
+ 'className assignment',
1587
+ );
1588
+ if (ts.isStringLiteralLike(node) && node.text.includes('<')) {
1589
+ for (const match of node.text.matchAll(
1590
+ /<[a-z][^>]*\sclass\s*=\s*["']([^"']+)["']/gi,
1591
+ ))
1592
+ borrowedValues(match[1].split(/\s+/).filter(Boolean), node, 'raw HTML');
1593
+ }
1153
1594
  ts.forEachChild(node, visit);
1154
1595
  };
1155
1596
  visit(source);
@@ -1161,6 +1602,8 @@ export async function analyzeUiProject({
1161
1602
  knownRules = [],
1162
1603
  adoption = false,
1163
1604
  ownership = 'package',
1605
+ ownershipContext = 'subject',
1606
+ implicitComponentOwnership = false,
1164
1607
  profile: packageProfile = resolve(
1165
1608
  import.meta.dirname,
1166
1609
  '../ai/application-ui-profile.defaults.json',
@@ -1196,6 +1639,13 @@ export async function analyzeUiProject({
1196
1639
  }
1197
1640
  }
1198
1641
  files.sort();
1642
+ const imports = new Map(
1643
+ files.map((file) => [file, relativeStyleImports(file, contents.get(file))]),
1644
+ );
1645
+ const implicitEntries =
1646
+ ownership === 'component' && implicitComponentOwnership
1647
+ ? await implicitOwnershipEntries(files, imports, root)
1648
+ : [];
1199
1649
  const profileContexts = new Map();
1200
1650
  const loadContext = async (file) => {
1201
1651
  const startDirectory = file === root ? root : dirname(file);
@@ -1209,10 +1659,30 @@ export async function analyzeUiProject({
1209
1659
  packageProfile,
1210
1660
  knownRules: [...Object.keys(UI_ANALYSIS_RULES), ...knownRules],
1211
1661
  });
1212
- const entries = await loadCatalogs(profileResult);
1662
+ const entries = await loadCatalogs(profileResult, ownership);
1663
+ const catalogedSources = new Set(
1664
+ entries
1665
+ .filter((entry) => entry.componentSource)
1666
+ .map((entry) =>
1667
+ resolve(entry.catalogDirectory, entry.componentSource),
1668
+ ),
1669
+ );
1670
+ const ownershipEntries = [
1671
+ ...entries,
1672
+ ...implicitEntries.filter(
1673
+ (entry) =>
1674
+ !catalogedSources.has(
1675
+ resolve(entry.catalogDirectory, entry.componentSource),
1676
+ ),
1677
+ ),
1678
+ ];
1213
1679
  return {
1214
1680
  profileResult,
1215
- facts: catalogFacts(entries),
1681
+ facts: catalogFacts(
1682
+ ownership === 'component'
1683
+ ? inferOwnedClasses(ownershipEntries, contents, imports)
1684
+ : entries,
1685
+ ),
1216
1686
  startDirectory,
1217
1687
  };
1218
1688
  })(),
@@ -1225,9 +1695,6 @@ export async function analyzeUiProject({
1225
1695
  ),
1226
1696
  );
1227
1697
  if (!files.length) await loadContext(root);
1228
- const imports = new Map(
1229
- files.map((file) => [file, relativeStyleImports(file, contents.get(file))]),
1230
- );
1231
1698
  // A stylesheet or source file belongs to the package whose manifest is
1232
1699
  // nearest it; entries of every other catalog package are foreign to it.
1233
1700
  const packages = new Map();
@@ -1375,6 +1842,7 @@ export async function analyzeUiProject({
1375
1842
  : new Set([contexts.get(file)]);
1376
1843
  for (const context of applicableContexts) {
1377
1844
  const fileDiagnostics = [];
1845
+ const own = await packageOf(dirname(file));
1378
1846
  await inspectCss(
1379
1847
  file,
1380
1848
  contents.get(file),
@@ -1384,6 +1852,19 @@ export async function analyzeUiProject({
1384
1852
  adoption,
1385
1853
  siblingOnLoud(file),
1386
1854
  await foreignTo(file),
1855
+ ownership === 'component'
1856
+ ? {
1857
+ ...jsxOwnershipEvidence(
1858
+ file,
1859
+ context.facts,
1860
+ contents,
1861
+ root,
1862
+ compilerOptions,
1863
+ ),
1864
+ ownPackage: own?.name,
1865
+ }
1866
+ : undefined,
1867
+ ownershipContext,
1387
1868
  );
1388
1869
  recordDiagnostics(fileDiagnostics, context);
1389
1870
  }
package/dist/catalog.js CHANGED
@@ -1,10 +1,10 @@
1
- import { Workbench } from './chunk-H7IODXGY.js';
1
+ import { Workbench } from './chunk-KCCEH7RF.js';
2
2
 
3
3
 
4
4
  import { PopupMenu } from './chunk-J3KGJ6P6.js';
5
5
 
6
6
  import { Row } from './chunk-VZOFZPSO.js';
7
- import { ListHeader } from './chunk-3BCJGM36.js';
7
+ import { ListHeader } from './chunk-M6DEEBEF.js';
8
8
  import { Text } from './chunk-YDGNU2OD.js';
9
9
  import { ListInsetText } from './chunk-DG6G5EPF.js';
10
10
  import { ListItem } from './chunk-NC4CFOID.js';
@@ -20,7 +20,7 @@ import { List } from './chunk-W2AC6Q2A.js';
20
20
 
21
21
  import { ToolbarControlGroup, ToolbarActionLink } from './chunk-QJ5VFCW3.js';
22
22
  import { Toolbar } from './chunk-RGS4WX2M.js';
23
- import { LucideIcon } from './chunk-MOSIE4Z2.js';
23
+ import { LucideIcon } from './chunk-WTSNV5RG.js';
24
24
 
25
25
  import { filterDataAttributes } from './chunk-SRSJO5QE.js';
26
26