@astryxdesign/cli 0.6.3-canary.ea2f048 → 0.6.3-canary.ebaebc4

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 (188) hide show
  1. package/README.md +2 -1
  2. package/api/build/build.type.d.mts +2 -2
  3. package/api/build/build.type.mjs +2 -2
  4. package/api/component/component.type.d.mts +6 -6
  5. package/api/component/component.type.mjs +19 -19
  6. package/api/discover/discover.type.d.mts +4 -4
  7. package/api/discover/discover.type.mjs +10 -10
  8. package/api/docs/_adapter.d.mts +37 -24
  9. package/api/docs/_adapter.mjs +169 -83
  10. package/api/docs/compiled-topics.test.mjs +78 -0
  11. package/api/docs/detail/detail.mjs +14 -63
  12. package/api/docs/detail/section/section.d.mts +1 -1
  13. package/api/docs/detail/section/section.mjs +44 -20
  14. package/api/docs/detail/section/section.test.mjs +41 -0
  15. package/api/docs/docs.d.mts +7 -2
  16. package/api/docs/docs.doc.mjs +27 -10
  17. package/api/docs/docs.mjs +16 -9
  18. package/api/docs/docs.test.mjs +6 -0
  19. package/api/docs/docs.type.d.mts +40 -3
  20. package/api/docs/docs.type.mjs +36 -8
  21. package/api/docs/index/index.d.mts +18 -0
  22. package/api/docs/index/index.mjs +32 -0
  23. package/api/docs/index/index.test.mjs +62 -0
  24. package/api/docs/integrationDocs.test.mjs +106 -0
  25. package/api/doctor/doctor.d.mts +48 -0
  26. package/api/doctor/doctor.mjs +232 -0
  27. package/api/doctor/doctor.test.mjs +196 -0
  28. package/api/hook/hook.type.d.mts +3 -3
  29. package/api/hook/hook.type.mjs +11 -11
  30. package/api/hook/list/list.d.mts +1 -1
  31. package/api/integration/add-contribution.mjs +5 -3
  32. package/api/integration/add-contribution.test.mjs +4 -4
  33. package/api/integration/integration-authoring.type.d.mts +1 -1
  34. package/api/integration/pack-check.mjs +49 -7
  35. package/api/integration/pack-check.test.mjs +249 -0
  36. package/api/search/search.d.mts +1 -1
  37. package/api/search/search.mjs +5 -5
  38. package/api/search/search.type.d.mts +2 -2
  39. package/api/search/search.type.mjs +1 -1
  40. package/api/swizzle/swizzle.type.d.mts +2 -2
  41. package/api/swizzle/swizzle.type.mjs +2 -2
  42. package/api/template/template.d.mts +1 -1
  43. package/api/template/template.type.d.mts +6 -6
  44. package/api/template/template.type.mjs +12 -12
  45. package/api/theme/build/build.mjs +20 -6
  46. package/api/theme/build/build.test.mjs +127 -0
  47. package/api/theme/palette/generate/generate.mjs +1 -1
  48. package/api/theme/palette/generate/generator.d.mts +10 -13
  49. package/api/theme/palette/generate/generator.mjs +7 -3
  50. package/api/theme/theme.type.d.mts +170 -11
  51. package/api/theme/theme.type.mjs +94 -27
  52. package/api/upgrade/_adapter.mjs +71 -5
  53. package/api/upgrade/project-context.test.mjs +272 -0
  54. package/api/upgrade/upgrade.doc.mjs +4 -3
  55. package/api/upgrade/upgrade.type.d.mts +5 -5
  56. package/api/upgrade/upgrade.type.mjs +11 -11
  57. package/assets/codemods/integration-discovery.mjs +40 -2
  58. package/assets/codemods/integration-discovery.test.mjs +58 -0
  59. package/assets/codemods/transforms/v0.3.0/__tests__/unwrap-authoring-factories.test.mjs +27 -5
  60. package/assets/codemods/transforms/v0.3.0/unwrap-authoring-factories.mjs +20 -5
  61. package/assets/docs/README.md +9 -0
  62. package/assets/docs/authoring.doc.mjs +14 -0
  63. package/assets/docs/cli-integrations.doc.mjs +86 -15
  64. package/assets/docs/styling-libraries.doc.mjs +1 -1
  65. package/assets/docs/working-with-ai.doc.mjs +1 -1
  66. package/authoring/_shared/contract.ts +22 -0
  67. package/authoring/codemod/codemod.doc.mjs +6 -1
  68. package/authoring/codemod/parse.d.mts +8 -8
  69. package/authoring/codemod/parse.mjs +8 -6
  70. package/authoring/config/parse.d.mts +13 -13
  71. package/authoring/config/parse.mjs +8 -8
  72. package/authoring/config/type.ts +3 -3
  73. package/authoring/debug/parse.d.mts +5 -5
  74. package/authoring/debug/parse.mjs +3 -3
  75. package/authoring/doctypes/_schema.d.mts +788 -23
  76. package/authoring/doctypes/_schema.mjs +492 -39
  77. package/authoring/doctypes/base/graph-fields.doc.d.mts +9 -0
  78. package/authoring/doctypes/base/graph-fields.doc.mjs +62 -0
  79. package/authoring/doctypes/base/type.ts +40 -0
  80. package/authoring/doctypes/command/command.doc.mjs +3 -2
  81. package/authoring/doctypes/command/parse.d.mts +2 -2
  82. package/authoring/doctypes/command/parse.mjs +1 -1
  83. package/authoring/doctypes/command/type.ts +3 -2
  84. package/authoring/doctypes/component/component.doc.mjs +6 -3
  85. package/authoring/doctypes/component/parse.d.mts +2 -2
  86. package/authoring/doctypes/component/parse.mjs +1 -1
  87. package/authoring/doctypes/component/type.ts +4 -3
  88. package/authoring/doctypes/enum/parse.d.mts +2 -2
  89. package/authoring/doctypes/enum/parse.mjs +1 -1
  90. package/authoring/doctypes/enum/type.ts +3 -1
  91. package/authoring/doctypes/function/function.doc.mjs +4 -0
  92. package/authoring/doctypes/function/parse.d.mts +2 -2
  93. package/authoring/doctypes/function/parse.mjs +1 -1
  94. package/authoring/doctypes/function/type.ts +6 -2
  95. package/authoring/doctypes/hook/hook.doc.mjs +4 -0
  96. package/authoring/doctypes/hook/parse.d.mts +2 -2
  97. package/authoring/doctypes/hook/parse.mjs +1 -1
  98. package/authoring/doctypes/hook/type.ts +3 -2
  99. package/authoring/doctypes/legacy.d.mts +8 -6
  100. package/authoring/doctypes/legacy.mjs +5 -4
  101. package/authoring/doctypes/load-contract.test.mjs +207 -0
  102. package/authoring/doctypes/namespace/namespace.doc.d.mts +9 -0
  103. package/authoring/doctypes/namespace/namespace.doc.mjs +132 -0
  104. package/authoring/doctypes/namespace/parse.d.mts +12 -0
  105. package/authoring/doctypes/namespace/parse.mjs +25 -0
  106. package/authoring/doctypes/namespace/parse.test.mjs +165 -0
  107. package/authoring/doctypes/namespace/type.ts +71 -0
  108. package/authoring/doctypes/parse.d.mts +20 -18
  109. package/authoring/doctypes/parse.mjs +16 -10
  110. package/authoring/doctypes/parse.test.mjs +77 -3
  111. package/authoring/doctypes/reference/parse.d.mts +2 -2
  112. package/authoring/doctypes/reference/parse.mjs +8 -5
  113. package/authoring/doctypes/reference/reference.doc.mjs +17 -4
  114. package/authoring/doctypes/reference/type.ts +51 -5
  115. package/authoring/doctypes/schema/parse.d.mts +2 -2
  116. package/authoring/doctypes/schema/parse.mjs +1 -1
  117. package/authoring/doctypes/schema/type.ts +3 -2
  118. package/authoring/doctypes/template/parse.d.mts +92 -1
  119. package/authoring/doctypes/template/parse.mjs +36 -2
  120. package/authoring/doctypes/template/parse.test.mjs +8 -2
  121. package/authoring/doctypes/template/template.doc.mjs +4 -0
  122. package/authoring/doctypes/template/type.ts +5 -2
  123. package/authoring/doctypes/types.ts +10 -9
  124. package/authoring/gap-report/parse.d.mts +10 -10
  125. package/authoring/gap-report/parse.mjs +6 -6
  126. package/authoring/gap-report/type.ts +1 -1
  127. package/authoring/identity/identity.doc.d.mts +9 -0
  128. package/authoring/identity/identity.doc.mjs +61 -0
  129. package/authoring/identity/type.ts +132 -0
  130. package/authoring/index.d.mts +1 -0
  131. package/authoring/index.d.ts +49 -17
  132. package/authoring/index.mjs +1 -0
  133. package/authoring/integration/integration.doc.mjs +13 -6
  134. package/authoring/integration/parse.d.mts +2 -2
  135. package/authoring/integration/parse.mjs +1 -1
  136. package/authoring/integration/parse.test.mjs +10 -1
  137. package/authoring/integration/schema.d.mts +6 -4
  138. package/authoring/integration/schema.mjs +9 -3
  139. package/authoring/integration/type.ts +23 -6
  140. package/authoring/shadcn/receipt.d.mts +6 -6
  141. package/clients/cli/commands/docs.doc.mjs +13 -3
  142. package/clients/cli/commands/docs.mjs +121 -21
  143. package/clients/cli/commands/docs.test.mjs +88 -0
  144. package/clients/cli/commands/integration-authoring.test.mjs +13 -9
  145. package/clients/cli/commands/theme-palette-generate.doc.mjs +8 -4
  146. package/clients/cli/commands/upgrade.doc.mjs +2 -2
  147. package/clients/cli/formatters/index.mjs +162 -1
  148. package/clients/cli/formatters/index.test.mjs +91 -0
  149. package/clients/cli/lib/manifest.mjs +7 -2
  150. package/foundation/config/project.mjs +21 -6
  151. package/foundation/discovery/authoring-self-docs.d.mts +69 -0
  152. package/foundation/discovery/authoring-self-docs.mjs +214 -0
  153. package/foundation/discovery/authoring-self-docs.test.mjs +154 -0
  154. package/foundation/discovery/component-discovery.d.mts +1 -1
  155. package/foundation/discovery/component-discovery.mjs +2 -1
  156. package/foundation/discovery/docs-discovery.d.mts +11 -4
  157. package/foundation/discovery/docs-discovery.mjs +208 -88
  158. package/foundation/discovery/docs-discovery.test.mjs +279 -13
  159. package/foundation/discovery/docs-output-budget.d.mts +28 -0
  160. package/foundation/discovery/docs-output-budget.mjs +50 -0
  161. package/foundation/discovery/docs-section-key.d.mts +98 -0
  162. package/foundation/discovery/docs-section-key.mjs +221 -0
  163. package/foundation/discovery/docs-section-key.test.mjs +224 -0
  164. package/foundation/discovery/template-adapter.mjs +2 -1
  165. package/foundation/discovery/theming-targets.test.mjs +4 -0
  166. package/foundation/doc-compiler/compile.d.mts +162 -0
  167. package/foundation/doc-compiler/compile.mjs +262 -0
  168. package/foundation/doc-compiler/doc-compiler.test.mjs +687 -0
  169. package/foundation/doc-compiler/ir.d.mts +9 -0
  170. package/foundation/doc-compiler/ir.mjs +287 -0
  171. package/foundation/doc-compiler/lenses.d.mts +33 -0
  172. package/foundation/doc-compiler/lenses.mjs +127 -0
  173. package/foundation/identity/provider-identity.d.mts +90 -0
  174. package/foundation/identity/provider-identity.mjs +320 -0
  175. package/foundation/identity/provider-identity.test.mjs +254 -0
  176. package/foundation/identity/providers.d.mts +7 -0
  177. package/foundation/identity/providers.mjs +16 -0
  178. package/foundation/integrations/autolink.mjs +12 -5
  179. package/foundation/integrations/integration-warnings.mjs +6 -0
  180. package/foundation/integrations/integrations.d.mts +46 -2
  181. package/foundation/integrations/integrations.mjs +167 -8
  182. package/foundation/integrations/integrations.test.mjs +384 -1
  183. package/foundation/integrations/provider-conflicts.test.mjs +125 -0
  184. package/foundation/integrations/validate-contributions.d.mts +2 -0
  185. package/foundation/integrations/validate-contributions.mjs +10 -0
  186. package/foundation/response/json-contract.test.mjs +46 -17
  187. package/foundation/response/response-types.doc.mjs +6 -1
  188. package/package.json +9 -11
@@ -77,6 +77,7 @@ describe('configured integrations', () => {
77
77
  const loaded = project.loadedIntegrations[0];
78
78
  // Identity comes from package.json, not the manifest.
79
79
  expect(loaded.name).toBe('@acme/widgets');
80
+ expect(loaded.providerId).toBe('@acme/widgets');
80
81
  expect(loaded.version).toBe('1.2.3');
81
82
  expect(loaded.components).toBe(path.join(pkgDir, 'docs'));
82
83
  expect(loaded.templates).toBe(path.join(pkgDir, 'blocks'));
@@ -84,6 +85,43 @@ describe('configured integrations', () => {
84
85
  expect(loaded.issuesUrl).toBe('https://example.com/issues');
85
86
  });
86
87
 
88
+ it('preserves provider identity across an explicit package rename', async () => {
89
+ writeManifestPackage(tmpDir, {
90
+ body: `export default {providerId: '@acme/legacy-widgets'};\n`,
91
+ });
92
+
93
+ const [loaded] = await loadIntegrations(['@acme/widgets'], {cwd: tmpDir});
94
+ expect(loaded.name).toBe('@acme/widgets');
95
+ expect(loaded.providerId).toBe('@acme/legacy-widgets');
96
+ expect(loaded.__unknownKeys).toEqual([]);
97
+ });
98
+
99
+ it('deduplicates package aliases by canonical provider identity', async () => {
100
+ writeManifestPackage(tmpDir, {body: `export default {};\n`});
101
+ const aliasDir = path.join(
102
+ tmpDir,
103
+ 'node_modules',
104
+ '@acme',
105
+ 'widgets-alias',
106
+ );
107
+ fs.mkdirSync(aliasDir, {recursive: true});
108
+ fs.writeFileSync(
109
+ path.join(aliasDir, 'package.json'),
110
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
111
+ );
112
+ fs.writeFileSync(
113
+ path.join(aliasDir, 'astryx.integration.mjs'),
114
+ `export default {};\n`,
115
+ );
116
+
117
+ const loaded = await loadIntegrations(
118
+ ['@acme/widgets', '@acme/widgets-alias'],
119
+ {cwd: tmpDir},
120
+ );
121
+ expect(loaded).toHaveLength(1);
122
+ expect(loaded[0].providerId).toBe('@acme/widgets');
123
+ });
124
+
87
125
  it('makes integration components discoverable', async () => {
88
126
  const pkgDir = writeManifestPackage(tmpDir, {
89
127
  body: `export default { components: './docs' };\n`,
@@ -207,6 +245,347 @@ describe('configured integrations', () => {
207
245
  });
208
246
  });
209
247
 
248
+ describe('provider identity conflicts', () => {
249
+ /**
250
+ * Install one integration package that contributes a single component.
251
+ * @param {string} dir directory under node_modules
252
+ * @param {{name: string, version: string}} pkg package.json identity
253
+ * @param {Record<string, unknown>} manifest extra manifest fields
254
+ * @param {string} component the component it contributes
255
+ */
256
+ function installPackage(dir, pkg, manifest, component) {
257
+ const pkgDir = path.join(tmpDir, 'node_modules', ...dir.split('/'));
258
+ const componentsDir = path.join(pkgDir, 'components');
259
+ fs.mkdirSync(componentsDir, {recursive: true});
260
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify(pkg));
261
+ fs.writeFileSync(
262
+ path.join(pkgDir, 'astryx.integration.mjs'),
263
+ `export default ${JSON.stringify({components: './components', ...manifest})};\n`,
264
+ );
265
+ fs.writeFileSync(
266
+ path.join(componentsDir, `${component}.doc.mjs`),
267
+ `export default {type: 'component', name: '${component}', props: []};\n`,
268
+ );
269
+ fs.writeFileSync(
270
+ path.join(componentsDir, `${component}.tsx`),
271
+ `export function ${component}() { return null; }\n`,
272
+ );
273
+ }
274
+
275
+ /** @param {string[]} integrations */
276
+ function configure(integrations) {
277
+ fs.writeFileSync(
278
+ path.join(tmpDir, 'astryx.config.mjs'),
279
+ `export default ${JSON.stringify({integrations})};\n`,
280
+ );
281
+ }
282
+
283
+ /**
284
+ * Make the project directory itself the package being authored.
285
+ * @param {Record<string, unknown>} pkg package.json fields
286
+ * @param {Record<string, unknown>} manifest extra manifest fields
287
+ * @param {string} component the component it contributes
288
+ */
289
+ function writeLocalPackage(pkg, manifest, component) {
290
+ const componentsDir = path.join(tmpDir, 'local-components');
291
+ fs.mkdirSync(componentsDir, {recursive: true});
292
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(pkg));
293
+ fs.writeFileSync(
294
+ path.join(tmpDir, 'astryx.integration.mjs'),
295
+ `export default ${JSON.stringify({components: './local-components', ...manifest})};\n`,
296
+ );
297
+ fs.writeFileSync(
298
+ path.join(componentsDir, `${component}.doc.mjs`),
299
+ `export default {type: 'component', name: '${component}', props: []};\n`,
300
+ );
301
+ fs.writeFileSync(
302
+ path.join(componentsDir, `${component}.tsx`),
303
+ `export function ${component}() { return null; }\n`,
304
+ );
305
+ }
306
+
307
+ /** @param {import('../config/project.mjs').Project} project */
308
+ async function conflictIssues(project) {
309
+ return (await project.issues()).filter(
310
+ issue => issue.code === 'duplicate_provider',
311
+ );
312
+ }
313
+
314
+ it('keeps the first package and reports a different package claiming its ID', async () => {
315
+ installPackage(
316
+ '@acme/widgets',
317
+ {name: '@acme/widgets', version: '1.0.0'},
318
+ {},
319
+ 'Widget',
320
+ );
321
+ installPackage(
322
+ '@acme/renamed',
323
+ {name: '@acme/renamed', version: '2.0.0'},
324
+ {providerId: '@acme/widgets'},
325
+ 'RenamedWidget',
326
+ );
327
+ configure(['@acme/widgets', '@acme/renamed']);
328
+
329
+ const loaded = await loadIntegrations(['@acme/widgets', '@acme/renamed'], {
330
+ cwd: tmpDir,
331
+ });
332
+ expect(loaded.map(integration => integration.name)).toEqual([
333
+ '@acme/widgets',
334
+ '@acme/renamed',
335
+ ]);
336
+ expect(loaded[1]).toMatchObject({
337
+ providerId: '@acme/widgets',
338
+ __providerConflict: {
339
+ providerId: '@acme/widgets',
340
+ claimedBy: '@acme/widgets',
341
+ },
342
+ });
343
+ expect(loaded[1].components).toBeUndefined();
344
+
345
+ const project = await Project.load(tmpDir, {fresh: true});
346
+ const owned = (await project.components()).map(component => [
347
+ component.package,
348
+ component.name,
349
+ ]);
350
+ expect(owned).toContainEqual(['@acme/widgets', 'Widget']);
351
+ expect(owned.some(([pkg]) => pkg === '@acme/renamed')).toBe(false);
352
+ expect(await project.issues()).toContainEqual(
353
+ expect.objectContaining({
354
+ package: '@acme/renamed@2.0.0',
355
+ code: 'duplicate_provider',
356
+ severity: 'warning',
357
+ message: expect.stringContaining(
358
+ '@acme/widgets@1.0.0 loads first and is used',
359
+ ),
360
+ }),
361
+ );
362
+ });
363
+
364
+ it('reports the same package configured again at another version', async () => {
365
+ installPackage(
366
+ '@acme/widgets',
367
+ {name: '@acme/widgets', version: '1.0.0'},
368
+ {},
369
+ 'Widget',
370
+ );
371
+ installPackage(
372
+ 'widgets-next',
373
+ {name: '@acme/widgets', version: '2.0.0'},
374
+ {},
375
+ 'NextWidget',
376
+ );
377
+ configure(['@acme/widgets', 'widgets-next']);
378
+
379
+ const project = await Project.load(tmpDir, {fresh: true});
380
+ const owned = (await project.components()).map(component => component.name);
381
+ expect(owned).toContain('Widget');
382
+ expect(owned).not.toContain('NextWidget');
383
+ expect(await project.issues()).toContainEqual(
384
+ expect.objectContaining({
385
+ package: '@acme/widgets@2.0.0',
386
+ code: 'duplicate_provider',
387
+ severity: 'warning',
388
+ message: expect.stringContaining('(from "widgets-next")'),
389
+ }),
390
+ );
391
+ });
392
+
393
+ it('reports an autolinked dependency that claims a configured ID', async () => {
394
+ installPackage(
395
+ '@acme/widgets',
396
+ {name: '@acme/widgets', version: '1.0.0'},
397
+ {},
398
+ 'Widget',
399
+ );
400
+ installPackage(
401
+ '@acme/renamed',
402
+ {name: '@acme/renamed', version: '2.0.0'},
403
+ {providerId: '@acme/widgets'},
404
+ 'RenamedWidget',
405
+ );
406
+ configure(['@acme/widgets']);
407
+ fs.writeFileSync(
408
+ path.join(tmpDir, 'package.json'),
409
+ JSON.stringify({
410
+ name: 'consumer',
411
+ dependencies: {'@acme/renamed': '2.0.0'},
412
+ }),
413
+ );
414
+
415
+ const project = await Project.load(tmpDir, {fresh: true});
416
+ expect(
417
+ project.loadedIntegrations.find(
418
+ integration => integration.name === '@acme/renamed',
419
+ ),
420
+ ).toMatchObject({
421
+ __autolinked: true,
422
+ __providerConflict: {claimedBy: '@acme/widgets'},
423
+ });
424
+ expect(await project.issues()).toContainEqual(
425
+ expect.objectContaining({
426
+ package: '@acme/renamed@2.0.0',
427
+ code: 'duplicate_provider',
428
+ }),
429
+ );
430
+ });
431
+
432
+ it("reports a set-aside package whose spec is the winner's name", async () => {
433
+ installPackage(
434
+ 'widgets-old',
435
+ {name: '@acme/widgets', version: '1.0.0'},
436
+ {},
437
+ 'OldWidget',
438
+ );
439
+ installPackage(
440
+ '@acme/widgets',
441
+ {name: '@acme/widgets', version: '2.0.0'},
442
+ {},
443
+ 'NewWidget',
444
+ );
445
+ configure(['widgets-old', '@acme/widgets']);
446
+
447
+ const project = await Project.load(tmpDir, {fresh: true});
448
+ const owned = (await project.components()).map(component => component.name);
449
+ expect(owned).toContain('OldWidget');
450
+ expect(owned).not.toContain('NewWidget');
451
+ expect(await conflictIssues(project)).toEqual([
452
+ expect.objectContaining({
453
+ package: '@acme/widgets@2.0.0',
454
+ severity: 'warning',
455
+ message: expect.stringContaining(
456
+ '@acme/widgets@2.0.0 and @acme/widgets@1.0.0 (from "widgets-old") both claim',
457
+ ),
458
+ }),
459
+ ]);
460
+ });
461
+
462
+ it('reports an unversioned set-aside package whose spec is the winner\'s name', async () => {
463
+ installPackage(
464
+ 'widgets-old',
465
+ {name: '@acme/widgets', version: '1.0.0'},
466
+ {},
467
+ 'OldWidget',
468
+ );
469
+ installPackage('@acme/widgets', {name: '@acme/widgets'}, {}, 'NewWidget');
470
+ configure(['widgets-old', '@acme/widgets']);
471
+
472
+ const project = await Project.load(tmpDir, {fresh: true});
473
+ expect(await conflictIssues(project)).toEqual([
474
+ expect.objectContaining({
475
+ package: '@acme/widgets',
476
+ message: expect.stringContaining(
477
+ '@acme/widgets@1.0.0 (from "widgets-old") loads first and is used',
478
+ ),
479
+ }),
480
+ ]);
481
+ });
482
+
483
+ it('reports an autolinked alias of the same package at another version', async () => {
484
+ installPackage(
485
+ '@acme/legacy-ui',
486
+ {name: '@acme/ui', version: '0.1.22'},
487
+ {},
488
+ 'LegacyButton',
489
+ );
490
+ installPackage(
491
+ '@acme/ui',
492
+ {name: '@acme/ui', version: '1.0.0'},
493
+ {},
494
+ 'NewButton',
495
+ );
496
+ fs.rmSync(path.join(tmpDir, 'astryx.config.mjs'));
497
+ fs.writeFileSync(
498
+ path.join(tmpDir, 'package.json'),
499
+ JSON.stringify({
500
+ name: 'consumer',
501
+ dependencies: {
502
+ '@acme/legacy-ui': 'npm:@acme/ui@0.1.22',
503
+ '@acme/ui': '1.0.0',
504
+ },
505
+ }),
506
+ );
507
+
508
+ const project = await Project.load(tmpDir, {fresh: true});
509
+ const owned = (await project.components()).map(component => component.name);
510
+ expect(owned).toContain('LegacyButton');
511
+ expect(owned).not.toContain('NewButton');
512
+ expect(await conflictIssues(project)).toEqual([
513
+ expect.objectContaining({
514
+ package: '@acme/ui@1.0.0',
515
+ message: expect.stringContaining(
516
+ '@acme/ui@1.0.0 and @acme/ui@0.1.22 (from "@acme/legacy-ui") both claim',
517
+ ),
518
+ }),
519
+ ]);
520
+ });
521
+
522
+ it('recomputes conflicts after the authored package replaces its installed copy', async () => {
523
+ installPackage(
524
+ '@acme/widgets',
525
+ {name: '@acme/widgets', version: '1.0.0'},
526
+ {},
527
+ 'Widget',
528
+ );
529
+ installPackage(
530
+ '@acme/renamed',
531
+ {name: '@acme/renamed', version: '2.0.0'},
532
+ {providerId: '@acme/widgets'},
533
+ 'RenamedWidget',
534
+ );
535
+ configure(['@acme/widgets', '@acme/renamed']);
536
+ writeLocalPackage(
537
+ {name: '@acme/widgets', version: '3.0.0-dev'},
538
+ {providerId: '@acme/widgets-next'},
539
+ 'LocalWidget',
540
+ );
541
+
542
+ const project = await Project.load(tmpDir, {fresh: true});
543
+ expect(
544
+ project.loadedIntegrations.some(
545
+ integration => integration.__providerConflict,
546
+ ),
547
+ ).toBe(false);
548
+ const owned = (await project.components()).map(component => component.name);
549
+ expect(owned).toEqual(
550
+ expect.arrayContaining(['LocalWidget', 'RenamedWidget']),
551
+ );
552
+ expect(owned).not.toContain('Widget');
553
+ expect(await conflictIssues(project)).toEqual([]);
554
+ });
555
+
556
+ it('lets the package being authored win over its installed predecessor', async () => {
557
+ installPackage(
558
+ '@acme/widgets',
559
+ {name: '@acme/widgets', version: '1.0.0'},
560
+ {},
561
+ 'Widget',
562
+ );
563
+ fs.rmSync(path.join(tmpDir, 'astryx.config.mjs'));
564
+ writeLocalPackage(
565
+ {
566
+ name: '@acme/renamed',
567
+ version: '2.0.0-dev',
568
+ devDependencies: {'@acme/widgets': '1.0.0'},
569
+ },
570
+ {providerId: '@acme/widgets'},
571
+ 'RenamedWidget',
572
+ );
573
+
574
+ const project = await Project.load(tmpDir, {fresh: true});
575
+ const owned = (await project.components()).map(component => component.name);
576
+ expect(owned).toContain('RenamedWidget');
577
+ expect(owned).not.toContain('Widget');
578
+ expect(await conflictIssues(project)).toEqual([
579
+ expect.objectContaining({
580
+ package: '@acme/widgets@1.0.0',
581
+ message: expect.stringContaining(
582
+ '@acme/renamed@2.0.0-dev is the package being authored, so it is used',
583
+ ),
584
+ }),
585
+ ]);
586
+ });
587
+ });
588
+
210
589
  describe('local integration self-resolution', () => {
211
590
  it('preserves a valid gapReport named export', async () => {
212
591
  fs.writeFileSync(
@@ -225,7 +604,11 @@ export default {};
225
604
 
226
605
  const loaded = await loadLocalIntegration(tmpDir, {fresh: true});
227
606
 
228
- expect(loaded).toMatchObject({name: '@acme/local', __local: true});
607
+ expect(loaded).toMatchObject({
608
+ name: '@acme/local',
609
+ providerId: '@acme/local',
610
+ __local: true,
611
+ });
229
612
  expect(loaded?.__gapReport?.audience).toBe('internal');
230
613
  expect(
231
614
  loaded?.__gapReport?.handle(
@@ -0,0 +1,125 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Provider-identity conflicts proved against the repository's real
5
+ * integration packages instead of hand-written fixtures: the Lab package as it
6
+ * ships, beside a copy of Charts whose manifest also claims Lab's provider ID.
7
+ *
8
+ * Fixtures live under a repo-local temp dir, not /tmp, because Vite refuses to
9
+ * dynamically import a module from outside the project root.
10
+ */
11
+
12
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import {fileURLToPath} from 'node:url';
16
+ import {Project} from '../config/project.mjs';
17
+ import {doctor} from '../../api/doctor/doctor.mjs';
18
+ import {runCli} from '../../test-utils/run-cli.mjs';
19
+
20
+ const REPO = path.resolve(
21
+ path.dirname(fileURLToPath(import.meta.url)),
22
+ '../../../..',
23
+ );
24
+ const CHARTS = path.join(REPO, 'packages', 'charts');
25
+ const SLOW = 60_000;
26
+
27
+ /** Both packages are named; Lab is named as the one used, Charts as set aside. */
28
+ const WINNER_NAMED =
29
+ /@astryxdesign\/charts@\S+ and @astryxdesign\/lab@\S+ both claim provider ID "@astryxdesign\/lab"\. @astryxdesign\/lab@\S+ loads first and is used; @astryxdesign\/charts@\S+ contributes nothing/u;
30
+
31
+ let consumer;
32
+ let chartsManifest;
33
+
34
+ beforeEach(() => {
35
+ consumer = fs.mkdtempSync(
36
+ path.join(process.cwd(), '.astryx-provider-conflict-'),
37
+ );
38
+ const scope = path.join(consumer, 'node_modules', '@astryxdesign');
39
+ fs.mkdirSync(scope, {recursive: true});
40
+ fs.symlinkSync(
41
+ path.join(REPO, 'packages', 'lab'),
42
+ path.join(scope, 'lab'),
43
+ 'dir',
44
+ );
45
+
46
+ const charts = path.join(scope, 'charts');
47
+ fs.cpSync(CHARTS, charts, {
48
+ recursive: true,
49
+ filter: source => {
50
+ const [top] = path.relative(CHARTS, source).split(path.sep);
51
+ return top !== 'node_modules' && top !== 'dist';
52
+ },
53
+ });
54
+ chartsManifest = path.join(charts, 'astryx.integration.mjs');
55
+ const shipped = fs.readFileSync(chartsManifest, 'utf8');
56
+ const claimed = shipped.replace(
57
+ 'export default {',
58
+ "export default {\n providerId: '@astryxdesign/lab',",
59
+ );
60
+ expect(claimed).not.toBe(shipped);
61
+ fs.writeFileSync(chartsManifest, claimed);
62
+
63
+ fs.writeFileSync(
64
+ path.join(consumer, 'package.json'),
65
+ JSON.stringify({name: 'consumer', private: true}),
66
+ );
67
+ fs.writeFileSync(
68
+ path.join(consumer, 'astryx.config.mjs'),
69
+ "export default {integrations: ['@astryxdesign/lab', '@astryxdesign/charts']};\n",
70
+ );
71
+ });
72
+
73
+ afterEach(() => {
74
+ fs.rmSync(consumer, {recursive: true, force: true});
75
+ });
76
+
77
+ describe('provider identity conflicts in the real integration packages', () => {
78
+ it('keeps Lab, sets Charts aside, and names both on every surface', async () => {
79
+ const project = await Project.load(consumer, {fresh: true});
80
+ const components = await project.components();
81
+ expect(
82
+ components.filter(component => component.package === '@astryxdesign/lab')
83
+ .length,
84
+ ).toBeGreaterThan(0);
85
+ expect(
86
+ components.some(component => component.package === '@astryxdesign/charts'),
87
+ ).toBe(false);
88
+ const conflicts = (await project.issues()).filter(
89
+ issue => issue.code === 'duplicate_provider',
90
+ );
91
+ expect(conflicts).toEqual([
92
+ expect.objectContaining({
93
+ package: expect.stringMatching(/^@astryxdesign\/charts@\S+$/u),
94
+ severity: 'warning',
95
+ message: expect.stringMatching(WINNER_NAMED),
96
+ }),
97
+ ]);
98
+
99
+ const report = await doctor({cwd: consumer});
100
+ const check = report.data.checks.find(
101
+ candidate => candidate.id === 'provider-identity',
102
+ );
103
+ expect(check?.status).toBe('warn');
104
+ expect(check?.message).toMatch(WINNER_NAMED);
105
+
106
+ const cli = await runCli(['component', '--list'], consumer);
107
+ expect(cli.status).toBe(0);
108
+ expect(cli.stderr).toMatch(WINNER_NAMED);
109
+ }, SLOW);
110
+
111
+ it('loads both real packages without an issue once their IDs differ', async () => {
112
+ fs.copyFileSync(path.join(CHARTS, 'astryx.integration.mjs'), chartsManifest);
113
+
114
+ const project = await Project.load(consumer, {fresh: true});
115
+ const components = await project.components();
116
+ expect(
117
+ components.some(component => component.package === '@astryxdesign/charts'),
118
+ ).toBe(true);
119
+ expect(
120
+ (await project.issues()).filter(
121
+ issue => issue.code === 'duplicate_provider',
122
+ ),
123
+ ).toEqual([]);
124
+ }, SLOW);
125
+ });
@@ -22,5 +22,7 @@ export function validateLoadedIntegration(loaded: LoadedIntegration): Promise<Is
22
22
  */
23
23
  /** Stable issue code for an invalid optional agent-doc contribution. */
24
24
  export const INVALID_AGENT_DOCS: "invalid_agent_docs";
25
+ /** Stable issue code for a package whose provider ID is already claimed. */
26
+ export const DUPLICATE_PROVIDER: "duplicate_provider";
25
27
  export type Issue = import("./issue").AstryxIntegrationIssue;
26
28
  export type LoadedIntegration = import("./integrations.mjs").LoadedIntegration;
@@ -39,6 +39,9 @@ import {discoverIntegrationThemes} from '../discovery/theme-discovery.mjs';
39
39
  /** Stable issue code for an invalid optional agent-doc contribution. */
40
40
  export const INVALID_AGENT_DOCS = 'invalid_agent_docs';
41
41
 
42
+ /** Stable issue code for a package whose provider ID is already claimed. */
43
+ export const DUPLICATE_PROVIDER = 'duplicate_provider';
44
+
42
45
  /** @param {string} code @param {string} message @returns {Issue} */
43
46
  export function issueError(code, message) {
44
47
  return {code, severity: 'error', message};
@@ -289,6 +292,13 @@ export async function validateLoadedIntegration(loaded) {
289
292
  /** @type {Issue[]} */
290
293
  const issues = [];
291
294
  if (!loaded || typeof loaded !== 'object') return issues;
295
+ // A package set aside for claiming another package's provider ID has no
296
+ // contribution roots to check; the conflict itself is what to report.
297
+ if (loaded.__providerConflict) {
298
+ return [
299
+ issueWarning(DUPLICATE_PROVIDER, loaded.__providerConflict.message),
300
+ ];
301
+ }
292
302
  // A manifest that threw on import (or failed the schema) carries a load-error
293
303
  // marker and no contribution roots, so every check below would find nothing
294
304
  // and report a clean integration — which is how a stale manifest used to go
@@ -13,6 +13,8 @@
13
13
 
14
14
  import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
15
15
  import {spawnSync} from 'node:child_process';
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
16
18
  import * as path from 'node:path';
17
19
  import {fileURLToPath} from 'node:url';
18
20
  import {
@@ -27,10 +29,20 @@ import {
27
29
  } from './json.mjs';
28
30
 
29
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
- const CLI = path.resolve(__dirname, '..', '..', 'clients', 'cli', 'bin', 'astryx.mjs');
31
-
32
- function runCli(args) {
32
+ const CLI = path.resolve(
33
+ __dirname,
34
+ '..',
35
+ '..',
36
+ 'clients',
37
+ 'cli',
38
+ 'bin',
39
+ 'astryx.mjs',
40
+ );
41
+
42
+ /** @param {string[]} args @param {{cwd?: string}} [options] */
43
+ function runCli(args, {cwd = process.cwd()} = {}) {
33
44
  return spawnSync(process.execPath, [CLI, ...args], {
45
+ cwd,
34
46
  encoding: 'utf8',
35
47
  timeout: 20_000,
36
48
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -42,7 +54,9 @@ describe('json envelope shape', () => {
42
54
  let logs;
43
55
  beforeEach(() => {
44
56
  logs = [];
45
- vi.spyOn(console, 'log').mockImplementation((...a) => logs.push(a.join(' ')));
57
+ vi.spyOn(console, 'log').mockImplementation((...a) =>
58
+ logs.push(a.join(' ')),
59
+ );
46
60
  setJsonMode(false);
47
61
  });
48
62
  afterEach(() => {
@@ -80,11 +94,13 @@ describe('json envelope shape', () => {
80
94
  });
81
95
 
82
96
  it('toErrorEnvelope honors an explicit code argument', () => {
83
- expect(toErrorEnvelope('boom', undefined, 'ERR_UNKNOWN_COMPONENT')).toEqual({
84
- apiVersion: API_VERSION,
85
- error: 'boom',
86
- code: 'ERR_UNKNOWN_COMPONENT',
87
- });
97
+ expect(toErrorEnvelope('boom', undefined, 'ERR_UNKNOWN_COMPONENT')).toEqual(
98
+ {
99
+ apiVersion: API_VERSION,
100
+ error: 'boom',
101
+ code: 'ERR_UNKNOWN_COMPONENT',
102
+ },
103
+ );
88
104
  });
89
105
 
90
106
  it('toErrorEnvelope reads a code carried on a thrown Error', () => {
@@ -109,8 +125,12 @@ describe('json envelope shape', () => {
109
125
  // The guard is `Array.isArray(...) && .length`, not a bare `?.length` — a
110
126
  // string ("hello".length === 5) or a `{length: n}` object must NOT slip
111
127
  // through as `suggestions`, which is contractually a Suggestion[].
112
- expect('suggestions' in toErrorEnvelope('x', /** @type {any} */ ('hello'))).toBe(false);
113
- expect('suggestions' in toErrorEnvelope('x', /** @type {any} */ ({length: 3}))).toBe(false);
128
+ expect(
129
+ 'suggestions' in toErrorEnvelope('x', /** @type {any} */ ('hello')),
130
+ ).toBe(false);
131
+ expect(
132
+ 'suggestions' in toErrorEnvelope('x', /** @type {any} */ ({length: 3})),
133
+ ).toBe(false);
114
134
  });
115
135
  });
116
136
 
@@ -119,8 +139,12 @@ describe('stdout discipline (humanLog / humanWarn)', () => {
119
139
  beforeEach(() => {
120
140
  logs = [];
121
141
  errs = [];
122
- vi.spyOn(console, 'log').mockImplementation((...a) => logs.push(a.join(' ')));
123
- vi.spyOn(console, 'error').mockImplementation((...a) => errs.push(a.join(' ')));
142
+ vi.spyOn(console, 'log').mockImplementation((...a) =>
143
+ logs.push(a.join(' ')),
144
+ );
145
+ vi.spyOn(console, 'error').mockImplementation((...a) =>
146
+ errs.push(a.join(' ')),
147
+ );
124
148
  });
125
149
  afterEach(() => {
126
150
  vi.restoreAllMocks();
@@ -174,10 +198,15 @@ describe('contract: every --json emission is valid JSON with apiVersion', () =>
174
198
  });
175
199
 
176
200
  it('init emits its install receipt', () => {
177
- const r = runCli(['init', '--json']);
178
- const env = JSON.parse(r.stdout);
179
- expect(env.apiVersion).toBe(API_VERSION);
180
- expect(env.type).toBe('init.run');
201
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-json-contract-'));
202
+ try {
203
+ const r = runCli(['init', '--json'], {cwd});
204
+ const env = JSON.parse(r.stdout);
205
+ expect(env.apiVersion).toBe(API_VERSION);
206
+ expect(env.type).toBe('init.run');
207
+ } finally {
208
+ fs.rmSync(cwd, {recursive: true, force: true});
209
+ }
181
210
  });
182
211
 
183
212
  it('supported command (discover) emits clean JSON, no human chatter leak', () => {