@astryxdesign/cli 0.7.2-canary.db3a2e7 → 0.7.2-canary.e26723d

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 (142) hide show
  1. package/README.md +7 -7
  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/component.test.mjs +420 -167
  7. package/api/component/integration-export-name.test.mjs +644 -0
  8. package/api/component/list/list.mjs +42 -4
  9. package/api/docs/_adapter.mjs +207 -1
  10. package/api/docs/integrationDocs.test.mjs +124 -0
  11. package/api/doctor/theme-checks.mjs +1 -1
  12. package/api/gap-report/gap-report.mjs +67 -4
  13. package/api/init/run/run.mjs +2 -2
  14. package/api/integration/_adapter.d.mts +12 -0
  15. package/api/integration/_adapter.mjs +54 -0
  16. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  17. package/api/integration/add-contribution.mjs +7 -3
  18. package/api/integration/authoring-checks.mjs +11 -2
  19. package/api/integration/authoring-checks.test.mjs +39 -0
  20. package/api/integration/authoring-checks.type.d.mts +9 -0
  21. package/api/integration/authoring-checks.type.mjs +14 -1
  22. package/api/integration/integrationComponentConflicts.doc.mjs +21 -5
  23. package/api/integration/pack-check.mjs +61 -107
  24. package/api/integration/pack-check.test.mjs +359 -0
  25. package/api/integration/validate-integration.test.mjs +11 -0
  26. package/api/search/search.d.mts +9 -0
  27. package/api/search/search.mjs +108 -16
  28. package/api/search/search.test.mjs +36 -0
  29. package/api/swizzle/copy/copy.mjs +28 -14
  30. package/api/template/template.test.mjs +120 -3
  31. package/api/theme/build/build.family.test.mjs +16 -0
  32. package/api/theme/build/build.mjs +81 -22
  33. package/api/theme/themeBuild.doc.mjs +3 -3
  34. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  35. package/assets/codemods/transforms/next/index.mjs +11 -1
  36. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  37. package/assets/docs/README.md +10 -0
  38. package/assets/docs/author-a-theme.doc.mjs +1 -1
  39. package/assets/docs/getting-started.doc.mjs +3 -3
  40. package/assets/docs/theme.doc.dense.mjs +1 -1
  41. package/assets/docs/theme.doc.mjs +1 -1
  42. package/assets/docs/theme.doc.zh.mjs +1 -1
  43. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  44. package/assets/docs/tree/check-the-props.doc.mjs +145 -0
  45. package/assets/docs/tree/checks.doc.mjs +4 -4
  46. package/assets/docs/tree/component-family.doc.mjs +5 -1
  47. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  48. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  49. package/assets/docs/tree/links.doc.mjs +2 -2
  50. package/assets/docs/tree/single-component.doc.mjs +3 -2
  51. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  52. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  53. package/assets/docs/tree/versioning.doc.mjs +1 -0
  54. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  55. package/assets/docs/use-a-theme.doc.mjs +2 -2
  56. package/assets/docs/working-with-ai.doc.mjs +43 -4
  57. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  58. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  59. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  60. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  61. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  62. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  63. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  64. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  65. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  66. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  67. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  68. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  69. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  70. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  71. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  72. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  73. package/assets/templates/pages/detail-page/page.tsx +3 -3
  74. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  75. package/assets/theme.template.ts +1 -1
  76. package/authoring/doctypes/base/type.ts +6 -0
  77. package/authoring/doctypes/component/component.doc.mjs +4 -4
  78. package/authoring/doctypes/component/type.ts +6 -3
  79. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  80. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  81. package/authoring/doctypes/template/parse.d.mts +6 -2
  82. package/authoring/doctypes/template/parse.mjs +3 -1
  83. package/authoring/doctypes/template/type.ts +22 -0
  84. package/authoring/index.d.ts +1 -0
  85. package/authoring/integration/integration.doc.mjs +8 -1
  86. package/authoring/integration/parse.test.mjs +10 -0
  87. package/authoring/integration/schema.d.mts +2 -0
  88. package/authoring/integration/schema.mjs +1 -0
  89. package/authoring/integration/type.ts +4 -0
  90. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  91. package/clients/cli/commands/build-theme.mjs +1 -1
  92. package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
  93. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  94. package/clients/cli/commands/doctor-integration-components.doc.mjs +9 -3
  95. package/clients/cli/commands/doctor-integration.test.mjs +392 -0
  96. package/clients/cli/commands/doctor.mjs +74 -0
  97. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  98. package/clients/cli/lib/component-format.mjs +5 -1
  99. package/foundation/config/project.mjs +82 -0
  100. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  101. package/foundation/discovery/component-discovery.d.mts +190 -10
  102. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  103. package/foundation/discovery/component-discovery.mjs +864 -56
  104. package/foundation/discovery/component-discovery.test.mjs +96 -0
  105. package/foundation/discovery/component-replacement.mjs +18 -0
  106. package/foundation/discovery/component-replacement.test.mjs +40 -0
  107. package/foundation/discovery/doc-types.d.mts +157 -0
  108. package/foundation/discovery/doc-types.mjs +1224 -0
  109. package/foundation/discovery/doc-types.test.mjs +652 -0
  110. package/foundation/discovery/docs-discovery.d.mts +81 -0
  111. package/foundation/discovery/docs-discovery.mjs +57 -0
  112. package/foundation/discovery/module-exports.d.mts +27 -0
  113. package/foundation/discovery/module-exports.mjs +356 -31
  114. package/foundation/discovery/module-exports.test.mjs +172 -0
  115. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  116. package/foundation/discovery/package-tsconfig.mjs +228 -0
  117. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  118. package/foundation/discovery/project-typescript.d.mts +23 -0
  119. package/foundation/discovery/project-typescript.mjs +61 -0
  120. package/foundation/discovery/prop-drift.d.mts +220 -0
  121. package/foundation/discovery/prop-drift.mjs +1765 -0
  122. package/foundation/discovery/prop-drift.test.mjs +1197 -0
  123. package/foundation/discovery/template-adapter.mjs +74 -5
  124. package/foundation/discovery/type-match.d.mts +53 -0
  125. package/foundation/discovery/type-match.mjs +660 -0
  126. package/foundation/discovery/type-match.test.mjs +169 -0
  127. package/foundation/discovery/type-text.d.mts +68 -0
  128. package/foundation/discovery/type-text.mjs +658 -0
  129. package/foundation/discovery/type-text.test.mjs +196 -0
  130. package/foundation/discovery/unresolved-references.d.mts +62 -0
  131. package/foundation/discovery/unresolved-references.mjs +790 -0
  132. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  133. package/foundation/doc-compiler/doc-loads.test.mjs +9 -1
  134. package/foundation/doc-compiler/inputs.mjs +23 -0
  135. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  136. package/foundation/doc-compiler/links.d.mts +9 -5
  137. package/foundation/doc-compiler/links.mjs +4 -2
  138. package/foundation/integrations/contribution-inventory.mjs +6 -1
  139. package/foundation/response/response-types.doc.mjs +1 -1
  140. package/foundation/xle/registry.mjs +4 -0
  141. package/foundation/xle/registry.test.mjs +28 -0
  142. package/package.json +10 -10
@@ -21,6 +21,7 @@ import {discoverOwnedComponents} from '../../../foundation/discovery/component-d
21
21
  import {discoverBuiltinTopics} from '../../../foundation/discovery/docs-discovery.mjs';
22
22
  import {findCoreDir} from '../../../foundation/fs/paths.mjs';
23
23
  import {registerDoctor} from './doctor.mjs';
24
+ import {expectRecordsCarry} from '../../../test-utils/text-projection.mjs';
24
25
 
25
26
  let tmpDir;
26
27
  let previousCwd;
@@ -366,6 +367,10 @@ describe('doctor integration — command', () => {
366
367
  const printed = logCalls.join('\n');
367
368
  expect(printed).toContain('core_not_found');
368
369
  expect(printed).not.toContain('[ok]');
370
+ // No TypeScript out here either: the props comparison says it skipped.
371
+ expect(printed).toContain(
372
+ '[skip] Props not compared: TypeScript is not installed in this project',
373
+ );
369
374
  expect(process.exitCode).toBe(1);
370
375
  } finally {
371
376
  tmpDir = previousTmp;
@@ -396,6 +401,328 @@ describe('doctor integration — command', () => {
396
401
  expect(process.exitCode).toBeUndefined();
397
402
  });
398
403
 
404
+ it('components prints each documented prop that differs from the code, and still exits 0', async () => {
405
+ writeComponentIntegration('AcmeBadge');
406
+ fs.writeFileSync(
407
+ path.join(tmpDir, 'components', 'AcmeBadge.doc.mjs'),
408
+ `export default {type: 'component', name: 'AcmeBadge', displayName: 'AcmeBadge', description: 'Fixture component.', props: [{name: 'label', type: 'string', description: 'Text.'}]};\n`,
409
+ );
410
+ fs.writeFileSync(
411
+ path.join(tmpDir, 'components', 'AcmeBadge.tsx'),
412
+ 'export function AcmeBadge(props: {label: string}) { return null; }\n',
413
+ );
414
+ process.chdir(tmpDir);
415
+
416
+ await createProgram().parseAsync([
417
+ 'node',
418
+ 'astryx',
419
+ 'doctor',
420
+ 'integration',
421
+ 'components',
422
+ ]);
423
+
424
+ const output = logCalls.join('\n');
425
+ expect(output).toMatch(/^kind:\s+required_mismatch$/m);
426
+ expect(output).toContain(
427
+ '"label" is required in the code, but the doc does not mark it required.',
428
+ );
429
+ expect(output).toContain(
430
+ '1 prop(s) differ between the docs and the code in 1 of 1 component(s) compared (TypeScript',
431
+ );
432
+ expect(output).not.toContain('[ok] Documented prop');
433
+ expect(process.exitCode).toBeUndefined();
434
+ });
435
+
436
+ it('components compares a hidden doc and a family entry with no record, so their drift is never an [ok]', async () => {
437
+ writeComponentIntegration('AcmeMenu');
438
+ const dir = path.join(tmpDir, 'components');
439
+ fs.writeFileSync(
440
+ path.join(dir, 'AcmeMenu.doc.mjs'),
441
+ `export default {type: 'component', name: 'AcmeMenuRoot', displayName: 'AcmeMenuRoot', description: 'Fixture component.', hiddenComponents: ['AcmeMenuItem'], components: [{name: 'AcmeMenuItem', displayName: 'AcmeMenuItem', description: 'Fixture item.', props: [{name: 'label', type: 'string', description: 'Text.'}]}]};\n`,
442
+ );
443
+ fs.writeFileSync(
444
+ path.join(dir, 'AcmeMenu.tsx'),
445
+ 'export function AcmeMenuRoot() { return null; }\nexport function AcmeMenuItem(props: {value?: string}) { return null; }\n',
446
+ );
447
+ fs.writeFileSync(
448
+ path.join(dir, 'AcmeSecret.doc.mjs'),
449
+ `export default {type: 'component', name: 'AcmeSecret', displayName: 'AcmeSecret', description: 'Fixture component.', hidden: true, props: [{name: 'tone', type: 'string', description: 'Tone.'}]};\n`,
450
+ );
451
+ fs.writeFileSync(
452
+ path.join(dir, 'AcmeSecret.tsx'),
453
+ 'export function AcmeSecret(props: {size?: number}) { return null; }\n',
454
+ );
455
+ process.chdir(tmpDir);
456
+ const run = async (/** @type {string[]} */ flags) => {
457
+ logCalls.length = 0;
458
+ await createProgram().parseAsync([
459
+ 'node',
460
+ 'astryx',
461
+ ...flags,
462
+ 'doctor',
463
+ 'integration',
464
+ 'components',
465
+ ]);
466
+ return logCalls.join('\n');
467
+ };
468
+
469
+ const report = JSON.parse(await run(['--json'])).data.propDrift;
470
+ const output = await run([]);
471
+
472
+ expect(
473
+ report.findings.map(f => `${f.component}.${f.prop} ${f.kind}`),
474
+ ).toEqual([
475
+ 'AcmeMenuItem.label not_in_code',
476
+ 'AcmeMenuItem.value not_in_doc',
477
+ 'AcmeSecret.size not_in_doc',
478
+ 'AcmeSecret.tone not_in_code',
479
+ ]);
480
+ expect(output).not.toContain('[ok] Documented prop');
481
+ expect(process.exitCode).toBeUndefined();
482
+ });
483
+
484
+ it('components lists props a mapped type builds from keys it cannot trace, and says the [ok] leaves them out', async () => {
485
+ writeComponentIntegration('AcmeChip');
486
+ const dir = path.join(tmpDir, 'components');
487
+ fs.writeFileSync(
488
+ path.join(dir, 'AcmeChip.doc.mjs'),
489
+ `export default {type: 'component', name: 'AcmeChip', displayName: 'AcmeChip', description: 'Fixture.', props: [{name: 'label', type: 'string', description: 'Text.'}]};\n`,
490
+ );
491
+ fs.writeFileSync(
492
+ path.join(dir, 'AcmeChip.tsx'),
493
+ "export function AcmeChip(props: {label?: string} & {[K in `on${Capitalize<'open'>}`]?: () => void}) { return null; }\n",
494
+ );
495
+ process.chdir(tmpDir);
496
+ const run = async (/** @type {string[]} */ flags) => {
497
+ logCalls.length = 0;
498
+ await createProgram().parseAsync([
499
+ 'node',
500
+ 'astryx',
501
+ ...flags,
502
+ 'doctor',
503
+ 'integration',
504
+ 'components',
505
+ ]);
506
+ return logCalls.join('\n');
507
+ };
508
+
509
+ const {propsNotChecked} = JSON.parse(await run(['--json'])).data.propDrift;
510
+ const output = await run([]);
511
+
512
+ expect(propsNotChecked).toEqual([
513
+ expect.objectContaining({component: 'AcmeChip', props: ['onOpen']}),
514
+ ]);
515
+ expect(output).toContain(
516
+ '[info] Props not checked for a doc entry in 1 component(s):',
517
+ );
518
+ expect(output).toContain(
519
+ 'in 1 component(s), apart from the props not checked (TypeScript',
520
+ );
521
+ });
522
+
523
+ it('components compares the entries of a family doc whose own source is missing', async () => {
524
+ writeComponentIntegration('AcmeTabs');
525
+ const dir = path.join(tmpDir, 'components');
526
+ const entry = (/** @type {string} */ name, /** @type {string} */ prop) =>
527
+ `{name: '${name}', displayName: '${name}', description: 'Fixture.', props: [{name: '${prop}', type: 'string', description: 'Text.'}]}`;
528
+ fs.writeFileSync(
529
+ path.join(dir, 'AcmeTabs.doc.mjs'),
530
+ `export default {type: 'component', name: 'AcmeTabs', displayName: 'AcmeTabs', description: 'Fixture.', props: [], hiddenComponents: ['AcmeTabsPanel'], components: [${entry('AcmeTabsItem', 'label')}, ${entry('AcmeTabsPanel', 'ghost')}]};\n`,
531
+ );
532
+ fs.rmSync(path.join(dir, 'AcmeTabs.tsx'), {force: true});
533
+ // The package's entry exports the members, so discovery records the
534
+ // listed one, though the family's own file is missing.
535
+ for (const name of ['AcmeTabsItem', 'AcmeTabsPanel']) {
536
+ fs.writeFileSync(
537
+ path.join(dir, `${name}.tsx`),
538
+ `export function ${name}(props: {label?: string; open?: boolean}) { return null; }\n`,
539
+ );
540
+ fs.appendFileSync(
541
+ path.join(tmpDir, 'index.ts'),
542
+ `export {${name}} from './components/${name}';\n`,
543
+ );
544
+ }
545
+ process.chdir(tmpDir);
546
+
547
+ await createProgram().parseAsync([
548
+ 'node',
549
+ 'astryx',
550
+ '--json',
551
+ 'doctor',
552
+ 'integration',
553
+ 'components',
554
+ ]);
555
+ const report = JSON.parse(logCalls.join('\n')).data.propDrift;
556
+
557
+ expect(
558
+ report.findings.map(
559
+ (/** @type {any} */ f) => `${f.component}.${f.prop} ${f.kind}`,
560
+ ),
561
+ ).toEqual([
562
+ 'AcmeTabsItem.open not_in_doc',
563
+ 'AcmeTabsPanel.ghost not_in_code',
564
+ 'AcmeTabsPanel.label not_in_doc',
565
+ 'AcmeTabsPanel.open not_in_doc',
566
+ ]);
567
+ expect(report.unchecked).toEqual([
568
+ {
569
+ component: 'AcmeTabs',
570
+ doc: 'components/AcmeTabs.doc.mjs',
571
+ reason: 'no source file for AcmeTabs',
572
+ },
573
+ ]);
574
+ });
575
+
576
+ it('components counts two docs of one name as two components, and compares a .doc.ts beside a .doc.mjs', async () => {
577
+ writeComponentIntegration('AcmeTwin');
578
+ const dir = path.join(tmpDir, 'components');
579
+ const doc = (/** @type {string} */ name, /** @type {string} */ prop) =>
580
+ `export default {type: 'component', name: '${name}', displayName: '${name}', description: 'Fixture.', props: [{name: '${prop}', type: 'string', description: 'Text.'}]};\n`;
581
+ fs.writeFileSync(
582
+ path.join(dir, 'AcmeTwin.doc.mjs'),
583
+ doc('AcmeTwin', 'tone'),
584
+ );
585
+ fs.writeFileSync(
586
+ path.join(dir, 'AcmeTwin.doc.ts'),
587
+ doc('AcmeTwin', 'size'),
588
+ );
589
+ fs.writeFileSync(
590
+ path.join(dir, 'AcmeTwin.tsx'),
591
+ 'export function AcmeTwin(props: {label?: string}) { return null; }\n',
592
+ );
593
+ for (const folder of ['a', 'b']) {
594
+ fs.mkdirSync(path.join(dir, folder));
595
+ fs.writeFileSync(
596
+ path.join(dir, folder, 'AcmeX.doc.mjs'),
597
+ doc('AcmeX', folder),
598
+ );
599
+ fs.writeFileSync(
600
+ path.join(dir, folder, 'AcmeX.tsx'),
601
+ 'export function AcmeX() { return null; }\n',
602
+ );
603
+ }
604
+ process.chdir(tmpDir);
605
+ const run = async (/** @type {string[]} */ flags) => {
606
+ logCalls.length = 0;
607
+ await createProgram().parseAsync([
608
+ 'node',
609
+ 'astryx',
610
+ ...flags,
611
+ 'doctor',
612
+ 'integration',
613
+ 'components',
614
+ ]);
615
+ return logCalls.join('\n');
616
+ };
617
+
618
+ const report = JSON.parse(await run(['--json'])).data.propDrift;
619
+ const output = await run([]);
620
+
621
+ expect(
622
+ report.findings
623
+ .filter(f => f.kind === 'not_in_code')
624
+ .map(f => `${f.doc} ${f.prop}`)
625
+ .sort(),
626
+ ).toEqual([
627
+ 'components/AcmeTwin.doc.mjs tone',
628
+ 'components/AcmeTwin.doc.ts size',
629
+ 'components/a/AcmeX.doc.mjs a',
630
+ 'components/b/AcmeX.doc.mjs b',
631
+ ]);
632
+ expect(output).toMatch(/ in 4 of 4 component\(s\) compared /);
633
+ });
634
+
635
+ it('components prints every field of the props comparison that JSON carries', async () => {
636
+ writeComponentIntegration('AcmeBadge');
637
+ fs.writeFileSync(
638
+ path.join(tmpDir, 'components', 'AcmeBadge.doc.mjs'),
639
+ `export default {type: 'component', name: 'AcmeBadge', displayName: 'AcmeBadge', description: 'Fixture component.', props: [${[
640
+ "{name: 'label', type: 'string', description: 'Text.'}",
641
+ "{name: 'elevation', type: 'number', description: 'Shadow.'}",
642
+ ].join(', ')}]};\n`,
643
+ );
644
+ // `key` is a prop a doc never needs, though the component declares it.
645
+ fs.writeFileSync(
646
+ path.join(tmpDir, 'components', 'AcmeBadge.tsx'),
647
+ "export function AcmeBadge(props: {label: string; tone?: 'neutral' | 'info'; key?: string}) { return null; }\n",
648
+ );
649
+ fs.writeFileSync(
650
+ path.join(tmpDir, 'components', 'AcmeLonely.doc.mjs'),
651
+ `export default {type: 'component', name: 'AcmeLonely', displayName: 'AcmeLonely', description: 'Fixture component.', props: []};\n`,
652
+ );
653
+ fs.writeFileSync(
654
+ path.join(tmpDir, 'components', 'AcmeLonely.tsx'),
655
+ 'export function Other() { return null; }\n',
656
+ );
657
+ process.chdir(tmpDir);
658
+ const run = async (/** @type {string[]} */ flags) => {
659
+ logCalls.length = 0;
660
+ await createProgram().parseAsync([
661
+ 'node',
662
+ 'astryx',
663
+ ...flags,
664
+ 'doctor',
665
+ 'integration',
666
+ 'components',
667
+ ]);
668
+ return logCalls.join('\n');
669
+ };
670
+
671
+ const report = JSON.parse(await run(['--json'])).data.propDrift;
672
+ const output = await run([]);
673
+
674
+ expect(report.findings.map(f => `${f.prop} ${f.kind}`)).toEqual([
675
+ 'elevation not_in_code',
676
+ 'label required_mismatch',
677
+ 'tone not_in_doc',
678
+ ]);
679
+ expectRecordsCarry(output, report.findings, ['component', 'prop', 'kind']);
680
+ expectRecordsCarry(output, report.unchecked, ['component', 'reason']);
681
+ expect(output).toContain(
682
+ `in 1 of ${report.components} component(s) compared (TypeScript ${report.typescript}).`,
683
+ );
684
+ });
685
+
686
+ it('components says documented props match when they do', async () => {
687
+ writeComponentIntegration('AcmeOnlyWidget');
688
+ process.chdir(tmpDir);
689
+
690
+ await createProgram().parseAsync([
691
+ 'node',
692
+ 'astryx',
693
+ 'doctor',
694
+ 'integration',
695
+ 'components',
696
+ ]);
697
+
698
+ expect(logCalls.join('\n')).toMatch(
699
+ /\[ok\] Documented prop names and required flags match the code in 1 component\(s\) \(TypeScript \d/,
700
+ );
701
+ });
702
+
703
+ it('components prints no [ok] for props it could not compare', async () => {
704
+ writeComponentIntegration('AcmeLonely');
705
+ fs.writeFileSync(
706
+ path.join(tmpDir, 'components', 'AcmeLonely.tsx'),
707
+ 'export function Other() { return null; }\n',
708
+ );
709
+ process.chdir(tmpDir);
710
+
711
+ await createProgram().parseAsync([
712
+ 'node',
713
+ 'astryx',
714
+ 'doctor',
715
+ 'integration',
716
+ 'components',
717
+ ]);
718
+
719
+ const output = logCalls.join('\n');
720
+ expect(output).toContain(
721
+ '[info] Props not compared for 1 documented component(s):',
722
+ );
723
+ expect(output).not.toContain('[ok] Documented prop');
724
+ });
725
+
399
726
  it('components exposes conflicts as structured JSON warnings', async () => {
400
727
  const coreDir = findCoreDir(tmpDir);
401
728
  expect(coreDir).not.toBeNull();
@@ -425,6 +752,71 @@ describe('doctor integration — command', () => {
425
752
  expect(process.exitCode).toBeUndefined();
426
753
  });
427
754
 
755
+ it('docs resolves a link to a component or a template, its own or Core’s, and still flags one that names no doc', async () => {
756
+ const page = (await discoverCoreTemplates()).find(
757
+ template => template.type === 'page',
758
+ );
759
+ expect(page).toBeDefined();
760
+ fs.writeFileSync(
761
+ path.join(tmpDir, 'package.json'),
762
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
763
+ );
764
+ fs.writeFileSync(
765
+ path.join(tmpDir, 'astryx.integration.mjs'),
766
+ "export default {components: './components', templates: './templates', docs: './docs'};\n",
767
+ );
768
+ for (const dir of ['components', 'templates', 'docs']) {
769
+ fs.mkdirSync(path.join(tmpDir, dir), {recursive: true});
770
+ }
771
+ fs.writeFileSync(
772
+ path.join(tmpDir, 'components', 'AcmeButton.doc.mjs'),
773
+ "export default {type: 'component', name: 'AcmeButton', displayName: 'Acme Button', description: 'A button.', props: []};\n",
774
+ );
775
+ fs.writeFileSync(
776
+ path.join(tmpDir, 'components', 'AcmeButton.tsx'),
777
+ 'export function AcmeButton() { return null; }\n',
778
+ );
779
+ fs.writeFileSync(
780
+ path.join(tmpDir, 'templates', 'acme-card.doc.mjs'),
781
+ "export default {type: 'block', name: 'Acme card', description: 'A card.'};\n",
782
+ );
783
+ fs.writeFileSync(
784
+ path.join(tmpDir, 'templates', 'acme-card.tsx'),
785
+ 'export default function Fixture() { return null; }\n',
786
+ );
787
+ const text =
788
+ 'Start with {@link component:AcmeButton} or {@link block:acme-card}, ' +
789
+ 'built on {@link @astryxdesign/core:component:Button} and ' +
790
+ `{@link @astryxdesign/core:page:${page?.dirName}}. Not {@link component:AcmeMissing}.`;
791
+ fs.writeFileSync(
792
+ path.join(tmpDir, 'docs', 'using-widgets.doc.mjs'),
793
+ `export default ${JSON.stringify({
794
+ type: 'generic',
795
+ name: 'using-widgets',
796
+ title: 'Using widgets',
797
+ description: 'Fixture topic.',
798
+ sections: [{title: 'Overview', content: [{type: 'prose', text}]}],
799
+ })};\n`,
800
+ );
801
+ process.chdir(tmpDir);
802
+
803
+ await createProgram().parseAsync([
804
+ 'node',
805
+ 'astryx',
806
+ '--json',
807
+ 'doctor',
808
+ 'integration',
809
+ 'docs',
810
+ ]);
811
+
812
+ const parsed = JSON.parse(logCalls.join('\n'));
813
+ const unresolved = parsed.data.issues
814
+ .filter(issue => issue.code === 'invalid_doc_graph')
815
+ .map(issue => issue.message);
816
+ expect(unresolved).toHaveLength(1);
817
+ expect(unresolved[0]).toContain('"component:AcmeMissing" names no doc');
818
+ });
819
+
428
820
  it('docs reports an intentional replacement as info', async () => {
429
821
  const [coreTopic] = Object.keys(discoverBuiltinTopics());
430
822
  writeDocIntegration({name: 'acme-setup', replaces: coreTopic});
@@ -173,6 +173,79 @@ function printTemplateConflicts(data) {
173
173
  emit(...output);
174
174
  }
175
175
 
176
+ /**
177
+ * The documented-props comparison, with every field its JSON carries: a
178
+ * visible line when it was skipped, the findings (all warnings), the
179
+ * components it could not read, and how many were compared with which
180
+ * TypeScript, as an [ok] when nothing differs (never after a failed check).
181
+ * @param {import('../../../api/integration/authoring-checks.type.mjs').PropDriftReport | undefined} report
182
+ * @param {boolean} failed
183
+ */
184
+ function propDriftBlocks(report, failed) {
185
+ if (!report) return [];
186
+ if (report.status === 'skipped') {
187
+ return [text(`[skip] Props not compared: ${report.reason}`)];
188
+ }
189
+ const blocks = [];
190
+ if (report.findings.length > 0) {
191
+ // Two docs of one name are two components.
192
+ const components = new Set(
193
+ report.findings.map(f => `${f.doc}\0${f.component}`),
194
+ ).size;
195
+ blocks.push(
196
+ records(report.findings, {
197
+ fields: [
198
+ 'severity',
199
+ 'component',
200
+ 'prop',
201
+ 'kind',
202
+ 'message',
203
+ 'doc',
204
+ 'source',
205
+ 'docType',
206
+ 'codeType',
207
+ 'docRequired',
208
+ 'codeRequired',
209
+ ],
210
+ format: {severity: statusToken},
211
+ }),
212
+ text(
213
+ `${report.findings.length} prop(s) differ between the docs and the code in ${components} of ${report.components} component(s) compared (TypeScript ${report.typescript}). ` +
214
+ 'These are warnings: update the doc, or the code if the doc is right.',
215
+ ),
216
+ );
217
+ }
218
+ if (report.unchecked.length > 0) {
219
+ blocks.push(
220
+ text(
221
+ `[info] Props not compared for ${report.unchecked.length} documented component(s):`,
222
+ ),
223
+ records(report.unchecked, {fields: ['component', 'doc', 'reason']}),
224
+ );
225
+ }
226
+ const notChecked = report.propsNotChecked ?? [];
227
+ if (notChecked.length > 0) {
228
+ blocks.push(
229
+ text(
230
+ `[info] Props not checked for a doc entry in ${notChecked.length} component(s):`,
231
+ ),
232
+ records(notChecked, {fields: ['component', 'doc', 'reason', 'props']}),
233
+ );
234
+ }
235
+ if (report.findings.length === 0) {
236
+ const apart =
237
+ notChecked.length > 0 ? ', apart from the props not checked' : '';
238
+ blocks.push(
239
+ text(
240
+ !failed && report.components > 0
241
+ ? `[ok] Documented prop names and required flags match the code in ${report.components} component(s)${apart} (TypeScript ${report.typescript}).`
242
+ : `[info] Props compared in ${report.components} component(s) (TypeScript ${report.typescript}).`,
243
+ ),
244
+ );
245
+ }
246
+ return blocks;
247
+ }
248
+
176
249
  /**
177
250
  * @param {import('../../../api/integration/authoring-checks.type.mjs').IntegrationComponentConflictResponse['data']} data
178
251
  */
@@ -205,6 +278,7 @@ function printComponentConflicts(data) {
205
278
  ),
206
279
  );
207
280
  }
281
+ output.push(...propDriftBlocks(data.propDrift, failed));
208
282
  emit(...output);
209
283
  }
210
284
 
@@ -30,7 +30,7 @@ export const doc = {
30
30
  {
31
31
  flag: '--family',
32
32
  description:
33
- 'Build the selected extension family into one keyed CSS, JS, and declaration set. Requires --family-key; refused with --out or --watch (ERR_THEME_INVALID)',
33
+ 'Build the selected extension family into one keyed CSS, JS, and declaration set, written beside the family root theme file, which must be inside the working directory (ERR_PATH_TRAVERSAL). Requires --family-key; refused with --out or --watch (ERR_THEME_INVALID)',
34
34
  },
35
35
  {
36
36
  flag: '--family-key <key>',
@@ -41,7 +41,7 @@ export const doc = {
41
41
  flag: '-o, --out <path>',
42
42
  param: 'options.out',
43
43
  description:
44
- 'Output CSS file path; the .js and .d.ts are written beside it (default: <theme name>.css next to the source). A relative path must stay inside the working directory. Single theme only: refused with more than one file or with --family (ERR_THEME_INVALID)',
44
+ 'Output CSS file path, relative or absolute; the .js and .d.ts are written beside it (default: <theme name>.css next to the source). Every output must resolve inside the working directory, the defaults beside the source too (ERR_PATH_TRAVERSAL). Single theme only: refused with more than one file or with --family (ERR_THEME_INVALID)',
45
45
  },
46
46
  {
47
47
  flag: '--icons-specifier <specifier>',
@@ -68,8 +68,12 @@ function formatPropsTable(props) {
68
68
  for (const p of props) {
69
69
  const def = p.default ? `\`${mdCell(p.default)}\`` : '-';
70
70
  const req = p.required ? ' **(required)**' : '';
71
+ // The text projection of the prop doc's `stability` field: an
72
+ // experimental prop is outside the stable compatibility promise.
73
+ const stability =
74
+ p.stability === 'experimental' ? ' **(experimental)**' : '';
71
75
  lines.push(
72
- `| \`${mdCell(p.name)}\` | \`${mdCell(p.type)}\` | ${def} | ${mdCell(p.description)}${req} |`,
76
+ `| \`${mdCell(p.name)}\` | \`${mdCell(p.type)}\` | ${def} | ${mdCell(p.description)}${req}${stability} |`,
73
77
  );
74
78
  }
75
79
  return lines.join('\n');
@@ -60,6 +60,7 @@ import {
60
60
  applyTemplateReplacements,
61
61
  effectiveTemplateDiscovery,
62
62
  discoverAllUnresolved as discoverTemplates,
63
+ discoverCoreTemplates,
63
64
  discoverIntegrationTemplatesForOne,
64
65
  } from '../discovery/template-adapter.mjs';
65
66
  import {
@@ -821,6 +822,85 @@ export class Project {
821
822
  });
822
823
  }
823
824
 
825
+ /**
826
+ * The components and templates this project reads, as links name them: by
827
+ * owner provider id, kind, and name (a template by its id).
828
+ * @returns {Promise<import('../discovery/docs-discovery.mjs').LinkableContributions>}
829
+ */
830
+ async #linkableContributions() {
831
+ /** @type {Map<string, string>} */
832
+ const providers = new Map([[CORE_PACKAGE, CORE_PACKAGE]]);
833
+ for (const integration of this.#loadedIntegrations) {
834
+ if (integration?.name) {
835
+ providers.set(
836
+ integration.name,
837
+ integration.providerId ?? integration.name,
838
+ );
839
+ }
840
+ }
841
+ const providerOf = (/** @type {string | undefined} */ pkg) =>
842
+ providers.get(pkg ?? CORE_PACKAGE) ?? pkg ?? CORE_PACKAGE;
843
+ // An active replacement answers to the Core name it replaces, so a link
844
+ // to that Core component has to select Core (spec:AST-035 FR11).
845
+ /** @type {Map<string, string>} */
846
+ const replacing = new Map();
847
+ if (this.#loadedIntegrations.length > 0) {
848
+ const {active} = await resolveComponentReplacements(
849
+ findCoreDir(this.#cwd),
850
+ this.#loadedIntegrations,
851
+ );
852
+ for (const each of active) {
853
+ replacing.set(`${each.package}\0${each.name}`, each.target);
854
+ }
855
+ }
856
+ const components = (await this.components()).map(record => {
857
+ const replaces = replacing.get(`${record.package}\0${record.name}`);
858
+ return {
859
+ providerId: providerOf(record.package),
860
+ package: record.package,
861
+ name: record.name,
862
+ docPath: record.docPath,
863
+ ...(replaces == null ? {} : {replaces}),
864
+ };
865
+ });
866
+ /** @param {any} template @param {string} pkg */
867
+ const linkable = (template, pkg) => ({
868
+ providerId: providerOf(pkg),
869
+ package: pkg,
870
+ type: template.type,
871
+ id: template.dirName,
872
+ name: template.name,
873
+ ...(template.displayName == null
874
+ ? {}
875
+ : {displayName: template.displayName}),
876
+ ...(template.description == null
877
+ ? {}
878
+ : {description: template.description}),
879
+ ...(typeof template.replaces === 'string'
880
+ ? {replaces: template.replaces}
881
+ : {}),
882
+ });
883
+ /** @param {any} template */
884
+ const isPageOrBlock = template =>
885
+ template.type === 'page' || template.type === 'block';
886
+ // Every Core template, a replaced one too, since `--package
887
+ // @astryxdesign/core` still opens it; everyone else's as unqualified
888
+ // lookups see them, an active replacement with the id it takes over.
889
+ const templates = [
890
+ ...(await discoverCoreTemplates())
891
+ .filter(isPageOrBlock)
892
+ .map(template => linkable(template, CORE_PACKAGE)),
893
+ .../** @type {any[]} */ (await this.templates())
894
+ .filter(
895
+ template =>
896
+ isPageOrBlock(template) &&
897
+ (template.package ?? CORE_PACKAGE) !== CORE_PACKAGE,
898
+ )
899
+ .map(template => linkable(template, template.package)),
900
+ ];
901
+ return {components, templates};
902
+ }
903
+
824
904
  /**
825
905
  * The CLI's own doc topics plus the ones the configured integrations
826
906
  * contribute, resolved into one catalog: additions, replacements (with the
@@ -838,6 +918,8 @@ export class Project {
838
918
  async docs() {
839
919
  return this.#memo('docs', async () => {
840
920
  const catalog = DocsCatalog.fromBuiltins();
921
+ // Links name component, page, and block docs too (spec:AST-047 FR9).
922
+ catalog.setContributions(() => this.#linkableContributions());
841
923
  let rank = 0;
842
924
 
843
925
  for (const integration of this.#loadedIntegrations) {
@@ -46,6 +46,7 @@ export const AUTHORING_SELF_DOCS = [
46
46
  'doctypes/reference/reference.doc.mjs',
47
47
  'doctypes/schema/schema.doc.mjs',
48
48
  'doctypes/template/template.doc.mjs',
49
+ 'doctypes/template/integration-template.doc.mjs',
49
50
  'doctypes/theme/theme.doc.mjs',
50
51
  ];
51
52