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

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.
@@ -17,7 +17,17 @@
17
17
  import {Worker} from 'node:worker_threads';
18
18
  import {AstryxError} from '../error.mjs';
19
19
  import {Project} from '../../foundation/config/project.mjs';
20
- import {CORE_PACKAGE} from '../../foundation/discovery/component-discovery.mjs';
20
+ import {
21
+ CORE_PACKAGE,
22
+ discoverIntegrationComponents,
23
+ docStem,
24
+ findComponentReadme,
25
+ findIntegrationComponent,
26
+ integrationRecordHold,
27
+ unscopedComponentOwners,
28
+ } from '../../foundation/discovery/component-discovery.mjs';
29
+ import {legacyExternalKeeps} from '../component/_adapter.mjs';
30
+ import {findCoreDir} from '../../foundation/fs/paths.mjs';
21
31
  import {captureEnv} from '../../foundation/debug/event.mjs';
22
32
  import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
23
33
  import {parseGapReportReceipt} from '../../authoring/gap-report/parse.mjs';
@@ -178,13 +188,65 @@ async function selectTarget(project, component, packageName) {
178
188
  coreName,
179
189
  );
180
190
  if (replacement) return targetForPackage(project, replacement.package);
191
+ // The owner rule runs over every owner discovery finds, loadable or not
192
+ // (a doc recorded under another export still answers to its file's name);
193
+ // then only owners whose docs load route a report.
194
+ const wanted = bareComponent.toLowerCase();
195
+ const loads = new Set(records.map(r => `${r.package}\0${r.docPath}`));
196
+ const recorded = project.loadedIntegrations.flatMap(integration =>
197
+ discoverIntegrationComponents(integration).flatMap(r =>
198
+ r.name.toLowerCase() === wanted
199
+ ? [{...r, hold: integrationRecordHold(r)}]
200
+ : docStem(r.docPath).toLowerCase() === wanted
201
+ ? [{...r, hold: 'doc'}]
202
+ : [],
203
+ ),
204
+ );
205
+ // The checks below find files by name, so they run on every spelling the
206
+ // case-blind match above found, as well as the one typed.
207
+ const spellings = new Set([
208
+ bareComponent,
209
+ ...recorded.flatMap(r =>
210
+ [r.name, docStem(r.docPath)].filter(n => n.toLowerCase() === wanted),
211
+ ),
212
+ ]);
213
+ // Core also holds a name only its doc lookup finds, as in detail: a name a
214
+ // doc records yields to it, though it routes no report itself.
215
+ const coreDir = findCoreDir(project.cwd);
216
+ const coreDocs =
217
+ coreDir != null &&
218
+ [...spellings].some(name => findComponentReadme(coreDir, name) != null);
219
+ const ruled = unscopedComponentOwners([
220
+ ...records
221
+ .filter(
222
+ r => r.package === CORE_PACKAGE && r.name.toLowerCase() === wanted,
223
+ )
224
+ .map(r => ({...r, hold: 'core'})),
225
+ ...recorded,
226
+ // A hidden doc named after its file still holds that name, as the
227
+ // component lookup finds it; it routes no report.
228
+ ...project.loadedIntegrations.flatMap(integration =>
229
+ [...spellings].flatMap(spelling => {
230
+ const found = findIntegrationComponent(integration, spelling);
231
+ return found?.hold === 'file'
232
+ ? [{...found, package: integration.name}]
233
+ : [];
234
+ }),
235
+ ),
236
+ ]).filter(r => !coreDocs || r.hold !== 'doc');
237
+ // A legacy docs package keeps an export name a doc records, as in detail.
181
238
  const owners = [
182
239
  ...new Set(
183
- records
240
+ ([...spellings].some(name =>
241
+ legacyExternalKeeps(project.cwd, name, ruled),
242
+ )
243
+ ? []
244
+ : ruled
245
+ )
184
246
  .filter(
185
- record => record.name.toLowerCase() === bareComponent.toLowerCase(),
247
+ r => r.hold === 'core' || loads.has(`${r.package}\0${r.docPath}`),
186
248
  )
187
- .map(record => record.package),
249
+ .map(r => r.package),
188
250
  ),
189
251
  ];
190
252
 
@@ -24,7 +24,6 @@ import * as path from 'node:path';
24
24
  import * as os from 'node:os';
25
25
  import {fileURLToPath} from 'node:url';
26
26
  import {spawnSync} from 'node:child_process';
27
- import jscodeshift from 'jscodeshift';
28
27
  import {assertWithin} from '../../foundation/fs/path-safety.mjs';
29
28
  import {resolvePackageDir} from '../../foundation/integrations/integrations.mjs';
30
29
  import {
@@ -41,6 +40,7 @@ import {
41
40
  } from '../../foundation/discovery/docs-discovery.mjs';
42
41
  import {discoverIntegrationThemes} from '../../foundation/discovery/theme-discovery.mjs';
43
42
  import {inspectPackageExport} from '../../foundation/discovery/package-exports.mjs';
43
+ import {moduleExportsName} from '../../foundation/discovery/module-exports.mjs';
44
44
  import {
45
45
  ThemeImportError,
46
46
  resolveThemeImports,
@@ -51,6 +51,7 @@ import {
51
51
  } from '../theme/build/build.mjs';
52
52
  import {
53
53
  discoverIntegrationComponents,
54
+ docStem,
54
55
  resolveIntegrationImportPath,
55
56
  } from '../../foundation/discovery/component-discovery.mjs';
56
57
  import {loadComponentReplacements} from '../component/_adapter.mjs';
@@ -307,98 +308,6 @@ function extractTarball(tgzPath, extractDir) {
307
308
  }
308
309
  }
309
310
 
310
- const MODULE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.tsx', '.jsx'];
311
- const moduleParser = jscodeshift.withParser('tsx');
312
-
313
- /** @param {unknown} specifier @param {string} fromFile */
314
- function resolveLocalModule(specifier, fromFile) {
315
- if (typeof specifier !== 'string' || !specifier.startsWith('.')) return null;
316
- const base = path.resolve(path.dirname(fromFile), specifier);
317
- const candidates = [
318
- base,
319
- ...MODULE_EXTENSIONS.map(extension => `${base}${extension}`),
320
- ...MODULE_EXTENSIONS.map(extension => path.join(base, `index${extension}`)),
321
- ];
322
- return candidates.find(candidate => fs.existsSync(candidate)) ?? null;
323
- }
324
-
325
- /** @param {any} declaration @param {string} exportName */
326
- function declarationExports(declaration, exportName) {
327
- if (!declaration) return false;
328
- if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration.type)) {
329
- return declaration.id?.name === exportName;
330
- }
331
- if (declaration.type !== 'VariableDeclaration') return false;
332
- return declaration.declarations.some(
333
- (/** @type {any} */ item) =>
334
- item.id?.type === 'Identifier' && item.id.name === exportName,
335
- );
336
- }
337
-
338
- /**
339
- * Check one resolved module's static export surface without executing package
340
- * code. Local ESM re-exports are followed recursively.
341
- *
342
- * @param {string} file
343
- * @param {string} exportName
344
- * @param {Set<string>} [seen]
345
- */
346
- function moduleExportsName(file, exportName, seen = new Set()) {
347
- const identity = `${file}\0${exportName}`;
348
- if (seen.has(identity) || !fs.existsSync(file)) return false;
349
- seen.add(identity);
350
-
351
- let root;
352
- try {
353
- root = moduleParser(fs.readFileSync(file, 'utf-8'));
354
- } catch {
355
- return false;
356
- }
357
-
358
- let found = false;
359
- root
360
- .find(moduleParser.ExportNamedDeclaration)
361
- .forEach((/** @type {any} */ exportPath) => {
362
- if (found) return;
363
- const node = exportPath.node;
364
- if (declarationExports(node.declaration, exportName)) {
365
- found = true;
366
- return;
367
- }
368
- for (const specifier of node.specifiers ?? []) {
369
- const exported = specifier.exported?.name ?? specifier.exported?.value;
370
- if (exported !== exportName) continue;
371
- if (!node.source) {
372
- found = true;
373
- return;
374
- }
375
- const target = resolveLocalModule(node.source.value, file);
376
- const imported =
377
- specifier.local?.name ?? specifier.local?.value ?? exportName;
378
- if (target && moduleExportsName(target, imported, seen)) {
379
- found = true;
380
- return;
381
- }
382
- }
383
- });
384
- if (found) return true;
385
- if (
386
- exportName === 'default' &&
387
- root.find(moduleParser.ExportDefaultDeclaration).size() > 0
388
- ) {
389
- return true;
390
- }
391
-
392
- root
393
- .find(moduleParser.ExportAllDeclaration)
394
- .forEach((/** @type {any} */ exportPath) => {
395
- if (found) return;
396
- const target = resolveLocalModule(exportPath.node.source?.value, file);
397
- if (target && moduleExportsName(target, exportName, seen)) found = true;
398
- });
399
- return found;
400
- }
401
-
402
311
  /**
403
312
  * Whether any of these doc files has a section that sets `id`.
404
313
  * @param {string[]} files
@@ -480,7 +389,7 @@ async function validatePackedComponentExports(integration, scratchBase) {
480
389
  issues.push(
481
390
  error(
482
391
  'component_doc_unloadable',
483
- `Component "${record.name}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
392
+ `Component "${docStem(record.docPath)}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
484
393
  ),
485
394
  );
486
395
  continue;
@@ -754,6 +754,38 @@ describe('integrationPackCheck', () => {
754
754
  );
755
755
  });
756
756
 
757
+ it('checks the export a component doc names when its file is named otherwise', async () => {
758
+ fs.writeFileSync(
759
+ path.join(tmpDir, 'package.json'),
760
+ `${JSON.stringify({
761
+ name: '@acme/widgets',
762
+ version: '1.0.0',
763
+ files: ['astryx.integration.mjs', 'components'],
764
+ exports: {'./components/Slides': './components/Slides.tsx'},
765
+ })}\n`,
766
+ );
767
+ fs.writeFileSync(
768
+ path.join(tmpDir, 'astryx.integration.mjs'),
769
+ "export default {components: './components'};\n",
770
+ );
771
+ fs.mkdirSync(path.join(tmpDir, 'components'));
772
+ // Slides.tsx exports AcmeCarousel and nothing named Slides.
773
+ fs.writeFileSync(
774
+ path.join(tmpDir, 'components', 'Slides.tsx'),
775
+ 'export function AcmeCarousel() { return null; }\n',
776
+ );
777
+ fs.writeFileSync(
778
+ path.join(tmpDir, 'components', 'Slides.doc.mjs'),
779
+ "export default {type: 'component', name: 'AcmeCarousel', import: '@acme/widgets/components/Slides', description: 'x', props: []};\n",
780
+ );
781
+
782
+ const result = await integrationPackCheck({cwd: tmpDir});
783
+ expect(result.data.packable).toBe(true);
784
+ expect(result.data.contributions.packed.components).toEqual([
785
+ 'AcmeCarousel',
786
+ ]);
787
+ });
788
+
757
789
  it('passes a generated component through its exact packed export', async () => {
758
790
  fs.writeFileSync(
759
791
  path.join(tmpDir, 'package.json'),
@@ -811,6 +843,86 @@ describe('integrationPackCheck', () => {
811
843
  );
812
844
  });
813
845
 
846
+ it('does not count a type-only re-export as the component', async () => {
847
+ fs.writeFileSync(
848
+ path.join(tmpDir, 'package.json'),
849
+ `${JSON.stringify({
850
+ name: '@acme/widgets',
851
+ version: '1.0.0',
852
+ files: ['astryx.integration.mjs', 'src'],
853
+ exports: {'.': './src/index.ts'},
854
+ })}\n`,
855
+ );
856
+ fs.writeFileSync(
857
+ path.join(tmpDir, 'astryx.integration.mjs'),
858
+ "export default {components: './src'};\n",
859
+ );
860
+ fs.mkdirSync(path.join(tmpDir, 'src'));
861
+ fs.writeFileSync(
862
+ path.join(tmpDir, 'src', 'index.ts'),
863
+ "export type {AcmeWidget} from './AcmeWidget';\n",
864
+ );
865
+ fs.writeFileSync(
866
+ path.join(tmpDir, 'src', 'AcmeWidget.tsx'),
867
+ 'export function AcmeWidget() { return null; }\n',
868
+ );
869
+ fs.writeFileSync(
870
+ path.join(tmpDir, 'src', 'AcmeWidget.doc.mjs'),
871
+ "export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
872
+ );
873
+
874
+ const result = await integrationPackCheck({cwd: tmpDir});
875
+
876
+ expect(result.data.issues).toContainEqual(
877
+ expect.objectContaining({code: 'component_export_missing'}),
878
+ );
879
+ });
880
+
881
+ it('follows a re-export of a folder to its index file', async () => {
882
+ // `./primitives` names a folder: TypeScript and bundlers load its index file.
883
+ fs.writeFileSync(
884
+ path.join(tmpDir, 'package.json'),
885
+ `${JSON.stringify({
886
+ name: '@acme/widgets',
887
+ version: '1.0.0',
888
+ files: ['astryx.integration.mjs', 'src'],
889
+ exports: {'.': './src/index.ts'},
890
+ })}\n`,
891
+ );
892
+ fs.writeFileSync(
893
+ path.join(tmpDir, 'astryx.integration.mjs'),
894
+ "export default {components: './src'};\n",
895
+ );
896
+ const primitives = path.join(tmpDir, 'src', 'primitives');
897
+ fs.mkdirSync(primitives, {recursive: true});
898
+ fs.writeFileSync(
899
+ path.join(tmpDir, 'src', 'index.ts'),
900
+ "export * from './primitives';\n",
901
+ );
902
+ fs.writeFileSync(
903
+ path.join(primitives, 'index.ts'),
904
+ "export * from './AcmeWidget';\n",
905
+ );
906
+ fs.writeFileSync(
907
+ path.join(primitives, 'AcmeWidget.tsx'),
908
+ 'export function AcmeWidget() { return null; }\n',
909
+ );
910
+ fs.writeFileSync(
911
+ path.join(primitives, 'AcmeWidget.doc.mjs'),
912
+ "export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
913
+ );
914
+
915
+ const result = await integrationPackCheck({cwd: tmpDir});
916
+
917
+ expect(result.data.issues).not.toContainEqual(
918
+ expect.objectContaining({code: 'component_export_missing'}),
919
+ );
920
+ expect(
921
+ result.data.packable,
922
+ JSON.stringify(result.data.issues, null, 2),
923
+ ).toBe(true);
924
+ });
925
+
814
926
  it('fails when packed template source is hidden by package exports', async () => {
815
927
  writePackage({
816
928
  manifest: "export default {templates: './templates'};\n",
@@ -252,4 +252,13 @@ export type Candidate = {
252
252
  _kind?: "page" | "block" | undefined;
253
253
  _resultName?: string | undefined;
254
254
  _commandName?: string | undefined;
255
+ /**
256
+ * - How an integration component holds its
257
+ * name, for the owner rule.
258
+ */
259
+ _hold?: "file" | "doc" | undefined;
260
+ /**
261
+ * - An integration component's doc.
262
+ */
263
+ _docPath?: string | undefined;
255
264
  };
@@ -81,12 +81,18 @@
81
81
 
82
82
  import {withListingCache} from '../../foundation/fs/listing-cache.mjs';
83
83
  import {readDocView} from '../../foundation/doc-compiler/read.mjs';
84
- import {findCoreDir} from '../../foundation/fs/paths.mjs';
84
+ import {
85
+ discoverExternalPackages,
86
+ findCoreDir,
87
+ } from '../../foundation/fs/paths.mjs';
85
88
  import {
86
89
  discoverComponents,
87
90
  discoverValidIntegrationComponents,
91
+ docStem,
88
92
  familyMember,
89
93
  findComponentReadme,
94
+ forEachIntegrationDoc,
95
+ integrationRecordHold,
90
96
  resolveImportPath,
91
97
  resolveIntegrationImportPath,
92
98
  CORE_PACKAGE,
@@ -98,6 +104,7 @@ import {
98
104
  import {
99
105
  loadComponentReplacements,
100
106
  loadIntegrationsSafely,
107
+ resolveLegacyExternalDoc,
101
108
  } from '../component/_adapter.mjs';
102
109
  import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
103
110
  import {discoverTemplates, extractComponents} from '../template/template.mjs';
@@ -150,6 +157,9 @@ import {setResultCoverage} from './coverage.mjs';
150
157
  * @property {'page'|'block'} [_kind]
151
158
  * @property {string} [_resultName]
152
159
  * @property {string} [_commandName]
160
+ * @property {'file'|'doc'} [_hold] - How an integration component holds its
161
+ * name, for the owner rule.
162
+ * @property {string} [_docPath] - An integration component's doc.
153
163
  */
154
164
 
155
165
  /**
@@ -1110,20 +1120,50 @@ async function gatherCoreComponents(coreDir) {
1110
1120
  * usage/description from each component's .doc.mjs, same as core. Without
1111
1121
  * this, an integration component is invisible to `search`/`build` even
1112
1122
  * though `component --list`/`component <Name>` already resolve it — the two
1113
- * discovery paths silently disagreed.
1123
+ * discovery paths silently disagreed. Also returns, per name, the docs that
1124
+ * hold it by their file name and the loadable docs that only record it.
1114
1125
  * @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
1115
- * @returns {Promise<Candidate[]>}
1126
+ * @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
1116
1127
  */
1117
1128
  async function gatherIntegrationComponents(loadedIntegrations) {
1118
1129
  /** @type {Candidate[]} */
1119
1130
  const candidates = [];
1131
+ // What the owner rule reads: the docs that hold a name by their file name,
1132
+ // loadable or not and hidden ones too, and the loadable docs that only
1133
+ // record it.
1134
+ /** @type {Map<string, Set<string>>} */
1135
+ const fileNamed = new Map();
1136
+ /** @type {Map<string, Set<string>>} */
1137
+ const documented = new Map();
1138
+ /** @param {Map<string, Set<string>>} map @param {string} name @param {string} docPath */
1139
+ const hold = (map, name, docPath) =>
1140
+ map.set(name, (map.get(name) ?? new Set()).add(docPath));
1120
1141
  for (const integration of loadedIntegrations) {
1121
- const {components} = await discoverValidIntegrationComponents(integration);
1142
+ const {components, discovered} =
1143
+ await discoverValidIntegrationComponents(integration);
1144
+ const renamed = new Set(
1145
+ discovered
1146
+ .filter(rec => integrationRecordHold(rec) === 'doc')
1147
+ .map(rec => rec.docPath),
1148
+ );
1149
+ forEachIntegrationDoc(integration.components, (docPath, stem) => {
1150
+ if (!renamed.has(docPath)) hold(fileNamed, stem, docPath);
1151
+ });
1122
1152
  for (const rec of components) {
1123
1153
  const doc = await loadModuleDoc(rec.docPath);
1154
+ // A doc that names another export of its file still answers to the
1155
+ // file's name, as `component <file name>` does.
1156
+ const fileName = docStem(rec.docPath);
1157
+ if (renamed.has(rec.docPath)) {
1158
+ hold(documented, rec.name, rec.docPath);
1159
+ hold(documented, fileName, rec.docPath);
1160
+ }
1124
1161
  candidates.push({
1125
1162
  domain: 'component',
1126
1163
  name: rec.name,
1164
+ aliases: fileName !== rec.name ? [fileName] : [],
1165
+ _hold: integrationRecordHold(rec),
1166
+ _docPath: rec.docPath,
1127
1167
  keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
1128
1168
  description: doc ? doc.usage?.description || doc.description || '' : '',
1129
1169
  guidance: guidanceFrom(doc),
@@ -1148,7 +1188,7 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1148
1188
  });
1149
1189
  }
1150
1190
  }
1151
- return candidates;
1191
+ return {candidates, fileNamed, documented};
1152
1192
  }
1153
1193
 
1154
1194
  /**
@@ -1160,11 +1200,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1160
1200
  */
1161
1201
  async function gatherComponents(coreDir, cwd) {
1162
1202
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1163
- const [core, integrations, replacements] = await Promise.all([
1203
+ const [core, gathered, replacements] = await Promise.all([
1164
1204
  gatherCoreComponents(coreDir),
1165
1205
  gatherIntegrationComponents(loadedIntegrations),
1166
1206
  loadComponentReplacements(coreDir, loadedIntegrations),
1167
1207
  ]);
1208
+ const {candidates: integrations, fileNamed, documented} = gathered;
1168
1209
  // An active replacement answers to the Core name it replaces, so a search
1169
1210
  // for that name finds the replacement and not the Core original
1170
1211
  // (spec:AST-035 FR11).
@@ -1175,9 +1216,43 @@ async function gatherComponents(coreDir, cwd) {
1175
1216
  active.target,
1176
1217
  ]),
1177
1218
  );
1219
+ // The owner rule, as component detail applies it: a candidate `component
1220
+ // <name>` does not open names its package and follows the owner, and a
1221
+ // file-name alias that opens another owner is dropped.
1222
+ const coreNames = new Set(core.map(candidate => candidate.name));
1223
+ /** @param {string} name @param {Candidate} candidate */
1224
+ const opens = (name, candidate) =>
1225
+ !coreNames.has(name) &&
1226
+ findComponentReadme(coreDir, name) == null &&
1227
+ [...(fileNamed.get(name) ?? [])].every(doc => doc === candidate._docPath) &&
1228
+ [...(documented.get(name) ?? [])].every(doc => doc === candidate._docPath);
1229
+ // A legacy docs package keeps an exact name over an export name a doc records.
1230
+ /** @type {ReturnType<typeof discoverExternalPackages> | undefined} */
1231
+ let externals;
1232
+ /** @param {string} name */
1233
+ const legacyDocuments = name =>
1234
+ (externals ??= discoverExternalPackages(cwd)).some(
1235
+ ext => resolveLegacyExternalDoc(ext, name) != null,
1236
+ );
1237
+ const ruled = integrations.map(candidate => {
1238
+ const aliases = (candidate.aliases ?? []).filter(alias =>
1239
+ opens(alias, candidate),
1240
+ );
1241
+ return candidate._hold === 'doc' &&
1242
+ (!opens(candidate.name, candidate) || legacyDocuments(candidate.name))
1243
+ ? {
1244
+ ...candidate,
1245
+ aliases,
1246
+ _command: `astryx component ${candidate.name} --package ${candidate._package}`,
1247
+ }
1248
+ : {...candidate, aliases};
1249
+ });
1178
1250
  return [
1179
1251
  ...core.filter(candidate => !replacements.forTarget(candidate.name)),
1180
- ...integrations.map(candidate => {
1252
+ ...[
1253
+ ...ruled.filter(candidate => !candidate._command),
1254
+ ...ruled.filter(candidate => candidate._command),
1255
+ ].map(candidate => {
1181
1256
  const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
1182
1257
  if (target != null) {
1183
1258
  return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
@@ -21,8 +21,10 @@ import {checkGhCli} from '../_github.mjs';
21
21
  import {Project} from '../../../foundation/config/project.mjs';
22
22
  import {
23
23
  CORE_PACKAGE,
24
- findIntegrationComponentDoc,
25
- findIntegrationComponentSource,
24
+ docStem,
25
+ findComponentReadme,
26
+ findIntegrationComponent,
27
+ unscopedComponentOwners,
26
28
  } from '../../../foundation/discovery/component-discovery.mjs';
27
29
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
28
30
  import {AstryxError, writeFailed} from '../../error.mjs';
@@ -126,33 +128,38 @@ async function loadConfigSafely(cwd) {
126
128
 
127
129
  /**
128
130
  * Build the set of OWNER packages that provide a component named `name` across
129
- * core + every loaded integration.
131
+ * core + every loaded integration. Core holds every name it documents, but has
132
+ * source to copy (`sourceDir`) only for a name with its own folder.
130
133
  * @param {string} coreDir
131
134
  * @param {Array<{name: string, components?: string, issuesUrl?: string}>} loadedIntegrations
132
135
  * @param {string} name
133
136
  * @param {string|undefined} coreIssuesUrl
134
- * @returns {Array<{package: string, sourceDir: string|null, ownerPackage: string, issuesUrl: string|undefined}>}
137
+ * @returns {Array<{package: string, sourceDir: string|null, docPath: string|null, ownerPackage: string, issuesUrl: string|undefined, hold: string}>}
135
138
  */
136
139
  function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
137
140
  const owners = [];
138
141
  const coreComponentDir = path.join(coreDir, 'src', name);
139
- if (fs.existsSync(coreComponentDir)) {
142
+ const coreFolder = fs.existsSync(coreComponentDir);
143
+ if (coreFolder || findComponentReadme(coreDir, name)) {
140
144
  owners.push({
141
145
  package: CORE_PACKAGE,
142
- sourceDir: coreComponentDir,
146
+ sourceDir: coreFolder ? coreComponentDir : null,
147
+ docPath: null,
143
148
  ownerPackage: CORE_PACKAGE,
144
149
  issuesUrl: coreIssuesUrl || DEFAULT_ISSUES_URL,
150
+ hold: 'core',
145
151
  });
146
152
  }
147
153
  for (const integration of loadedIntegrations) {
148
- const docPath = findIntegrationComponentDoc(integration, name);
149
- if (!docPath) continue;
150
- const sourcePath = findIntegrationComponentSource(integration, name);
154
+ const found = findIntegrationComponent(integration, name);
155
+ if (!found) continue;
151
156
  owners.push({
152
157
  package: integration.name,
153
- sourceDir: sourcePath ? path.dirname(sourcePath) : null,
158
+ sourceDir: found.sourcePath ? path.dirname(found.sourcePath) : null,
159
+ docPath: found.docPath,
154
160
  ownerPackage: integration.name,
155
161
  issuesUrl: integration.issuesUrl,
162
+ hold: found.hold,
156
163
  });
157
164
  }
158
165
  return owners;
@@ -222,25 +229,32 @@ export async function swizzleCopy(component, options = {}) {
222
229
  : undefined;
223
230
  // An active integration replacement answers to the Core name it replaces,
224
231
  // bare or qualified by its own package (spec:AST-035 FR11): copy it under its
225
- // own name. `--package @astryxdesign/core` still copies the Core original.
232
+ // doc's file name, the folder it has always gone to. `--package
233
+ // @astryxdesign/core` still copies the Core original.
226
234
  if (pkg !== CORE_PACKAGE) {
227
235
  const replacement = await resolveComponentReplacement(
228
236
  coreDir,
229
237
  loadedIntegrations,
230
238
  dirName,
231
239
  );
240
+ const copyName = replacement ? docStem(replacement.docPath) : null;
232
241
  if (
233
242
  replacement &&
234
- (!pkg || (pkg === replacement.package && replacement.name !== dirName))
243
+ copyName &&
244
+ (!pkg || (pkg === replacement.package && copyName !== dirName))
235
245
  ) {
236
- return swizzleCopy(replacement.name, {
246
+ return swizzleCopy(copyName, {
237
247
  ...options,
238
248
  package: replacement.package,
239
249
  });
240
250
  }
241
251
  }
242
252
 
243
- const allOwners = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
253
+ const found = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
254
+ // The owner rule, then a Core name without its own folder drops out.
255
+ const allOwners = (pkg ? found : unscopedComponentOwners(found)).filter(
256
+ owner => owner.package !== CORE_PACKAGE || owner.sourceDir != null,
257
+ );
244
258
 
245
259
  if (allOwners.length === 0) {
246
260
  throw new AstryxError(
@@ -35,6 +35,14 @@ export const docs = {
35
35
  type: 'prose',
36
36
  text: 'Add writes the entry only when package.json already has an `exports` map, so start every package with `"exports": {}`.',
37
37
  },
38
+ {
39
+ type: 'prose',
40
+ text: 'A file does not have to be named after its component. When `Slides.tsx` exports `AcmeCarousel` and nothing named `Slides`, keep the doc beside it as `Slides.doc.mjs` and set its `name` to `AcmeCarousel`: apps list, search and open the component by that name, and `integration verify` checks that its import exports `AcmeCarousel`. A file that exports its own name keeps that name.',
41
+ },
42
+ {
43
+ type: 'prose',
44
+ text: "The file name still opens the doc. A name that Core or another doc's file already has stays theirs, and so does a Core name your package replaces: with `Badge.doc.mjs` naming `AcmeBadge`, `astryx component Badge` opens Core's `Badge`, and `--package` with your package name reaches yours.",
45
+ },
38
46
  ],
39
47
  },
40
48
  {
@@ -30,7 +30,7 @@ export const doc = {
30
30
  name: 'name',
31
31
  type: 'string',
32
32
  description:
33
- "Stable machine identity and directory name without the Astryx prefix, PascalCase. e.g. 'Button', 'TextInput', 'AppShell'. Change `displayName`, not `name`, to edit the visible label; registry URLs derive from this identity by default.",
33
+ "Stable machine identity and directory name without the Astryx prefix, PascalCase. e.g. 'Button', 'TextInput', 'AppShell'. For an integration component it is the export apps import: the doc sits beside the file that exports it, and when that file exports nothing under its own name, `name` may name the export it does have. Change `displayName`, not `name`, to edit the visible label; registry URLs derive from this identity by default.",
34
34
  required: true,
35
35
  },
36
36
  {
@@ -33,9 +33,12 @@ export interface ComponentBaseDoc extends AuthoredDocGraphFields {
33
33
  type?: 'component';
34
34
  /**
35
35
  * Stable machine identity and directory name without the Astryx prefix,
36
- * PascalCase (for example `Button`, `Table`, or `AppShell`). Do not change
37
- * this to edit the visible label; use `displayName` for that. Registry URLs
38
- * derive from this value by default.
36
+ * PascalCase (for example `Button`, `Table`, or `AppShell`). For an
37
+ * integration component it is the export apps import: the doc sits beside
38
+ * the file that exports it, and when that file exports nothing under its own
39
+ * name, `name` may name the export it does have. Do not change this to edit
40
+ * the visible label; use `displayName` for that. Registry URLs derive from
41
+ * this value by default.
39
42
  */
40
43
  name: string;
41
44
  /** Human-readable display name with spaces between words, used by the