@astryxdesign/cli 0.7.2-canary.e2909cf → 0.7.2-canary.e4bdd70

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 (194) hide show
  1. package/README.md +15 -14
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/integration-export-name.test.mjs +644 -0
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +67 -4
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +51 -97
  33. package/api/integration/pack-check.test.mjs +359 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.d.mts +22 -6
  40. package/api/search/search.domain-priority.test.mjs +93 -18
  41. package/api/search/search.integration-check.test.mjs +24 -0
  42. package/api/search/search.mjs +133 -22
  43. package/api/swizzle/copy/copy.mjs +28 -14
  44. package/api/theme/build/build.family.test.mjs +54 -6
  45. package/api/theme/build/build.mjs +114 -17
  46. package/api/theme/themeBuild.doc.mjs +4 -4
  47. package/assets/docs/README.md +10 -0
  48. package/assets/docs/author-a-theme.doc.mjs +1 -1
  49. package/assets/docs/getting-started.doc.mjs +3 -3
  50. package/assets/docs/styling-overview.doc.mjs +1 -1
  51. package/assets/docs/theme.doc.dense.mjs +1 -1
  52. package/assets/docs/theme.doc.mjs +1 -1
  53. package/assets/docs/theme.doc.zh.mjs +1 -1
  54. package/assets/docs/tree/add-a-component.doc.mjs +11 -1
  55. package/assets/docs/tree/add-a-theme.doc.mjs +1 -1
  56. package/assets/docs/tree/add-a-topic.doc.mjs +10 -7
  57. package/assets/docs/tree/agent-guidance.doc.mjs +2 -2
  58. package/assets/docs/tree/block-template.doc.mjs +8 -20
  59. package/assets/docs/tree/build-the-template.doc.mjs +1 -1
  60. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  61. package/assets/docs/tree/check-your-docs.doc.mjs +51 -5
  62. package/assets/docs/tree/checks.doc.mjs +24 -16
  63. package/assets/docs/tree/codemods.doc.mjs +4 -4
  64. package/assets/docs/tree/component-family.doc.mjs +8 -6
  65. package/assets/docs/tree/component-imports.doc.mjs +10 -2
  66. package/assets/docs/tree/components.doc.mjs +1 -1
  67. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +7 -18
  68. package/assets/docs/tree/define-the-theme.doc.mjs +6 -5
  69. package/assets/docs/tree/describe-the-component.doc.mjs +2 -2
  70. package/assets/docs/tree/docs.doc.mjs +3 -3
  71. package/assets/docs/tree/document-the-template.doc.mjs +1 -1
  72. package/assets/docs/tree/document-the-theme.doc.mjs +1 -1
  73. package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
  74. package/assets/docs/tree/extend-or-replace.doc.mjs +6 -6
  75. package/assets/docs/tree/fonts-and-assets.doc.mjs +2 -2
  76. package/assets/docs/tree/generate-a-palette.doc.mjs +5 -3
  77. package/assets/docs/tree/grade-template-with-agent.doc.mjs +1 -1
  78. package/assets/docs/tree/integrations.doc.mjs +8 -0
  79. package/assets/docs/tree/links.doc.mjs +6 -5
  80. package/assets/docs/tree/package-and-test.doc.mjs +1 -1
  81. package/assets/docs/tree/page-template.doc.mjs +4 -8
  82. package/assets/docs/tree/publishing.doc.mjs +1 -1
  83. package/assets/docs/tree/quick-start.doc.mjs +37 -21
  84. package/assets/docs/tree/replace-a-core-component.doc.mjs +4 -6
  85. package/assets/docs/tree/replace-a-core-template.doc.mjs +6 -8
  86. package/assets/docs/tree/sections-and-placement.doc.mjs +4 -6
  87. package/assets/docs/tree/see-it-in-an-app.doc.mjs +4 -2
  88. package/assets/docs/tree/short-and-findable.doc.mjs +6 -5
  89. package/assets/docs/tree/single-component.doc.mjs +15 -6
  90. package/assets/docs/tree/start-a-template.doc.mjs +3 -3
  91. package/assets/docs/tree/subcomponent.doc.mjs +6 -6
  92. package/assets/docs/tree/template-assets.doc.mjs +2 -2
  93. package/assets/docs/tree/template-doc-overview.doc.mjs +4 -15
  94. package/assets/docs/tree/template-fonts.doc.mjs +1 -1
  95. package/assets/docs/tree/template-icons.doc.mjs +1 -1
  96. package/assets/docs/tree/template-images-media.doc.mjs +1 -1
  97. package/assets/docs/tree/template-styles.doc.mjs +2 -2
  98. package/assets/docs/tree/templates.doc.mjs +1 -1
  99. package/assets/docs/tree/test-in-an-app.doc.mjs +3 -3
  100. package/assets/docs/tree/test-template-in-app.doc.mjs +1 -1
  101. package/assets/docs/tree/tokens-and-setup.doc.mjs +19 -15
  102. package/assets/docs/tree/troubleshooting.doc.mjs +13 -5
  103. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  104. package/assets/docs/tree/upgrading.doc.mjs +3 -3
  105. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +3 -1
  106. package/assets/docs/tree/verify-packed-template.doc.mjs +1 -1
  107. package/assets/docs/tree/versioning.doc.mjs +7 -6
  108. package/assets/docs/tree/write-the-template-file.doc.mjs +4 -3
  109. package/assets/docs/use-a-theme.doc.mjs +2 -2
  110. package/assets/theme.template.ts +1 -1
  111. package/authoring/codemod/codemod.doc.mjs +7 -3
  112. package/authoring/config/config.doc.mjs +9 -6
  113. package/authoring/config/type.ts +4 -2
  114. package/authoring/discover/discover.doc.mjs +1 -1
  115. package/authoring/doctypes/base/graph-fields.doc.mjs +6 -4
  116. package/authoring/doctypes/command/command.doc.mjs +14 -12
  117. package/authoring/doctypes/component/component.doc.mjs +29 -26
  118. package/authoring/doctypes/component/type.ts +13 -8
  119. package/authoring/doctypes/enum/enum.doc.mjs +7 -4
  120. package/authoring/doctypes/function/function.doc.mjs +9 -8
  121. package/authoring/doctypes/hook/hook.doc.mjs +5 -4
  122. package/authoring/doctypes/namespace/namespace.doc.mjs +7 -6
  123. package/authoring/doctypes/reference/reference.doc.mjs +11 -10
  124. package/authoring/doctypes/schema/schema.doc.mjs +9 -9
  125. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  126. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  127. package/authoring/doctypes/template/parse.d.mts +6 -2
  128. package/authoring/doctypes/template/parse.mjs +3 -1
  129. package/authoring/doctypes/template/template.doc.mjs +3 -3
  130. package/authoring/doctypes/template/type.ts +22 -0
  131. package/authoring/doctypes/theme/theme.doc.mjs +2 -2
  132. package/authoring/gap-report/gap-report.doc.mjs +2 -2
  133. package/authoring/identity/identity.doc.mjs +3 -3
  134. package/authoring/index.d.ts +1 -0
  135. package/authoring/integration/integration.doc.mjs +12 -4
  136. package/authoring/integration/parse.test.mjs +10 -0
  137. package/authoring/integration/schema.d.mts +2 -0
  138. package/authoring/integration/schema.mjs +1 -0
  139. package/authoring/integration/type.ts +4 -0
  140. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  141. package/clients/cli/commands/build-theme.mjs +1 -1
  142. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  143. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  144. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  145. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  146. package/clients/cli/commands/doctor.mjs +190 -0
  147. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  148. package/clients/cli/commands/integration-check.test.mjs +267 -0
  149. package/clients/cli/commands/integration.doc.mjs +6 -2
  150. package/clients/cli/commands/integration.mjs +107 -1
  151. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  152. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  153. package/clients/cli/index.mjs +1 -0
  154. package/foundation/config/project.mjs +82 -0
  155. package/foundation/discovery/authoring-self-docs.mjs +2 -1
  156. package/foundation/discovery/component-discovery.d.mts +178 -10
  157. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  158. package/foundation/discovery/component-discovery.mjs +659 -62
  159. package/foundation/discovery/component-replacement.mjs +18 -0
  160. package/foundation/discovery/component-replacement.test.mjs +40 -0
  161. package/foundation/discovery/doc-types.d.mts +157 -0
  162. package/foundation/discovery/doc-types.mjs +1224 -0
  163. package/foundation/discovery/doc-types.test.mjs +652 -0
  164. package/foundation/discovery/docs-discovery.d.mts +81 -0
  165. package/foundation/discovery/docs-discovery.mjs +57 -0
  166. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  167. package/foundation/discovery/integration-doc-files.mjs +231 -0
  168. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  169. package/foundation/discovery/module-exports.d.mts +27 -0
  170. package/foundation/discovery/module-exports.mjs +356 -31
  171. package/foundation/discovery/module-exports.test.mjs +172 -0
  172. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  173. package/foundation/discovery/package-tsconfig.mjs +228 -0
  174. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  175. package/foundation/discovery/project-typescript.d.mts +23 -0
  176. package/foundation/discovery/project-typescript.mjs +61 -0
  177. package/foundation/discovery/prop-drift.d.mts +278 -0
  178. package/foundation/discovery/prop-drift.mjs +2263 -0
  179. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  180. package/foundation/discovery/type-match.d.mts +53 -0
  181. package/foundation/discovery/type-match.mjs +660 -0
  182. package/foundation/discovery/type-match.test.mjs +169 -0
  183. package/foundation/discovery/type-text.d.mts +68 -0
  184. package/foundation/discovery/type-text.mjs +658 -0
  185. package/foundation/discovery/type-text.test.mjs +196 -0
  186. package/foundation/discovery/unresolved-references.d.mts +62 -0
  187. package/foundation/discovery/unresolved-references.mjs +790 -0
  188. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  189. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  190. package/foundation/doc-compiler/links.d.mts +9 -5
  191. package/foundation/doc-compiler/links.mjs +4 -2
  192. package/foundation/integrations/contribution-inventory.mjs +6 -1
  193. package/foundation/response/response-types.doc.mjs +7 -2
  194. package/package.json +9 -9
@@ -7,10 +7,18 @@
7
7
  import * as fs from 'node:fs';
8
8
  import {createRequire} from 'node:module';
9
9
  import * as path from 'node:path';
10
+ import {DOC_EXPORTS} from '../doc-compiler/read.mjs';
10
11
  import {listDir, listDirEntries, pathExists} from '../fs/listing-cache.mjs';
11
12
  import {existsCaseExact} from '../fs/paths.mjs';
12
13
  import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
13
14
  import {loadComponentDoc} from './component-loader.mjs';
15
+ import {
16
+ HAS_COMPUTED,
17
+ moduleExportSource,
18
+ moduleExportsName,
19
+ staticExportObject,
20
+ } from './module-exports.mjs';
21
+ import {inspectPackageExport} from './package-exports.mjs';
14
22
 
15
23
  const require = createRequire(import.meta.url);
16
24
 
@@ -82,12 +90,40 @@ function componentNameFromFile(fileName) {
82
90
  const GROUP_RE = /(?:^|\n) {0,2}group:\s*['"]([^'"]+)['"]/;
83
91
  const HIDDEN_COMPONENTS_RE = /(?:^|\n) {0,2}hiddenComponents:\s*\[([^\]]*)\]/;
84
92
  const HIDDEN_RE = /(?:^|\n) {0,2}hidden:\s*true/;
93
+ // Every top-level `name: '...'` (or `"name": "..."`) in the file: a quick check
94
+ // that a doc is named after its file before anything parses it.
95
+ const NAME_FIELD_RE = /(?:^|\n) {0,2}['"]?name['"]?\s*:\s*['"]([^'"\n]+)['"]/g;
96
+ // A `components` field anywhere in the file, written out or as a shorthand: the
97
+ // quick check for a family doc, before anything parses it. A false match only
98
+ // costs the parse.
99
+ const MEMBERS_FIELD_RE = /\bcomponents\b/;
100
+ // A top-level `replaces: '...'`: the Core name a doc claims for its package.
101
+ const REPLACES_FIELD_RE =
102
+ /(?:^|\n) {0,2}['"]?replaces['"]?\s*:\s*['"]([^'"\n]+)['"]/;
103
+
104
+ /**
105
+ * What a quick scan of a doc's text finds before anything parses it: the
106
+ * top-level `name` values it writes (none when a spread could set the name),
107
+ * whether it may list family members, and the Core name it replaces.
108
+ * @param {string} text
109
+ * @returns {{names: string[], hasMembers: boolean, replaces: string | null}}
110
+ */
111
+ function scanDocText(text) {
112
+ return {
113
+ names: text.includes('...')
114
+ ? []
115
+ : [...text.matchAll(NAME_FIELD_RE)].map(match => match[1]),
116
+ hasMembers: MEMBERS_FIELD_RE.test(text),
117
+ replaces: REPLACES_FIELD_RE.exec(text)?.[1] ?? null,
118
+ };
119
+ }
85
120
 
86
121
  /**
87
122
  * Read the `group`, `hiddenComponents`, and `hidden` fields from a
88
- * component's .doc.mjs file (synchronous).
123
+ * component's .doc.mjs file (synchronous), with what {@link scanDocText}
124
+ * finds in it.
89
125
  * @param {string} docPath
90
- * @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean}}
126
+ * @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean, names: string[], hasMembers: boolean, replaces: string | null}}
91
127
  */
92
128
  function readDocMeta(docPath) {
93
129
  try {
@@ -106,9 +142,17 @@ function readDocMeta(docPath) {
106
142
  group: groupMatch ? groupMatch[1] : null,
107
143
  hiddenComponents: hiddenSet,
108
144
  hidden,
145
+ ...scanDocText(content),
109
146
  };
110
147
  } catch {
111
- return {group: null, hiddenComponents: new Set(), hidden: false};
148
+ return {
149
+ group: null,
150
+ hiddenComponents: new Set(),
151
+ hidden: false,
152
+ names: [],
153
+ hasMembers: false,
154
+ replaces: null,
155
+ };
112
156
  }
113
157
  }
114
158
 
@@ -148,9 +192,12 @@ function docMemberNames(source) {
148
192
  let ast;
149
193
  try {
150
194
  babelParser ??= require('@babel/parser');
151
- ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(source, {
152
- sourceType: 'module',
153
- });
195
+ ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(
196
+ source,
197
+ {
198
+ sourceType: 'module',
199
+ },
200
+ );
154
201
  } catch {
155
202
  return [];
156
203
  }
@@ -165,12 +212,16 @@ function docMemberNames(source) {
165
212
  for (const declarator of declaration.declarations) {
166
213
  if (declarator.id.type !== 'Identifier' || !declarator.init) continue;
167
214
  constants.set(declarator.id.name, declarator.init);
168
- if (node.type === 'ExportNamedDeclaration' && declarator.id.name === 'docs') {
215
+ if (
216
+ node.type === 'ExportNamedDeclaration' &&
217
+ declarator.id.name === 'docs'
218
+ ) {
169
219
  docsExport = declarator.init;
170
220
  }
171
221
  }
172
222
  }
173
- if (node.type === 'ExportDefaultDeclaration') defaultExport = node.declaration;
223
+ if (node.type === 'ExportDefaultDeclaration')
224
+ defaultExport = node.declaration;
174
225
  }
175
226
  let doc = defaultExport ?? docsExport;
176
227
  if (doc?.type === 'Identifier') doc = constants.get(doc.name);
@@ -388,7 +439,9 @@ export function discoverComponents(coreDir) {
388
439
  if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
389
440
  const name = entry.name;
390
441
  if (componentGroups.has(name) || name.endsWith('Context')) continue;
391
- if (listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))) {
442
+ if (
443
+ listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))
444
+ ) {
392
445
  continue;
393
446
  }
394
447
  if (!hasOwnFolder(srcDir, name)) continue;
@@ -843,6 +896,345 @@ export function findExternalComponentDoc(docsDir, name) {
843
896
  // commands — and the future integration-component swizzle — can disambiguate
844
897
  // by package and route source/issues correctly.
845
898
 
899
+ // A doc's `name` can name another export of its file, and a family doc's full
900
+ // entries are components under their own names; every other doc reads as before.
901
+ const COMPONENT_NAME_RE = /^[A-Z][\w$]*$/;
902
+
903
+ /** @type {Map<string, {stamp: string, value: Record<string, any> | null}>} doc reads, reused while the doc file is unchanged */
904
+ const docReads = new Map();
905
+
906
+ /**
907
+ * A file's identity for a cache: ctime and inode change on a rewrite even when
908
+ * npm stamps the same size and mtime.
909
+ * @param {string} file
910
+ */
911
+ function fileStamp(file) {
912
+ try {
913
+ const {size, mtimeMs, ctimeMs, ino} = fs.statSync(file);
914
+ return `${size}:${mtimeMs}:${ctimeMs}:${ino}`;
915
+ } catch {
916
+ return 'missing';
917
+ }
918
+ }
919
+
920
+ /** UTF-16 code-unit order, the same in every locale. @param {string} a @param {string} b */
921
+ function byCodeUnit(a, b) {
922
+ return a < b ? -1 : a > b ? 1 : 0;
923
+ }
924
+
925
+ /**
926
+ * A component doc's exported object as plain data, read without running it.
927
+ * @param {string} docPath
928
+ * @returns {Record<string, any> | null}
929
+ */
930
+ function docObject(docPath) {
931
+ const stamp = fileStamp(docPath);
932
+ const cached = docReads.get(docPath);
933
+ if (cached?.stamp === stamp) return cached.value;
934
+ const value = staticExportObject(docPath, DOC_EXPORTS.components);
935
+ docReads.set(docPath, {stamp, value});
936
+ return value;
937
+ }
938
+
939
+ /** @param {string} docPath */
940
+ export function docStem(docPath) {
941
+ return path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
942
+ }
943
+
944
+ /**
945
+ * The export a component doc names when its same-stem source is not named
946
+ * after an export, or null to keep the stem.
947
+ * @param {{docPath: string, sourcePath: string|null, stem: string, names: string[]}} doc
948
+ * `names`: top-level names a quick scan found (none: the doc is read)
949
+ * @returns {string | null}
950
+ */
951
+ function otherExportName({docPath, sourcePath, stem, names}) {
952
+ if (sourcePath == null) return null;
953
+ if (names.length > 0 && names.every(name => name === stem)) return null;
954
+ const name = docObject(docPath)?.name;
955
+ return typeof name === 'string' &&
956
+ name !== stem &&
957
+ COMPONENT_NAME_RE.test(name) &&
958
+ moduleExportsName(sourcePath, name) &&
959
+ !moduleExportsName(sourcePath, stem)
960
+ ? name
961
+ : null;
962
+ }
963
+
964
+ /**
965
+ * The file Node loads for a package without `exports`: its `main`, else its
966
+ * `index` file.
967
+ * @param {string} packageDir
968
+ * @returns {string | null}
969
+ */
970
+ function packageMain(packageDir) {
971
+ let main;
972
+ try {
973
+ main = JSON.parse(
974
+ fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'),
975
+ ).main;
976
+ } catch {
977
+ return null;
978
+ }
979
+ const entry =
980
+ typeof main === 'string' && main !== ''
981
+ ? `./${path.posix.normalize(main).replace(/^\.\//, '')}`
982
+ : './index';
983
+ return inspectPackageExport({}, packageDir, entry).target;
984
+ }
985
+
986
+ /**
987
+ * The local module a doc's import names: the doc's own `import`, else the
988
+ * subpath its directory publishes, read through the package's `exports` (its
989
+ * `main` when it has none). Undefined when the package directory is unknown;
990
+ * null when the import names another package or resolves to no file.
991
+ *
992
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
993
+ * @param {string} docPath
994
+ * @param {Record<string, any> | null} doc
995
+ * @returns {string | null | undefined}
996
+ */
997
+ function importedModule(integration, docPath, doc) {
998
+ const packageName = integration?.name;
999
+ const packageDir = integration?.__packageDir;
1000
+ if (!packageName || !packageDir) return undefined;
1001
+ const exportsMap =
1002
+ integration.__packageExports === undefined
1003
+ ? readPackageExports(packageDir)
1004
+ : integration.__packageExports;
1005
+ const specifier = resolveIntegrationImportPath(
1006
+ {exportsMap, packageDir, docPath, packageName},
1007
+ docStem(docPath),
1008
+ typeof doc?.import === 'string' ? doc.import : null,
1009
+ );
1010
+ if (specifier !== packageName && !specifier.startsWith(`${packageName}/`)) {
1011
+ return null;
1012
+ }
1013
+ if (specifier === packageName && exportsMap == null) {
1014
+ return packageMain(packageDir);
1015
+ }
1016
+ const subpath =
1017
+ specifier === packageName
1018
+ ? '.'
1019
+ : `./${specifier.slice(packageName.length + 1)}`;
1020
+ return inspectPackageExport(
1021
+ {name: packageName, exports: exportsMap ?? undefined},
1022
+ packageDir,
1023
+ subpath,
1024
+ ).target;
1025
+ }
1026
+
1027
+ /**
1028
+ * Whether `file` sits in `folder` or below it.
1029
+ * @param {string} folder
1030
+ * @param {string} file
1031
+ */
1032
+ function isWithin(folder, file) {
1033
+ const relative = path.relative(folder, file);
1034
+ return (
1035
+ relative !== '' &&
1036
+ !path.isAbsolute(relative) &&
1037
+ relative.split(path.sep)[0] !== '..'
1038
+ );
1039
+ }
1040
+
1041
+ /**
1042
+ * The `.tsx` file in `folder`, or below it, that declares `name`, taking files
1043
+ * in path order: the source of a member whose import never reaches a `.tsx`
1044
+ * file, as when the import names built output.
1045
+ *
1046
+ * @param {string} folder
1047
+ * @param {string} name
1048
+ * @returns {string | null}
1049
+ */
1050
+ export function folderSourceFor(folder, name) {
1051
+ /** @type {string[]} */
1052
+ const files = [];
1053
+ /** @param {string} dirPath */
1054
+ const scan = dirPath => {
1055
+ for (const entry of listDirEntries(dirPath)) {
1056
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
1057
+ if (entry.name.startsWith('.')) continue;
1058
+ const fullPath = path.join(dirPath, entry.name);
1059
+ if (entry.isDirectory()) scan(fullPath);
1060
+ else if (
1061
+ entry.name.endsWith('.tsx') &&
1062
+ !/\.(?:test|spec|stories)\.tsx$/.test(entry.name)
1063
+ ) {
1064
+ files.push(fullPath);
1065
+ }
1066
+ }
1067
+ };
1068
+ scan(folder);
1069
+ for (const file of files.sort(byCodeUnit)) {
1070
+ const declared = moduleExportSource(file, name);
1071
+ if (
1072
+ declared != null &&
1073
+ declared.endsWith('.tsx') &&
1074
+ isWithin(folder, declared)
1075
+ ) {
1076
+ return declared;
1077
+ }
1078
+ }
1079
+ return null;
1080
+ }
1081
+
1082
+ /**
1083
+ * The full entries a family doc lists in `components` that the module its
1084
+ * import names exports, each with its source. An entry is full when it says
1085
+ * more than its `name`; the name must be static PascalCase, not hidden, and
1086
+ * neither the doc's stem nor the export the doc is recorded under. A member's
1087
+ * source is the `.tsx` file in the family doc's folder, or below it, that
1088
+ * declares it: the one the family's own source or the imported module reaches,
1089
+ * else the one in that folder that declares the name, as when the import names
1090
+ * built output. A member that only a bundle or a file outside the folder
1091
+ * declares has no source.
1092
+ *
1093
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1094
+ * @param {{docPath: string, sourcePath: string|null}} record the doc's own
1095
+ * @param {string | null | undefined} ownName the export the doc is recorded
1096
+ * under, when it names one
1097
+ * @param {string} [only] look for this member alone
1098
+ * @returns {Array<{name: string, sourcePath: string | null}>}
1099
+ */
1100
+ function familyMembers(integration, record, ownName, only) {
1101
+ const familySource = record.sourcePath;
1102
+ const doc = docObject(record.docPath);
1103
+ const entries = Array.isArray(doc?.components) ? doc.components : [];
1104
+ const hidden = new Set(
1105
+ Array.isArray(doc?.hiddenComponents) ? doc.hiddenComponents : [],
1106
+ );
1107
+ const stem = docStem(record.docPath);
1108
+ /** @type {string[]} */
1109
+ const names = [];
1110
+ for (const entry of entries) {
1111
+ if (entry == null || typeof entry !== 'object') continue;
1112
+ const name = entry.name;
1113
+ const full = Object.keys(entry).length > 1 || entry[HAS_COMPUTED] === true;
1114
+ if (typeof name !== 'string' || !full) continue;
1115
+ if (!COMPONENT_NAME_RE.test(name) || hidden.has(name)) continue;
1116
+ if (name === stem || name === ownName) continue;
1117
+ if (only != null && name !== only) continue;
1118
+ names.push(name);
1119
+ }
1120
+ if (names.length === 0) return [];
1121
+ const imported = importedModule(integration, record.docPath, doc);
1122
+ const module = imported === undefined ? familySource : imported;
1123
+ if (module == null) return [];
1124
+ const folder = path.dirname(record.docPath);
1125
+ /** @param {string | null} file */
1126
+ const inFolder = file =>
1127
+ file != null && file.endsWith('.tsx') && isWithin(folder, file);
1128
+ return names.flatMap(name => {
1129
+ const exported = moduleExportSource(module, name);
1130
+ if (exported == null) return [];
1131
+ // A member's source is a `.tsx` file in the family's folder, as a doc's own
1132
+ // is: never a bundle, even one under a family at the package root.
1133
+ const reached = [
1134
+ familySource == null ? null : moduleExportSource(familySource, name),
1135
+ exported,
1136
+ ].find(inFolder);
1137
+ return [{name, sourcePath: reached ?? folderSourceFor(folder, name)}];
1138
+ });
1139
+ }
1140
+
1141
+ /**
1142
+ * Each family doc under an integration's components root that names an
1143
+ * import, with the file the import resolves to: null when it resolves to none,
1144
+ * such as build output that does not exist yet.
1145
+ *
1146
+ * @param {{name: string, components?: string, issuesUrl?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1147
+ * @returns {Array<{docPath: string, module: string | null}>}
1148
+ */
1149
+ export function familyImports(integration) {
1150
+ return discoverIntegrationComponents(integration).flatMap(record => {
1151
+ if (record.docPath == null || !isOwnRecord(record)) return [];
1152
+ const doc = docObject(record.docPath);
1153
+ if (!Array.isArray(doc?.components) || doc.components.length === 0) {
1154
+ return [];
1155
+ }
1156
+ const module = importedModule(integration, record.docPath, doc);
1157
+ return module === undefined ? [] : [{docPath: record.docPath, module}];
1158
+ });
1159
+ }
1160
+
1161
+ /**
1162
+ * Whether `record` is its doc's own component (under the doc's stem or the
1163
+ * export its `name` documents) rather than a member the doc lists.
1164
+ *
1165
+ * @param {{name: string, docPath: string|null}} record
1166
+ * @returns {boolean}
1167
+ */
1168
+ function isOwnRecord(record) {
1169
+ if (record.docPath == null) return true;
1170
+ const stem = docStem(record.docPath);
1171
+ if (stem === record.name) return true;
1172
+ return (
1173
+ otherExportName({
1174
+ docPath: record.docPath,
1175
+ sourcePath: integrationSourceForDoc(record.docPath),
1176
+ stem,
1177
+ names: [],
1178
+ }) === record.name
1179
+ );
1180
+ }
1181
+
1182
+ /**
1183
+ * Whether a discovered integration record is a member its family doc lists,
1184
+ * decided from the files, not from whether the doc loads.
1185
+ *
1186
+ * @param {{name: string, docPath: string|null}} record
1187
+ * @returns {boolean}
1188
+ */
1189
+ export function isIntegrationMember(record) {
1190
+ return !isOwnRecord(record);
1191
+ }
1192
+
1193
+ /**
1194
+ * The entry a family doc gives a member that discovery lists under its own
1195
+ * name, or null when the record is the doc's own component or the loaded doc
1196
+ * has no entry for it.
1197
+ *
1198
+ * @param {any} doc the doc loaded from `record.docPath`
1199
+ * @param {{name: string, docPath: string|null}} record
1200
+ * @returns {any}
1201
+ */
1202
+ export function integrationMemberEntry(doc, record) {
1203
+ if (doc == null || typeof doc !== 'object' || isOwnRecord(record)) {
1204
+ return null;
1205
+ }
1206
+ if (!Array.isArray(doc.components)) return null;
1207
+ return (
1208
+ doc.components.find(
1209
+ (/** @type {any} */ entry) => entry?.name === record.name,
1210
+ ) ?? null
1211
+ );
1212
+ }
1213
+
1214
+ /**
1215
+ * Visit every component doc under an integration's components root, hidden
1216
+ * ones included, in discovery order, with its stem.
1217
+ * @param {string | undefined} componentsDir
1218
+ * @param {(docPath: string, stem: string) => void} visit
1219
+ */
1220
+ export function forEachIntegrationDoc(componentsDir, visit) {
1221
+ if (!componentsDir || !pathExists(componentsDir)) return;
1222
+ /** @param {string} dirPath */
1223
+ function scanDir(dirPath) {
1224
+ for (const entry of listDirEntries(dirPath)) {
1225
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
1226
+ const fullPath = path.join(dirPath, entry.name);
1227
+ if (entry.isDirectory()) {
1228
+ scanDir(fullPath);
1229
+ continue;
1230
+ }
1231
+ const suffix = INTEGRATION_DOC_SUFFIXES.find(s => entry.name.endsWith(s));
1232
+ if (suffix) visit(fullPath, entry.name.slice(0, -suffix.length));
1233
+ }
1234
+ }
1235
+ scanDir(componentsDir);
1236
+ }
1237
+
846
1238
  /**
847
1239
  * Given an integration component doc path, return the sibling component source
848
1240
  * (`{Name}.tsx`) if one exists, else null.
@@ -850,7 +1242,7 @@ export function findExternalComponentDoc(docsDir, name) {
850
1242
  * @param {string} docPath absolute path to a `{Name}.doc.{ts,mjs,js}` file
851
1243
  * @returns {string|null}
852
1244
  */
853
- function integrationSourceForDoc(docPath) {
1245
+ export function integrationSourceForDoc(docPath) {
854
1246
  const dir = path.dirname(docPath);
855
1247
  const base = path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
856
1248
  const candidate = path.join(dir, `${base}.tsx`);
@@ -860,49 +1252,85 @@ function integrationSourceForDoc(docPath) {
860
1252
  /**
861
1253
  * Discover ownership records for the components contributed by a single loaded
862
1254
  * integration. Scans the integration's resolved `components` dir for same-stem
863
- * doc files and records each with owner package + issuesUrl + sourcePath.
1255
+ * doc files and records each with owner package + issuesUrl + sourcePath. A doc
1256
+ * that names a differently named export of its file is recorded under that
1257
+ * export, and each family member it lists, under the member's name, unless the
1258
+ * name is taken: every doc's file name (a hidden doc's too), a Core name a doc
1259
+ * in the package replaces, and a name an earlier doc records.
864
1260
  *
865
- * @param {{name: string, components?: string, issuesUrl?: string}} integration
1261
+ * @param {{name: string, components?: string, issuesUrl?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
866
1262
  * a single entry from `loadedIntegrations` (lib/integrations.mjs)
1263
+ * @param {{documentedNames?: boolean}} [options] `documentedNames: false`
1264
+ * records each doc under its file name alone, without reading the names
1265
+ * docs document: for a caller that needs only what each doc says
867
1266
  * @returns {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>}
868
1267
  */
869
- export function discoverIntegrationComponents(integration) {
1268
+ export function discoverIntegrationComponents(
1269
+ integration,
1270
+ {documentedNames = true} = {},
1271
+ ) {
870
1272
  const componentsDir = integration?.components;
871
1273
  if (!componentsDir || !pathExists(componentsDir)) return [];
872
1274
 
873
1275
  /** @type {Map<string, {name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>} */
874
1276
  const byName = new Map();
875
-
876
- /** @param {string} dirPath */
877
- function scanDir(dirPath) {
878
- const entries = listDirEntries(dirPath);
879
- for (const entry of entries) {
880
- if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
881
- const fullPath = path.join(dirPath, entry.name);
882
- if (entry.isDirectory()) {
883
- scanDir(fullPath);
884
- continue;
885
- }
886
- const suffix = INTEGRATION_DOC_SUFFIXES.find(s => entry.name.endsWith(s));
887
- if (!suffix) continue;
888
- const name = entry.name.slice(0, -suffix.length);
889
- const {group, hidden} = readDocMeta(fullPath);
890
- if (hidden) continue;
891
- // First doc wins per name (precedence matches INTEGRATION_DOC_SUFFIXES).
892
- if (byName.has(name)) continue;
893
- byName.set(name, {
894
- name,
895
- package: integration.name,
896
- docPath: fullPath,
897
- sourcePath: integrationSourceForDoc(fullPath),
898
- issuesUrl: integration.issuesUrl,
899
- group: group ?? null,
900
- });
1277
+ /** @type {Map<string, {names: string[], hasMembers: boolean, replaces: string | null}>} stem → quick scan */
1278
+ const scans = new Map();
1279
+ /** @type {Set<string>} names no doc can record under another name */
1280
+ const taken = new Set();
1281
+
1282
+ forEachIntegrationDoc(componentsDir, (fullPath, name) => {
1283
+ taken.add(name);
1284
+ const {group, hidden, names, hasMembers, replaces} = readDocMeta(fullPath);
1285
+ if (hidden) return;
1286
+ // First doc wins per name (precedence matches INTEGRATION_DOC_SUFFIXES).
1287
+ if (byName.has(name)) return;
1288
+ byName.set(name, {
1289
+ name,
1290
+ package: integration.name,
1291
+ docPath: fullPath,
1292
+ sourcePath: integrationSourceForDoc(fullPath),
1293
+ issuesUrl: integration.issuesUrl,
1294
+ group: group ?? null,
1295
+ });
1296
+ scans.set(name, {names, hasMembers, replaces});
1297
+ });
1298
+ if (!documentedNames) return [...byName.values()];
1299
+
1300
+ // A doc that names a differently named export of its file is listed under
1301
+ // that export, and each member a family doc lists under the member's name,
1302
+ // unless the name is taken.
1303
+ for (const {replaces} of scans.values()) if (replaces) taken.add(replaces);
1304
+ const records = [...byName.values()];
1305
+ const byPath = [...records].sort((a, b) => byCodeUnit(a.docPath, b.docPath));
1306
+ /** @type {Map<string, string>} stem → export name */
1307
+ const exportNames = new Map();
1308
+ for (const record of byPath) {
1309
+ const exportName = otherExportName({
1310
+ ...record,
1311
+ stem: record.name,
1312
+ names: scans.get(record.name)?.names ?? [],
1313
+ });
1314
+ if (exportName == null || taken.has(exportName)) continue;
1315
+ taken.add(exportName);
1316
+ exportNames.set(record.name, exportName);
1317
+ }
1318
+ /** @type {typeof records} */
1319
+ const members = [];
1320
+ for (const record of byPath) {
1321
+ if (!scans.get(record.name)?.hasMembers) continue;
1322
+ const ownName = exportNames.get(record.name);
1323
+ for (const member of familyMembers(integration, record, ownName)) {
1324
+ if (taken.has(member.name)) continue;
1325
+ taken.add(member.name);
1326
+ members.push({...record, ...member});
901
1327
  }
902
1328
  }
903
-
904
- scanDir(componentsDir);
905
- return [...byName.values()];
1329
+ const named = records.map(record => {
1330
+ const name = exportNames.get(record.name);
1331
+ return name ? {...record, name} : record;
1332
+ });
1333
+ return [...named, ...members];
906
1334
  }
907
1335
 
908
1336
  /**
@@ -910,24 +1338,33 @@ export function discoverIntegrationComponents(integration) {
910
1338
  * Invalid metadata or a missing same-stem source removes only that component;
911
1339
  * callers can report the returned errors while retaining valid siblings. A
912
1340
  * component whose doc sets `replaces` carries the value as written; the
913
- * component replacement resolver decides what it means.
1341
+ * component replacement resolver decides what it means. A family member is
1342
+ * read through its family's doc: it never carries the family's `replaces`, and
1343
+ * a family doc that fails to load is reported once. A doc that fails to load
1344
+ * is reported under its file name: what it says cannot be trusted.
914
1345
  *
915
1346
  * @param {{name: string, components?: string, issuesUrl?: string}} integration
1347
+ * @param {{documentedNames?: boolean}} [options] as for
1348
+ * {@link discoverIntegrationComponents}
916
1349
  * @returns {Promise<{
917
- * components: Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
1350
+ * components: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
918
1351
  * discovered: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>,
919
1352
  * errors: Array<{name: string, message: string}>,
920
1353
  * }>}
921
1354
  */
922
- export async function discoverValidIntegrationComponents(integration) {
923
- const discovered = discoverIntegrationComponents(integration);
924
- /** @type {Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
1355
+ export async function discoverValidIntegrationComponents(integration, options) {
1356
+ const discovered = discoverIntegrationComponents(integration, options);
1357
+ /** @type {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
925
1358
  const components = [];
926
1359
  /** @type {Array<{name: string, message: string}>} */
927
1360
  const errors = [];
1361
+ /** @type {Set<string>} docs that failed to load */
1362
+ const failedDocs = new Set();
928
1363
 
929
1364
  for (const record of discovered) {
930
- if (record.sourcePath == null) {
1365
+ // A doc's own component needs its same-stem source. A member has none when
1366
+ // only a bundle or a file outside its family's folder declares it.
1367
+ if (record.sourcePath == null && isOwnRecord(record)) {
931
1368
  errors.push({
932
1369
  name: record.name,
933
1370
  message: `Component "${record.name}" is missing its same-stem source file ${record.name}.tsx.`,
@@ -937,18 +1374,24 @@ export async function discoverValidIntegrationComponents(integration) {
937
1374
  try {
938
1375
  const doc = await loadComponentDoc(record.docPath);
939
1376
  const valid =
940
- /** @type {{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null}} */ (
1377
+ /** @type {{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}} */ (
941
1378
  record
942
1379
  );
943
1380
  components.push(
944
- doc != null && typeof doc === 'object' && doc.replaces !== undefined
1381
+ isOwnRecord(record) &&
1382
+ doc != null &&
1383
+ typeof doc === 'object' &&
1384
+ doc.replaces !== undefined
945
1385
  ? {...valid, replaces: doc.replaces}
946
1386
  : valid,
947
1387
  );
948
1388
  } catch (err) {
1389
+ if (failedDocs.has(record.docPath)) continue;
1390
+ failedDocs.add(record.docPath);
1391
+ const name = docStem(record.docPath);
949
1392
  errors.push({
950
- name: record.name,
951
- message: `Component "${record.name}" has invalid metadata: ${
1393
+ name,
1394
+ message: `Component "${name}" has invalid metadata: ${
952
1395
  err instanceof Error ? err.message : String(err)
953
1396
  }`,
954
1397
  });
@@ -958,19 +1401,158 @@ export async function discoverValidIntegrationComponents(integration) {
958
1401
  return {components, discovered, errors};
959
1402
  }
960
1403
 
1404
+ /**
1405
+ * How an owner holds a component name, which the owner rule
1406
+ * ({@link unscopedComponentOwners}) reads: `core` when Core documents it;
1407
+ * `file` when an integration doc named after its file is recorded under it;
1408
+ * `doc` when only a doc records it: an export name, a member, or the file name
1409
+ * of a doc recorded under another export.
1410
+ * @typedef {'core' | 'file' | 'doc'} NameHold
1411
+ */
1412
+
1413
+ /**
1414
+ * How a discovered integration record holds its name.
1415
+ * @param {{name: string, docPath: string|null}} record
1416
+ * @returns {'file' | 'doc'}
1417
+ */
1418
+ export function integrationRecordHold(record) {
1419
+ return record.docPath == null || docStem(record.docPath) === record.name
1420
+ ? 'file'
1421
+ : 'doc';
1422
+ }
1423
+
1424
+ /**
1425
+ * The doc that records `name` as its export or a member though its file is
1426
+ * named otherwise, with the source, reading only docs that write the name.
1427
+ *
1428
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1429
+ * @param {string} componentsDir
1430
+ * @param {string} name
1431
+ * @returns {{docPath: string, sourcePath: string|null} | null}
1432
+ */
1433
+ function findDocumentedComponent(integration, componentsDir, name) {
1434
+ if (!COMPONENT_NAME_RE.test(name)) return null;
1435
+ const literals = [`'${name}'`, `"${name}"`, `\`${name}\``];
1436
+ /** @type {Set<string>} */
1437
+ const stems = new Set();
1438
+ /** @type {Array<{docPath: string, stem: string, names: string[], hasMembers: boolean, replaces: string | null, sourcePath: string|null}>} */
1439
+ const candidates = [];
1440
+ forEachIntegrationDoc(componentsDir, (docPath, stem) => {
1441
+ let text;
1442
+ try {
1443
+ text = fs.readFileSync(docPath, 'utf-8');
1444
+ } catch {
1445
+ return;
1446
+ }
1447
+ // The docs discovery records: the first visible doc for each stem.
1448
+ if (HIDDEN_RE.test(text) || stems.has(stem)) return;
1449
+ stems.add(stem);
1450
+ if (!literals.some(literal => text.includes(literal))) return;
1451
+ candidates.push({
1452
+ docPath,
1453
+ stem,
1454
+ ...scanDocText(text),
1455
+ sourcePath: integrationSourceForDoc(docPath),
1456
+ });
1457
+ });
1458
+ // A Core name a doc in the package replaces is the replacement's to answer.
1459
+ if (candidates.some(candidate => candidate.replaces === name)) return null;
1460
+ candidates.sort((a, b) => byCodeUnit(a.docPath, b.docPath));
1461
+ for (const candidate of candidates) {
1462
+ if (otherExportName(candidate) === name) {
1463
+ return {docPath: candidate.docPath, sourcePath: candidate.sourcePath};
1464
+ }
1465
+ }
1466
+ for (const candidate of candidates) {
1467
+ if (!candidate.hasMembers) continue;
1468
+ const ownName = otherExportName(candidate);
1469
+ const [member] = familyMembers(integration, candidate, ownName, name);
1470
+ if (member) {
1471
+ return {docPath: candidate.docPath, sourcePath: member.sourcePath};
1472
+ }
1473
+ }
1474
+ return null;
1475
+ }
1476
+
1477
+ /**
1478
+ * The integration component a name finds, as discovery records it, and its
1479
+ * hold: the same-stem doc first, then a doc that records the name as an export
1480
+ * or a member.
1481
+ *
1482
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1483
+ * @param {string} name
1484
+ * @returns {{docPath: string, sourcePath: string|null, hold: 'file' | 'doc'} | null}
1485
+ */
1486
+ export function findIntegrationComponent(integration, name) {
1487
+ const componentsDir = integration?.components;
1488
+ if (!componentsDir || !pathExists(componentsDir)) return null;
1489
+ const sameStem = findSameStemDoc(componentsDir, name);
1490
+ if (sameStem) {
1491
+ const exportName = otherExportName({
1492
+ docPath: sameStem,
1493
+ sourcePath: integrationSourceForDoc(sameStem),
1494
+ stem: name,
1495
+ names: readDocMeta(sameStem).names,
1496
+ });
1497
+ const renamed =
1498
+ exportName != null &&
1499
+ findSameStemDoc(componentsDir, exportName) == null &&
1500
+ findDocumentedComponent(integration, componentsDir, exportName)
1501
+ ?.docPath === sameStem;
1502
+ return {
1503
+ docPath: sameStem,
1504
+ sourcePath: integrationSourceForDoc(sameStem),
1505
+ hold: renamed ? 'doc' : 'file',
1506
+ };
1507
+ }
1508
+ const found = findDocumentedComponent(integration, componentsDir, name);
1509
+ return found && {...found, hold: 'doc'};
1510
+ }
1511
+
1512
+ /**
1513
+ * The name discovery records a component doc under: the export its `name`
1514
+ * documents when the doc is recorded under it, else the doc's file name.
1515
+ *
1516
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1517
+ * @param {string} docPath
1518
+ * @returns {string}
1519
+ */
1520
+ export function recordedDocName(integration, docPath) {
1521
+ const stem = docStem(docPath);
1522
+ const found = findIntegrationComponent(integration, stem);
1523
+ if (found?.docPath !== docPath || found.hold !== 'doc') return stem;
1524
+ return (
1525
+ otherExportName({
1526
+ docPath,
1527
+ sourcePath: integrationSourceForDoc(docPath),
1528
+ stem,
1529
+ names: readDocMeta(docPath).names,
1530
+ }) ?? stem
1531
+ );
1532
+ }
1533
+
961
1534
  /**
962
1535
  * Find an integration component's doc file by name within a loaded
963
1536
  * integration's resolved `components` dir. Honors the same-stem convention
964
- * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`.
1537
+ * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`, and finds a
1538
+ * doc that records the component under another name: a differently named
1539
+ * export of its file, or a member its family doc lists.
965
1540
  *
966
- * @param {{components?: string}} integration
1541
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
967
1542
  * @param {string} name bare component name (no `XDS`/`Astryx` prefix)
968
1543
  * @returns {string|null}
969
1544
  */
970
1545
  export function findIntegrationComponentDoc(integration, name) {
971
- const componentsDir = integration?.components;
972
- if (!componentsDir || !pathExists(componentsDir)) return null;
1546
+ return findIntegrationComponent(integration, name)?.docPath ?? null;
1547
+ }
973
1548
 
1549
+ /**
1550
+ * The first `{Name}.doc.{ts,mjs,js}` under `componentsDir`, shallowest first.
1551
+ * @param {string} componentsDir
1552
+ * @param {string} name
1553
+ * @returns {string|null}
1554
+ */
1555
+ function findSameStemDoc(componentsDir, name) {
974
1556
  /**
975
1557
  * @param {string} dirPath
976
1558
  * @returns {string | null}
@@ -996,18 +1578,33 @@ export function findIntegrationComponentDoc(integration, name) {
996
1578
  }
997
1579
 
998
1580
  /**
999
- * Find an integration component's swizzleable source file (`{Name}.tsx`) by
1000
- * name within a loaded integration's resolved `components` dir. Returns null
1581
+ * Find an integration component's swizzleable source file by name within a
1582
+ * loaded integration's resolved `components` dir: the same-stem `{Name}.tsx`
1583
+ * of its doc, or, for a family member, the file that declares it. Returns null
1001
1584
  * when the integration ships docs without source.
1002
1585
  *
1003
- * @param {{components?: string}} integration
1586
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1004
1587
  * @param {string} name bare component name
1005
1588
  * @returns {string|null}
1006
1589
  */
1007
1590
  export function findIntegrationComponentSource(integration, name) {
1008
- const docPath = findIntegrationComponentDoc(integration, name);
1009
- if (!docPath) return null;
1010
- return integrationSourceForDoc(docPath);
1591
+ return findIntegrationComponent(integration, name)?.sourcePath ?? null;
1592
+ }
1593
+
1594
+ /**
1595
+ * The owner rule: an owner that holds a name only because a doc records it
1596
+ * (`hold: 'doc'`) yields to Core and to every file-named owner; `--package`
1597
+ * still reaches it. Surfaces resolve a replacement first, then this rule, then
1598
+ * which docs load.
1599
+ *
1600
+ * @template {{hold?: string}} T
1601
+ * @param {T[]} owners each with its {@link NameHold}; an owner without one
1602
+ * counts as Core's or file-named
1603
+ * @returns {T[]}
1604
+ */
1605
+ export function unscopedComponentOwners(owners) {
1606
+ const primary = owners.filter(owner => owner.hold !== 'doc');
1607
+ return primary.length > 0 ? primary : owners;
1011
1608
  }
1012
1609
 
1013
1610
  /**