@astryxdesign/cli 0.7.2-canary.f16dc9f → 0.7.2-canary.f2b60a0

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 (243) hide show
  1. package/README.md +15 -14
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/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.d.mts +11 -0
  10. package/api/docs/_adapter.mjs +244 -1
  11. package/api/docs/integrationDocs.test.mjs +124 -0
  12. package/api/doctor/doctor.mjs +27 -3
  13. package/api/doctor/doctor.test.mjs +43 -0
  14. package/api/doctor/theme-checks.mjs +1 -1
  15. package/api/gap-report/gap-report.mjs +67 -4
  16. package/api/index.d.mts +2 -0
  17. package/api/index.mjs +2 -0
  18. package/api/init/run/run.mjs +2 -2
  19. package/api/integration/_adapter.d.mts +12 -0
  20. package/api/integration/_adapter.mjs +54 -0
  21. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  22. package/api/integration/add-contribution.mjs +8 -4
  23. package/api/integration/add-contribution.test.mjs +4 -2
  24. package/api/integration/authoring-checks.mjs +11 -2
  25. package/api/integration/authoring-checks.test.mjs +39 -0
  26. package/api/integration/authoring-checks.type.d.mts +9 -0
  27. package/api/integration/authoring-checks.type.mjs +14 -1
  28. package/api/integration/check/check.d.mts +96 -0
  29. package/api/integration/check/check.mjs +357 -0
  30. package/api/integration/check/check.test.mjs +489 -0
  31. package/api/integration/integration-check.type.d.mts +118 -0
  32. package/api/integration/integration-check.type.mjs +84 -0
  33. package/api/integration/integrationCheck.doc.d.mts +10 -0
  34. package/api/integration/integrationCheck.doc.mjs +73 -0
  35. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  36. package/api/integration/pack-check.mjs +61 -107
  37. package/api/integration/pack-check.test.mjs +359 -0
  38. package/api/integration/validate-integration.mjs +8 -0
  39. package/api/integration/validate-integration.test.mjs +11 -0
  40. package/api/integration/validate-integration.type.d.mts +8 -0
  41. package/api/integration/validate-integration.type.mjs +12 -1
  42. package/api/integration/validateIntegration.doc.mjs +17 -3
  43. package/api/search/search.d.mts +22 -6
  44. package/api/search/search.domain-priority.test.mjs +108 -18
  45. package/api/search/search.integration-check.test.mjs +24 -0
  46. package/api/search/search.mjs +141 -25
  47. package/api/search/search.test.mjs +36 -0
  48. package/api/swizzle/copy/copy.mjs +28 -14
  49. package/api/template/template.test.mjs +120 -3
  50. package/api/theme/build/build.family.test.mjs +64 -0
  51. package/api/theme/build/build.mjs +180 -24
  52. package/api/theme/themeBuild.doc.mjs +4 -4
  53. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  54. package/assets/codemods/transforms/next/index.mjs +11 -1
  55. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  56. package/assets/docs/README.md +10 -0
  57. package/assets/docs/author-a-theme.doc.mjs +1 -1
  58. package/assets/docs/getting-started.doc.mjs +3 -3
  59. package/assets/docs/styling-overview.doc.mjs +1 -1
  60. package/assets/docs/theme.doc.dense.mjs +1 -1
  61. package/assets/docs/theme.doc.mjs +1 -1
  62. package/assets/docs/theme.doc.zh.mjs +1 -1
  63. package/assets/docs/tree/add-a-component.doc.mjs +11 -1
  64. package/assets/docs/tree/add-a-theme.doc.mjs +1 -1
  65. package/assets/docs/tree/add-a-topic.doc.mjs +10 -7
  66. package/assets/docs/tree/agent-guidance.doc.mjs +2 -2
  67. package/assets/docs/tree/block-template.doc.mjs +8 -20
  68. package/assets/docs/tree/build-the-template.doc.mjs +1 -1
  69. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  70. package/assets/docs/tree/check-your-docs.doc.mjs +51 -5
  71. package/assets/docs/tree/checks.doc.mjs +24 -16
  72. package/assets/docs/tree/codemods.doc.mjs +4 -4
  73. package/assets/docs/tree/component-family.doc.mjs +8 -6
  74. package/assets/docs/tree/component-imports.doc.mjs +10 -2
  75. package/assets/docs/tree/components.doc.mjs +1 -1
  76. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +7 -18
  77. package/assets/docs/tree/define-the-theme.doc.mjs +6 -5
  78. package/assets/docs/tree/describe-the-component.doc.mjs +2 -2
  79. package/assets/docs/tree/docs.doc.mjs +3 -3
  80. package/assets/docs/tree/document-the-template.doc.mjs +1 -1
  81. package/assets/docs/tree/document-the-theme.doc.mjs +1 -1
  82. package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
  83. package/assets/docs/tree/extend-or-replace.doc.mjs +6 -6
  84. package/assets/docs/tree/fonts-and-assets.doc.mjs +2 -2
  85. package/assets/docs/tree/generate-a-palette.doc.mjs +5 -3
  86. package/assets/docs/tree/grade-template-with-agent.doc.mjs +1 -1
  87. package/assets/docs/tree/integrations.doc.mjs +8 -0
  88. package/assets/docs/tree/links.doc.mjs +6 -5
  89. package/assets/docs/tree/migration-setup.doc.mjs +1 -0
  90. package/assets/docs/tree/migration-verification.doc.mjs +0 -1
  91. package/assets/docs/tree/migration.doc.mjs +0 -1
  92. package/assets/docs/tree/package-and-test.doc.mjs +1 -1
  93. package/assets/docs/tree/page-template.doc.mjs +4 -8
  94. package/assets/docs/tree/publishing.doc.mjs +1 -1
  95. package/assets/docs/tree/quick-start.doc.mjs +37 -21
  96. package/assets/docs/tree/replace-a-core-component.doc.mjs +4 -6
  97. package/assets/docs/tree/replace-a-core-template.doc.mjs +6 -8
  98. package/assets/docs/tree/sections-and-placement.doc.mjs +4 -6
  99. package/assets/docs/tree/see-it-in-an-app.doc.mjs +4 -2
  100. package/assets/docs/tree/short-and-findable.doc.mjs +6 -5
  101. package/assets/docs/tree/single-component.doc.mjs +15 -6
  102. package/assets/docs/tree/start-a-template.doc.mjs +3 -3
  103. package/assets/docs/tree/subcomponent.doc.mjs +6 -6
  104. package/assets/docs/tree/template-assets.doc.mjs +2 -2
  105. package/assets/docs/tree/template-doc-overview.doc.mjs +4 -15
  106. package/assets/docs/tree/template-fonts.doc.mjs +1 -1
  107. package/assets/docs/tree/template-icons.doc.mjs +1 -1
  108. package/assets/docs/tree/template-images-media.doc.mjs +1 -1
  109. package/assets/docs/tree/template-styles.doc.mjs +2 -2
  110. package/assets/docs/tree/templates.doc.mjs +1 -1
  111. package/assets/docs/tree/test-in-an-app.doc.mjs +3 -3
  112. package/assets/docs/tree/test-template-in-app.doc.mjs +1 -1
  113. package/assets/docs/tree/tokens-and-setup.doc.mjs +19 -15
  114. package/assets/docs/tree/troubleshooting.doc.mjs +13 -5
  115. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  116. package/assets/docs/tree/upgrading.doc.mjs +3 -3
  117. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +3 -1
  118. package/assets/docs/tree/verify-packed-template.doc.mjs +1 -1
  119. package/assets/docs/tree/versioning.doc.mjs +7 -6
  120. package/assets/docs/tree/write-the-template-file.doc.mjs +4 -3
  121. package/assets/docs/use-a-theme.doc.mjs +2 -2
  122. package/assets/docs/working-with-ai.doc.mjs +43 -4
  123. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  124. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  125. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  126. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  127. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  128. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  129. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  130. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  131. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  132. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  133. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  134. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  135. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  136. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  137. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  138. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  139. package/assets/templates/pages/detail-page/page.tsx +3 -3
  140. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  141. package/assets/theme.template.ts +1 -1
  142. package/authoring/codemod/codemod.doc.mjs +7 -3
  143. package/authoring/config/config.doc.mjs +9 -6
  144. package/authoring/config/type.ts +4 -2
  145. package/authoring/discover/discover.doc.mjs +1 -1
  146. package/authoring/doctypes/base/graph-fields.doc.mjs +6 -4
  147. package/authoring/doctypes/base/type.ts +6 -0
  148. package/authoring/doctypes/command/command.doc.mjs +14 -12
  149. package/authoring/doctypes/component/component.doc.mjs +31 -28
  150. package/authoring/doctypes/component/type.ts +13 -8
  151. package/authoring/doctypes/enum/enum.doc.mjs +7 -4
  152. package/authoring/doctypes/function/function.doc.mjs +9 -8
  153. package/authoring/doctypes/hook/hook.doc.mjs +5 -4
  154. package/authoring/doctypes/namespace/namespace.doc.mjs +7 -6
  155. package/authoring/doctypes/reference/reference.doc.mjs +11 -10
  156. package/authoring/doctypes/schema/schema.doc.mjs +9 -9
  157. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  158. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  159. package/authoring/doctypes/template/parse.d.mts +6 -2
  160. package/authoring/doctypes/template/parse.mjs +3 -1
  161. package/authoring/doctypes/template/template.doc.mjs +3 -3
  162. package/authoring/doctypes/template/type.ts +22 -0
  163. package/authoring/doctypes/theme/theme.doc.mjs +2 -2
  164. package/authoring/gap-report/gap-report.doc.mjs +2 -2
  165. package/authoring/identity/identity.doc.mjs +3 -3
  166. package/authoring/index.d.ts +1 -0
  167. package/authoring/integration/integration.doc.mjs +12 -4
  168. package/authoring/integration/parse.test.mjs +10 -0
  169. package/authoring/integration/schema.d.mts +2 -0
  170. package/authoring/integration/schema.mjs +1 -0
  171. package/authoring/integration/type.ts +4 -0
  172. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  173. package/clients/cli/commands/build-theme.mjs +1 -1
  174. package/clients/cli/commands/build-theme.path-safety.test.mjs +224 -6
  175. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  176. package/clients/cli/commands/cli-postinstall.test.mjs +49 -1
  177. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  178. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  179. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  180. package/clients/cli/commands/doctor.mjs +190 -0
  181. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  182. package/clients/cli/commands/integration-check.test.mjs +267 -0
  183. package/clients/cli/commands/integration.doc.mjs +6 -2
  184. package/clients/cli/commands/integration.mjs +107 -1
  185. package/clients/cli/commands/setup-nudge.test.mjs +79 -1
  186. package/clients/cli/commands/swizzle.mjs +5 -3
  187. package/clients/cli/commands/swizzle.routing.test.mjs +18 -1
  188. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  189. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  190. package/clients/cli/index.mjs +30 -5
  191. package/clients/cli/lib/component-format.mjs +5 -1
  192. package/foundation/agent-docs/agent-doc-state.d.mts +14 -0
  193. package/foundation/agent-docs/agent-doc-state.mjs +38 -0
  194. package/foundation/agent-docs/agent-docs.d.mts +2 -1
  195. package/foundation/agent-docs/agent-docs.mjs +8 -6
  196. package/foundation/config/project.mjs +82 -0
  197. package/foundation/discovery/authoring-self-docs.mjs +2 -1
  198. package/foundation/discovery/component-discovery.d.mts +190 -10
  199. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  200. package/foundation/discovery/component-discovery.mjs +864 -56
  201. package/foundation/discovery/component-discovery.test.mjs +96 -0
  202. package/foundation/discovery/component-replacement.mjs +18 -0
  203. package/foundation/discovery/component-replacement.test.mjs +40 -0
  204. package/foundation/discovery/doc-types.d.mts +157 -0
  205. package/foundation/discovery/doc-types.mjs +1224 -0
  206. package/foundation/discovery/doc-types.test.mjs +652 -0
  207. package/foundation/discovery/docs-discovery.d.mts +81 -0
  208. package/foundation/discovery/docs-discovery.mjs +57 -0
  209. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  210. package/foundation/discovery/integration-doc-files.mjs +231 -0
  211. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  212. package/foundation/discovery/module-exports.d.mts +27 -0
  213. package/foundation/discovery/module-exports.mjs +356 -31
  214. package/foundation/discovery/module-exports.test.mjs +172 -0
  215. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  216. package/foundation/discovery/package-tsconfig.mjs +228 -0
  217. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  218. package/foundation/discovery/project-typescript.d.mts +23 -0
  219. package/foundation/discovery/project-typescript.mjs +61 -0
  220. package/foundation/discovery/prop-drift.d.mts +278 -0
  221. package/foundation/discovery/prop-drift.mjs +2263 -0
  222. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  223. package/foundation/discovery/template-adapter.mjs +74 -5
  224. package/foundation/discovery/type-match.d.mts +53 -0
  225. package/foundation/discovery/type-match.mjs +660 -0
  226. package/foundation/discovery/type-match.test.mjs +169 -0
  227. package/foundation/discovery/type-text.d.mts +68 -0
  228. package/foundation/discovery/type-text.mjs +658 -0
  229. package/foundation/discovery/type-text.test.mjs +196 -0
  230. package/foundation/discovery/unresolved-references.d.mts +62 -0
  231. package/foundation/discovery/unresolved-references.mjs +790 -0
  232. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  233. package/foundation/doc-compiler/doc-loads.test.mjs +11 -1
  234. package/foundation/doc-compiler/inputs.mjs +23 -0
  235. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  236. package/foundation/doc-compiler/links.d.mts +9 -5
  237. package/foundation/doc-compiler/links.mjs +4 -2
  238. package/foundation/integrations/contribution-inventory.mjs +6 -1
  239. package/foundation/response/response-types.doc.mjs +7 -2
  240. package/foundation/xle/registry.mjs +4 -0
  241. package/foundation/xle/registry.test.mjs +28 -0
  242. package/package.json +10 -10
  243. package/scripts/postinstall.mjs +3 -3
@@ -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;
@@ -209,6 +210,425 @@ describe('doctor integration — command', () => {
209
210
  expect(process.exitCode).toBeUndefined();
210
211
  });
211
212
 
213
+ it('validate warns about each doc field that does not match its authoring type, and still exits 0', async () => {
214
+ writeIntegration({id: 'hero', name: 'Hero', type: 'block'});
215
+ process.chdir(tmpDir);
216
+
217
+ await createProgram().parseAsync([
218
+ 'node',
219
+ 'astryx',
220
+ 'doctor',
221
+ 'integration',
222
+ 'validate',
223
+ ]);
224
+
225
+ const output = logCalls.join('\n');
226
+ expect(output).toContain('[ok] No integration issues found.');
227
+ expect(output).toMatch(
228
+ /Checking 1 doc\(s\) against the authoring types \(TypeScript \d+\.\d+/,
229
+ );
230
+ expect(output).toContain('templates/hero.doc.mjs');
231
+ expect(output).toMatch(/export:\s+default/);
232
+ expect(output).toMatch(/type:\s+IntegrationTemplateDoc/);
233
+ expect(output).toContain(
234
+ 'Add `aspectRatio`: IntegrationBlockTemplateDoc requires it.',
235
+ );
236
+ expect(output).toContain(
237
+ 'Add `displayName`: IntegrationBlockTemplateDoc requires it.',
238
+ );
239
+ expect(output).toContain(
240
+ '2 doc field(s) in 1 file(s) do not match the authoring types.',
241
+ );
242
+ expect(process.exitCode).toBeUndefined();
243
+ });
244
+
245
+ it('validate says there is no doc to type-check, rather than asking for TypeScript', async () => {
246
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-no-docs-'));
247
+ try {
248
+ fs.writeFileSync(
249
+ path.join(outside, 'package.json'),
250
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
251
+ );
252
+ fs.writeFileSync(
253
+ path.join(outside, 'astryx.integration.mjs'),
254
+ "export default {components: './components'};\n",
255
+ );
256
+ fs.mkdirSync(path.join(outside, 'components'));
257
+ fs.writeFileSync(
258
+ path.join(outside, 'components', 'Widget.tsx'),
259
+ 'export function Widget() { return null; }\n',
260
+ );
261
+ process.chdir(outside);
262
+
263
+ await createProgram().parseAsync([
264
+ 'node',
265
+ 'astryx',
266
+ 'doctor',
267
+ 'integration',
268
+ 'validate',
269
+ ]);
270
+
271
+ const output = logCalls.join('\n');
272
+ expect(output).toContain('[info] No doc files to type-check.');
273
+ expect(output).not.toContain('Docs not type-checked');
274
+ expect(output).not.toContain('against the authoring types');
275
+ } finally {
276
+ process.chdir(previousCwd);
277
+ fs.rmSync(outside, {recursive: true, force: true});
278
+ }
279
+ });
280
+
281
+ it('validate says the docs matched even when another check failed', async () => {
282
+ writeIntegration({id: 'hero', missingRoot: true});
283
+ fs.writeFileSync(
284
+ path.join(tmpDir, 'astryx.integration.mjs'),
285
+ "export default {templates: './missing', components: './components'};\n",
286
+ );
287
+ fs.mkdirSync(path.join(tmpDir, 'components'), {recursive: true});
288
+ fs.writeFileSync(
289
+ path.join(tmpDir, 'components', 'AcmeBadge.doc.mjs'),
290
+ "export default {type: 'component', name: 'AcmeBadge', displayName: 'Badge', usage: {description: 'A badge.'}, props: []};\n",
291
+ );
292
+ fs.writeFileSync(
293
+ path.join(tmpDir, 'components', 'AcmeBadge.tsx'),
294
+ 'export function AcmeBadge() { return null; }\n',
295
+ );
296
+ process.chdir(tmpDir);
297
+
298
+ await createProgram().parseAsync([
299
+ 'node',
300
+ 'astryx',
301
+ 'doctor',
302
+ 'integration',
303
+ 'validate',
304
+ ]);
305
+
306
+ const output = logCalls.join('\n');
307
+ expect(process.exitCode).toBe(1);
308
+ expect(output).toContain(
309
+ '[info] No doc field differs from its authoring type (1 checked).',
310
+ );
311
+ expect(output).not.toContain('[ok] Every doc');
312
+ });
313
+
314
+ it('validate prints every field of the doc type check that JSON carries', async () => {
315
+ writeIntegration({id: 'hero', name: 'Hero', type: 'block'});
316
+ fs.writeFileSync(
317
+ path.join(tmpDir, 'astryx.integration.mjs'),
318
+ "export default {templates: './templates', components: './components'};\n",
319
+ );
320
+ fs.mkdirSync(path.join(tmpDir, 'components'), {recursive: true});
321
+ fs.writeFileSync(
322
+ path.join(tmpDir, 'components', 'AcmeBadge.doc.mjs'),
323
+ "export default {type: 'component', name: 'AcmeBadge', displayName: 5, usage: {description: 'A badge.'}, props: [], keyword: ['x']};\n",
324
+ );
325
+ fs.writeFileSync(
326
+ path.join(tmpDir, 'components', 'AcmeBadge.tsx'),
327
+ 'export function AcmeBadge() { return null; }\n',
328
+ );
329
+ fs.writeFileSync(
330
+ path.join(tmpDir, 'components', 'shared.mjs'),
331
+ `export default {type: 'component', name: 'AcmeAgain', displayName: 'Again', usage: {description: 'x'}, props: []};\n`,
332
+ );
333
+ fs.writeFileSync(
334
+ path.join(tmpDir, 'components', 'AcmeAgain.doc.mjs'),
335
+ "export {default} from './shared.mjs';\n",
336
+ );
337
+ fs.writeFileSync(
338
+ path.join(tmpDir, 'components', 'AcmeAgain.tsx'),
339
+ 'export function AcmeAgain() { return null; }\n',
340
+ );
341
+ process.chdir(tmpDir);
342
+ const run = async (/** @type {string[]} */ flags) => {
343
+ logCalls.length = 0;
344
+ await createProgram().parseAsync([
345
+ 'node',
346
+ 'astryx',
347
+ ...flags,
348
+ 'doctor',
349
+ 'integration',
350
+ 'validate',
351
+ ]);
352
+ return logCalls.join('\n');
353
+ };
354
+
355
+ const report = JSON.parse(await run(['--json'])).data.docTypes;
356
+ const output = await run([]);
357
+
358
+ expect([...new Set(report.findings.map(f => f.problem))].sort()).toEqual([
359
+ 'missing_field',
360
+ 'unknown_field',
361
+ 'wrong_type',
362
+ ]);
363
+ expect(report.unchecked).toHaveLength(1);
364
+ expectRecordsCarry(output, report.findings, ['doc', 'field', 'problem']);
365
+ expectRecordsCarry(output, report.unchecked, ['doc', 'reason']);
366
+ expect(output).toContain(
367
+ `Checking ${report.docs} doc(s) against the authoring types (TypeScript ${report.typescript})`,
368
+ );
369
+ });
370
+
371
+ it('validate type-checks each doc component discovery serves, in __fixtures__, in a dot-folder or behind a symlink', async () => {
372
+ fs.writeFileSync(
373
+ path.join(tmpDir, 'package.json'),
374
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
375
+ );
376
+ fs.writeFileSync(
377
+ path.join(tmpDir, 'astryx.integration.mjs'),
378
+ `export default {components: './components'};\n`,
379
+ );
380
+ for (const [rel, name] of [
381
+ ['components/__fixtures__/AcmeFixture.doc.mjs', 'AcmeFixture'],
382
+ ['components/.drafts/AcmeDraft.doc.mjs', 'AcmeDraft'],
383
+ ['shared/AcmeLinked.doc.mjs', 'AcmeLinked'],
384
+ ]) {
385
+ fs.mkdirSync(path.dirname(path.join(tmpDir, rel)), {recursive: true});
386
+ fs.writeFileSync(
387
+ path.join(tmpDir, rel),
388
+ `export default {type: 'component', name: '${name}', displayName: '${name}', usage: {description: 'Fixture.'}, props: [], keyword: []};\n`,
389
+ );
390
+ }
391
+ fs.symlinkSync(
392
+ path.join(tmpDir, 'shared', 'AcmeLinked.doc.mjs'),
393
+ path.join(tmpDir, 'components', 'AcmeLinked.doc.mjs'),
394
+ );
395
+ process.chdir(tmpDir);
396
+
397
+ await createProgram().parseAsync([
398
+ 'node',
399
+ 'astryx',
400
+ '--json',
401
+ 'doctor',
402
+ 'integration',
403
+ 'validate',
404
+ ]);
405
+
406
+ const {docTypes} = JSON.parse(logCalls.join('\n')).data;
407
+ expect(docTypes).toMatchObject({status: 'checked', docs: 3, unchecked: []});
408
+ expect(
409
+ docTypes.findings.map(
410
+ (/** @type {any} */ f) => `${f.doc} ${f.field} ${f.problem}`,
411
+ ),
412
+ ).toEqual([
413
+ 'components/__fixtures__/AcmeFixture.doc.mjs keyword unknown_field',
414
+ 'components/.drafts/AcmeDraft.doc.mjs keyword unknown_field',
415
+ 'components/AcmeLinked.doc.mjs keyword unknown_field',
416
+ ]);
417
+ });
418
+
419
+ it('validate type-checks a hidden doc and a second doc for one name, and counts a test fixture', async () => {
420
+ fs.writeFileSync(
421
+ path.join(tmpDir, 'package.json'),
422
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
423
+ );
424
+ fs.writeFileSync(
425
+ path.join(tmpDir, 'astryx.integration.mjs'),
426
+ `export default {components: './components'};\n`,
427
+ );
428
+ for (const [rel, name, extra] of [
429
+ ['components/AcmeBadge.doc.mjs', 'AcmeBadge', ''],
430
+ ['components/again/AcmeBadge.doc.mjs', 'AcmeBadge', ', keyword: []'],
431
+ [
432
+ 'components/AcmeHidden.doc.mjs',
433
+ 'AcmeHidden',
434
+ ', hidden: true, keyword: []',
435
+ ],
436
+ [
437
+ 'components/__tests__/AcmeTested.doc.mjs',
438
+ 'AcmeTested',
439
+ ', keyword: []',
440
+ ],
441
+ ]) {
442
+ fs.mkdirSync(path.dirname(path.join(tmpDir, rel)), {recursive: true});
443
+ fs.writeFileSync(
444
+ path.join(tmpDir, rel),
445
+ `export default {type: 'component', name: '${name}', displayName: '${name}', usage: {description: 'Fixture.'}, props: []${extra}};\n`,
446
+ );
447
+ fs.writeFileSync(
448
+ path.join(tmpDir, rel.replace('.doc.mjs', '.tsx')),
449
+ 'export default function Fixture() { return null; }\n',
450
+ );
451
+ }
452
+ process.chdir(tmpDir);
453
+ const run = async (/** @type {string[]} */ flags) => {
454
+ logCalls.length = 0;
455
+ await createProgram().parseAsync([
456
+ 'node',
457
+ 'astryx',
458
+ ...flags,
459
+ 'doctor',
460
+ 'integration',
461
+ 'validate',
462
+ ]);
463
+ return logCalls.join('\n');
464
+ };
465
+
466
+ const {docTypes} = JSON.parse(await run(['--json'])).data;
467
+ const output = await run([]);
468
+
469
+ expect(docTypes).toMatchObject({docs: 3, fixtures: 1, unchecked: []});
470
+ expect(
471
+ docTypes.findings.map(
472
+ (/** @type {any} */ f) => `${f.doc} ${f.field} ${f.problem}`,
473
+ ),
474
+ ).toEqual([
475
+ 'components/AcmeHidden.doc.mjs keyword unknown_field',
476
+ 'components/again/AcmeBadge.doc.mjs keyword unknown_field',
477
+ ]);
478
+ expect(output).toContain('[info] 1 test fixture doc(s) not checked.');
479
+ expect(output).not.toContain('[ok] Every doc matches');
480
+ });
481
+
482
+ it('validate keeps the fixture count when test fixtures are the only docs', async () => {
483
+ fs.writeFileSync(
484
+ path.join(tmpDir, 'package.json'),
485
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
486
+ );
487
+ fs.writeFileSync(
488
+ path.join(tmpDir, 'astryx.integration.mjs'),
489
+ `export default {components: './components'};\n`,
490
+ );
491
+ const rel = 'components/__tests__/AcmeTested.doc.mjs';
492
+ fs.mkdirSync(path.dirname(path.join(tmpDir, rel)), {recursive: true});
493
+ fs.writeFileSync(
494
+ path.join(tmpDir, rel),
495
+ `export default {type: 'component', name: 'AcmeTested', displayName: 'AcmeTested', usage: {description: 'Fixture.'}, props: []};\n`,
496
+ );
497
+ process.chdir(tmpDir);
498
+ const run = async (/** @type {string[]} */ flags) => {
499
+ logCalls.length = 0;
500
+ await createProgram().parseAsync([
501
+ 'node',
502
+ 'astryx',
503
+ ...flags,
504
+ 'doctor',
505
+ 'integration',
506
+ 'validate',
507
+ ]);
508
+ return logCalls.join('\n');
509
+ };
510
+
511
+ const {docTypes} = JSON.parse(await run(['--json'])).data;
512
+ const output = await run([]);
513
+
514
+ expect(docTypes).toMatchObject({docs: 0, fixtures: 1, findings: []});
515
+ expect(output).toContain('[info] No doc files to type-check.');
516
+ expect(output).toContain('[info] 1 test fixture doc(s) not checked.');
517
+ });
518
+
519
+ it("validate keeps main's issue for a root it cannot read, and lists a root with a folder it cannot read", async () => {
520
+ fs.writeFileSync(
521
+ path.join(tmpDir, 'package.json'),
522
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
523
+ );
524
+ fs.writeFileSync(
525
+ path.join(tmpDir, 'astryx.integration.mjs'),
526
+ `export default {components: './components', docs: './docs'};\n`,
527
+ );
528
+ fs.mkdirSync(path.join(tmpDir, 'components', 'locked'), {recursive: true});
529
+ fs.mkdirSync(path.join(tmpDir, 'docs'));
530
+ const locked = [
531
+ path.join(tmpDir, 'components', 'locked'),
532
+ path.join(tmpDir, 'docs'),
533
+ ];
534
+ for (const dir of locked) fs.chmodSync(dir, 0o000);
535
+ process.chdir(tmpDir);
536
+ try {
537
+ await createProgram().parseAsync([
538
+ 'node',
539
+ 'astryx',
540
+ '--json',
541
+ 'doctor',
542
+ 'integration',
543
+ 'validate',
544
+ ]);
545
+ } finally {
546
+ for (const dir of locked) fs.chmodSync(dir, 0o755);
547
+ }
548
+
549
+ const {data} = JSON.parse(logCalls.join('\n'));
550
+ expect(data.issues.map((/** @type {any} */ i) => i.code).sort()).toEqual([
551
+ 'invalid_component',
552
+ 'unreadable_root',
553
+ ]);
554
+ expect(data.docTypes.unchecked).toEqual([
555
+ {
556
+ doc: 'components',
557
+ reason: expect.stringMatching(
558
+ /^the components root does not load, so no doc in it was checked: .*EACCES/,
559
+ ),
560
+ },
561
+ ]);
562
+ });
563
+
564
+ it('validate reports the doc type check as additive JSON', async () => {
565
+ writeIntegration({id: 'hero', name: 'Hero', type: 'block'});
566
+ process.chdir(tmpDir);
567
+
568
+ await createProgram().parseAsync([
569
+ 'node',
570
+ 'astryx',
571
+ '--json',
572
+ 'doctor',
573
+ 'integration',
574
+ 'validate',
575
+ ]);
576
+
577
+ const parsed = JSON.parse(logCalls.join('\n'));
578
+ expect(parsed.data.issues).toEqual([]);
579
+ expect(parsed.data.docTypes).toMatchObject({
580
+ status: 'checked',
581
+ docs: 1,
582
+ unchecked: [],
583
+ });
584
+ expect(parsed.data.docTypes.typescript).toMatch(/^\d+\.\d+/);
585
+ expect(parsed.data.docTypes.findings).toEqual([
586
+ expect.objectContaining({
587
+ doc: 'templates/hero.doc.mjs',
588
+ export: 'default',
589
+ type: 'IntegrationTemplateDoc',
590
+ field: 'aspectRatio',
591
+ problem: 'missing_field',
592
+ severity: 'warning',
593
+ }),
594
+ expect.objectContaining({field: 'displayName', problem: 'missing_field'}),
595
+ ]);
596
+ expect(process.exitCode).toBeUndefined();
597
+ });
598
+
599
+ it('validate says, in one visible line, that docs were not type-checked when the project has no TypeScript', async () => {
600
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-no-ts-'));
601
+ const inside = tmpDir;
602
+ try {
603
+ // The fixture helpers write to tmpDir; point it outside the repository,
604
+ // where no TypeScript resolves, and restore it for afterEach.
605
+ tmpDir = outside;
606
+ writeIntegration({id: 'hero', name: 'Hero', type: 'block'});
607
+ tmpDir = inside;
608
+ process.chdir(outside);
609
+
610
+ await createProgram().parseAsync([
611
+ 'node',
612
+ 'astryx',
613
+ 'doctor',
614
+ 'integration',
615
+ 'validate',
616
+ ]);
617
+
618
+ const output = logCalls.join('\n');
619
+ expect(output).toContain(
620
+ '[skip] Docs not type-checked: TypeScript is not installed in this project',
621
+ );
622
+ expect(output).not.toMatch(
623
+ /Checking \d+ doc\(s\) against the authoring types/,
624
+ );
625
+ expect(process.exitCode).toBeUndefined();
626
+ } finally {
627
+ process.chdir(previousCwd);
628
+ fs.rmSync(outside, {recursive: true, force: true});
629
+ }
630
+ });
631
+
212
632
  it('templates warns on a Core id and writes the exact --package command', async () => {
213
633
  const [core] = await discoverCoreTemplates();
214
634
  expect(core).toBeDefined();
@@ -366,6 +786,10 @@ describe('doctor integration — command', () => {
366
786
  const printed = logCalls.join('\n');
367
787
  expect(printed).toContain('core_not_found');
368
788
  expect(printed).not.toContain('[ok]');
789
+ // No TypeScript out here either: the props comparison says it skipped.
790
+ expect(printed).toContain(
791
+ '[skip] Props not compared: TypeScript is not installed in this project',
792
+ );
369
793
  expect(process.exitCode).toBe(1);
370
794
  } finally {
371
795
  tmpDir = previousTmp;
@@ -396,6 +820,354 @@ describe('doctor integration — command', () => {
396
820
  expect(process.exitCode).toBeUndefined();
397
821
  });
398
822
 
823
+ it('components prints each documented prop that differs from the code, and still exits 0', async () => {
824
+ writeComponentIntegration('AcmeBadge');
825
+ fs.writeFileSync(
826
+ path.join(tmpDir, 'components', 'AcmeBadge.doc.mjs'),
827
+ `export default {type: 'component', name: 'AcmeBadge', displayName: 'AcmeBadge', description: 'Fixture component.', props: [{name: 'label', type: 'string', description: 'Text.'}]};\n`,
828
+ );
829
+ fs.writeFileSync(
830
+ path.join(tmpDir, 'components', 'AcmeBadge.tsx'),
831
+ 'export function AcmeBadge(props: {label: string}) { return null; }\n',
832
+ );
833
+ process.chdir(tmpDir);
834
+
835
+ await createProgram().parseAsync([
836
+ 'node',
837
+ 'astryx',
838
+ 'doctor',
839
+ 'integration',
840
+ 'components',
841
+ ]);
842
+
843
+ const output = logCalls.join('\n');
844
+ expect(output).toMatch(/^kind:\s+required_mismatch$/m);
845
+ expect(output).toContain(
846
+ '"label" is required in the code, but the doc does not mark it required.',
847
+ );
848
+ expect(output).toContain(
849
+ '1 prop(s) differ between the docs and the code in 1 of 1 component(s) compared (TypeScript',
850
+ );
851
+ expect(output).not.toContain('[ok] Documented prop');
852
+ expect(process.exitCode).toBeUndefined();
853
+ });
854
+
855
+ it('components compares a hidden doc and a family entry with no record, so their drift is never an [ok]', async () => {
856
+ writeComponentIntegration('AcmeMenu');
857
+ const dir = path.join(tmpDir, 'components');
858
+ fs.writeFileSync(
859
+ path.join(dir, 'AcmeMenu.doc.mjs'),
860
+ `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`,
861
+ );
862
+ fs.writeFileSync(
863
+ path.join(dir, 'AcmeMenu.tsx'),
864
+ 'export function AcmeMenuRoot() { return null; }\nexport function AcmeMenuItem(props: {value?: string}) { return null; }\n',
865
+ );
866
+ fs.writeFileSync(
867
+ path.join(dir, 'AcmeSecret.doc.mjs'),
868
+ `export default {type: 'component', name: 'AcmeSecret', displayName: 'AcmeSecret', description: 'Fixture component.', hidden: true, props: [{name: 'tone', type: 'string', description: 'Tone.'}]};\n`,
869
+ );
870
+ fs.writeFileSync(
871
+ path.join(dir, 'AcmeSecret.tsx'),
872
+ 'export function AcmeSecret(props: {size?: number}) { return null; }\n',
873
+ );
874
+ process.chdir(tmpDir);
875
+ const run = async (/** @type {string[]} */ flags) => {
876
+ logCalls.length = 0;
877
+ await createProgram().parseAsync([
878
+ 'node',
879
+ 'astryx',
880
+ ...flags,
881
+ 'doctor',
882
+ 'integration',
883
+ 'components',
884
+ ]);
885
+ return logCalls.join('\n');
886
+ };
887
+
888
+ const report = JSON.parse(await run(['--json'])).data.propDrift;
889
+ const output = await run([]);
890
+
891
+ expect(
892
+ report.findings.map(f => `${f.component}.${f.prop} ${f.kind}`),
893
+ ).toEqual([
894
+ 'AcmeMenuItem.label not_in_code',
895
+ 'AcmeMenuItem.value not_in_doc',
896
+ 'AcmeSecret.size not_in_doc',
897
+ 'AcmeSecret.tone not_in_code',
898
+ ]);
899
+ expect(output).not.toContain('[ok] Documented prop');
900
+ expect(process.exitCode).toBeUndefined();
901
+ });
902
+
903
+ it('components lists props a mapped type builds from keys it cannot trace, and says the [ok] leaves them out', async () => {
904
+ writeComponentIntegration('AcmeChip');
905
+ const dir = path.join(tmpDir, 'components');
906
+ fs.writeFileSync(
907
+ path.join(dir, 'AcmeChip.doc.mjs'),
908
+ `export default {type: 'component', name: 'AcmeChip', displayName: 'AcmeChip', description: 'Fixture.', props: [{name: 'label', type: 'string', description: 'Text.'}]};\n`,
909
+ );
910
+ fs.writeFileSync(
911
+ path.join(dir, 'AcmeChip.tsx'),
912
+ "export function AcmeChip(props: {label?: string} & {[K in `on${Capitalize<'open'>}`]?: () => void}) { return null; }\n",
913
+ );
914
+ process.chdir(tmpDir);
915
+ const run = async (/** @type {string[]} */ flags) => {
916
+ logCalls.length = 0;
917
+ await createProgram().parseAsync([
918
+ 'node',
919
+ 'astryx',
920
+ ...flags,
921
+ 'doctor',
922
+ 'integration',
923
+ 'components',
924
+ ]);
925
+ return logCalls.join('\n');
926
+ };
927
+
928
+ const {propsNotChecked} = JSON.parse(await run(['--json'])).data.propDrift;
929
+ const output = await run([]);
930
+
931
+ expect(propsNotChecked).toEqual([
932
+ expect.objectContaining({component: 'AcmeChip', props: ['onOpen']}),
933
+ ]);
934
+ expect(output).toContain(
935
+ '[info] Props not checked for a doc entry in 1 component(s):',
936
+ );
937
+ expect(output).toContain(
938
+ 'in 1 component(s), apart from the props not checked (TypeScript',
939
+ );
940
+ });
941
+
942
+ it('components compares the entries of a family doc whose own source is missing', async () => {
943
+ writeComponentIntegration('AcmeTabs');
944
+ const dir = path.join(tmpDir, 'components');
945
+ const entry = (/** @type {string} */ name, /** @type {string} */ prop) =>
946
+ `{name: '${name}', displayName: '${name}', description: 'Fixture.', props: [{name: '${prop}', type: 'string', description: 'Text.'}]}`;
947
+ fs.writeFileSync(
948
+ path.join(dir, 'AcmeTabs.doc.mjs'),
949
+ `export default {type: 'component', name: 'AcmeTabs', displayName: 'AcmeTabs', description: 'Fixture.', props: [], hiddenComponents: ['AcmeTabsPanel'], components: [${entry('AcmeTabsItem', 'label')}, ${entry('AcmeTabsPanel', 'ghost')}]};\n`,
950
+ );
951
+ fs.rmSync(path.join(dir, 'AcmeTabs.tsx'), {force: true});
952
+ // The package's entry exports the members, so discovery records the
953
+ // listed one, though the family's own file is missing.
954
+ for (const name of ['AcmeTabsItem', 'AcmeTabsPanel']) {
955
+ fs.writeFileSync(
956
+ path.join(dir, `${name}.tsx`),
957
+ `export function ${name}(props: {label?: string; open?: boolean}) { return null; }\n`,
958
+ );
959
+ fs.appendFileSync(
960
+ path.join(tmpDir, 'index.ts'),
961
+ `export {${name}} from './components/${name}';\n`,
962
+ );
963
+ }
964
+ process.chdir(tmpDir);
965
+
966
+ await createProgram().parseAsync([
967
+ 'node',
968
+ 'astryx',
969
+ '--json',
970
+ 'doctor',
971
+ 'integration',
972
+ 'components',
973
+ ]);
974
+ const report = JSON.parse(logCalls.join('\n')).data.propDrift;
975
+
976
+ expect(
977
+ report.findings.map(
978
+ (/** @type {any} */ f) => `${f.component}.${f.prop} ${f.kind}`,
979
+ ),
980
+ ).toEqual([
981
+ 'AcmeTabsItem.open not_in_doc',
982
+ 'AcmeTabsPanel.ghost not_in_code',
983
+ 'AcmeTabsPanel.label not_in_doc',
984
+ 'AcmeTabsPanel.open not_in_doc',
985
+ ]);
986
+ expect(report.unchecked).toEqual([
987
+ {
988
+ component: 'AcmeTabs',
989
+ doc: 'components/AcmeTabs.doc.mjs',
990
+ reason: 'no source file for AcmeTabs',
991
+ },
992
+ ]);
993
+ });
994
+
995
+ it('components counts two docs of one name as two components, and compares a .doc.ts beside a .doc.mjs', async () => {
996
+ writeComponentIntegration('AcmeTwin');
997
+ const dir = path.join(tmpDir, 'components');
998
+ const doc = (/** @type {string} */ name, /** @type {string} */ prop) =>
999
+ `export default {type: 'component', name: '${name}', displayName: '${name}', description: 'Fixture.', props: [{name: '${prop}', type: 'string', description: 'Text.'}]};\n`;
1000
+ fs.writeFileSync(
1001
+ path.join(dir, 'AcmeTwin.doc.mjs'),
1002
+ doc('AcmeTwin', 'tone'),
1003
+ );
1004
+ fs.writeFileSync(
1005
+ path.join(dir, 'AcmeTwin.doc.ts'),
1006
+ doc('AcmeTwin', 'size'),
1007
+ );
1008
+ fs.writeFileSync(
1009
+ path.join(dir, 'AcmeTwin.tsx'),
1010
+ 'export function AcmeTwin(props: {label?: string}) { return null; }\n',
1011
+ );
1012
+ for (const folder of ['a', 'b']) {
1013
+ fs.mkdirSync(path.join(dir, folder));
1014
+ fs.writeFileSync(
1015
+ path.join(dir, folder, 'AcmeX.doc.mjs'),
1016
+ doc('AcmeX', folder),
1017
+ );
1018
+ fs.writeFileSync(
1019
+ path.join(dir, folder, 'AcmeX.tsx'),
1020
+ 'export function AcmeX() { return null; }\n',
1021
+ );
1022
+ }
1023
+ process.chdir(tmpDir);
1024
+ const run = async (/** @type {string[]} */ flags) => {
1025
+ logCalls.length = 0;
1026
+ await createProgram().parseAsync([
1027
+ 'node',
1028
+ 'astryx',
1029
+ ...flags,
1030
+ 'doctor',
1031
+ 'integration',
1032
+ 'components',
1033
+ ]);
1034
+ return logCalls.join('\n');
1035
+ };
1036
+
1037
+ const report = JSON.parse(await run(['--json'])).data.propDrift;
1038
+ const output = await run([]);
1039
+
1040
+ expect(
1041
+ report.findings
1042
+ .filter(f => f.kind === 'not_in_code')
1043
+ .map(f => `${f.doc} ${f.prop}`)
1044
+ .sort(),
1045
+ ).toEqual([
1046
+ 'components/AcmeTwin.doc.mjs tone',
1047
+ 'components/AcmeTwin.doc.ts size',
1048
+ 'components/a/AcmeX.doc.mjs a',
1049
+ 'components/b/AcmeX.doc.mjs b',
1050
+ ]);
1051
+ expect(output).toMatch(/ in 4 of 4 component\(s\) compared /);
1052
+ });
1053
+
1054
+ it('components prints every field of the props comparison that JSON carries', async () => {
1055
+ writeComponentIntegration('AcmeBadge');
1056
+ fs.writeFileSync(
1057
+ path.join(tmpDir, 'components', 'AcmeBadge.doc.mjs'),
1058
+ `export default {type: 'component', name: 'AcmeBadge', displayName: 'AcmeBadge', description: 'Fixture component.', props: [${[
1059
+ "{name: 'label', type: 'string', description: 'Text.'}",
1060
+ "{name: 'elevation', type: 'number', description: 'Shadow.'}",
1061
+ "{name: 'size', type: 'string', description: 'Size.'}",
1062
+ "{name: 'meta', type: '{id: string; extra?: number}', description: 'Data.'}",
1063
+ ].join(', ')}]};\n`,
1064
+ );
1065
+ // `key` is a prop a doc never needs, though the component declares it.
1066
+ fs.writeFileSync(
1067
+ path.join(tmpDir, 'components', 'AcmeBadge.tsx'),
1068
+ "export function AcmeBadge(props: {label: string; tone?: 'neutral' | 'info'; size?: number; meta?: {id: string}; key?: string}) { return null; }\n",
1069
+ );
1070
+ fs.writeFileSync(
1071
+ path.join(tmpDir, 'components', 'AcmeLonely.doc.mjs'),
1072
+ `export default {type: 'component', name: 'AcmeLonely', displayName: 'AcmeLonely', description: 'Fixture component.', props: []};\n`,
1073
+ );
1074
+ fs.writeFileSync(
1075
+ path.join(tmpDir, 'components', 'AcmeLonely.tsx'),
1076
+ 'export function Other() { return null; }\n',
1077
+ );
1078
+ process.chdir(tmpDir);
1079
+ const run = async (/** @type {string[]} */ flags) => {
1080
+ logCalls.length = 0;
1081
+ await createProgram().parseAsync([
1082
+ 'node',
1083
+ 'astryx',
1084
+ ...flags,
1085
+ 'doctor',
1086
+ 'integration',
1087
+ 'components',
1088
+ ]);
1089
+ return logCalls.join('\n');
1090
+ };
1091
+
1092
+ const report = JSON.parse(await run(['--json'])).data.propDrift;
1093
+ const output = await run([]);
1094
+
1095
+ expect(report.findings.map(f => `${f.prop} ${f.kind}`)).toEqual([
1096
+ 'elevation not_in_code',
1097
+ 'label required_mismatch',
1098
+ 'size type_mismatch',
1099
+ 'tone not_in_doc',
1100
+ ]);
1101
+ expectRecordsCarry(output, report.findings, ['component', 'prop', 'kind']);
1102
+ expectRecordsCarry(output, report.unchecked, ['component', 'reason']);
1103
+ expectRecordsCarry(output, report.typesNotCompared, [
1104
+ 'component',
1105
+ 'reason',
1106
+ 'props',
1107
+ ]);
1108
+ expect(report.typesNotCompared).toHaveLength(1);
1109
+ expect(output).toContain(
1110
+ `Documented prop types judged: ${report.typesCompared}; not compared: 1.`,
1111
+ );
1112
+ // The response-type reference, which the README's JSON table comes from,
1113
+ // names every field too.
1114
+ const {doc: responseTypes} =
1115
+ await import('../../../foundation/response/response-types.doc.mjs');
1116
+ const member = responseTypes.members.find(
1117
+ m => m.value === 'integration.component-conflicts',
1118
+ );
1119
+ for (const field of Object.keys(report)) {
1120
+ expect(member?.description).toMatch(new RegExp(`\\b${field}\\b`));
1121
+ }
1122
+ expect(output).toContain(
1123
+ `in 1 of ${report.components} component(s) compared (TypeScript ${report.typescript}).`,
1124
+ );
1125
+ });
1126
+
1127
+ it('components says documented props match when they do', async () => {
1128
+ writeComponentIntegration('AcmeOnlyWidget');
1129
+ process.chdir(tmpDir);
1130
+
1131
+ await createProgram().parseAsync([
1132
+ 'node',
1133
+ 'astryx',
1134
+ 'doctor',
1135
+ 'integration',
1136
+ 'components',
1137
+ ]);
1138
+
1139
+ const output = logCalls.join('\n');
1140
+ expect(output).toMatch(
1141
+ /\[ok\] Documented prop names and required flags match the code in 1 component\(s\) \(TypeScript \d/,
1142
+ );
1143
+ expect(output).toMatch(
1144
+ /\[info\] Documented prop types judged: \d+; not compared: 0\./,
1145
+ );
1146
+ });
1147
+
1148
+ it('components prints no [ok] for props it could not compare', async () => {
1149
+ writeComponentIntegration('AcmeLonely');
1150
+ fs.writeFileSync(
1151
+ path.join(tmpDir, 'components', 'AcmeLonely.tsx'),
1152
+ 'export function Other() { return null; }\n',
1153
+ );
1154
+ process.chdir(tmpDir);
1155
+
1156
+ await createProgram().parseAsync([
1157
+ 'node',
1158
+ 'astryx',
1159
+ 'doctor',
1160
+ 'integration',
1161
+ 'components',
1162
+ ]);
1163
+
1164
+ const output = logCalls.join('\n');
1165
+ expect(output).toContain(
1166
+ '[info] Props not compared for 1 documented component(s):',
1167
+ );
1168
+ expect(output).not.toContain('[ok] Documented prop');
1169
+ });
1170
+
399
1171
  it('components exposes conflicts as structured JSON warnings', async () => {
400
1172
  const coreDir = findCoreDir(tmpDir);
401
1173
  expect(coreDir).not.toBeNull();
@@ -425,6 +1197,71 @@ describe('doctor integration — command', () => {
425
1197
  expect(process.exitCode).toBeUndefined();
426
1198
  });
427
1199
 
1200
+ 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 () => {
1201
+ const page = (await discoverCoreTemplates()).find(
1202
+ template => template.type === 'page',
1203
+ );
1204
+ expect(page).toBeDefined();
1205
+ fs.writeFileSync(
1206
+ path.join(tmpDir, 'package.json'),
1207
+ JSON.stringify({name: '@acme/widgets', version: '1.2.3'}),
1208
+ );
1209
+ fs.writeFileSync(
1210
+ path.join(tmpDir, 'astryx.integration.mjs'),
1211
+ "export default {components: './components', templates: './templates', docs: './docs'};\n",
1212
+ );
1213
+ for (const dir of ['components', 'templates', 'docs']) {
1214
+ fs.mkdirSync(path.join(tmpDir, dir), {recursive: true});
1215
+ }
1216
+ fs.writeFileSync(
1217
+ path.join(tmpDir, 'components', 'AcmeButton.doc.mjs'),
1218
+ "export default {type: 'component', name: 'AcmeButton', displayName: 'Acme Button', description: 'A button.', props: []};\n",
1219
+ );
1220
+ fs.writeFileSync(
1221
+ path.join(tmpDir, 'components', 'AcmeButton.tsx'),
1222
+ 'export function AcmeButton() { return null; }\n',
1223
+ );
1224
+ fs.writeFileSync(
1225
+ path.join(tmpDir, 'templates', 'acme-card.doc.mjs'),
1226
+ "export default {type: 'block', name: 'Acme card', description: 'A card.'};\n",
1227
+ );
1228
+ fs.writeFileSync(
1229
+ path.join(tmpDir, 'templates', 'acme-card.tsx'),
1230
+ 'export default function Fixture() { return null; }\n',
1231
+ );
1232
+ const text =
1233
+ 'Start with {@link component:AcmeButton} or {@link block:acme-card}, ' +
1234
+ 'built on {@link @astryxdesign/core:component:Button} and ' +
1235
+ `{@link @astryxdesign/core:page:${page?.dirName}}. Not {@link component:AcmeMissing}.`;
1236
+ fs.writeFileSync(
1237
+ path.join(tmpDir, 'docs', 'using-widgets.doc.mjs'),
1238
+ `export default ${JSON.stringify({
1239
+ type: 'generic',
1240
+ name: 'using-widgets',
1241
+ title: 'Using widgets',
1242
+ description: 'Fixture topic.',
1243
+ sections: [{title: 'Overview', content: [{type: 'prose', text}]}],
1244
+ })};\n`,
1245
+ );
1246
+ process.chdir(tmpDir);
1247
+
1248
+ await createProgram().parseAsync([
1249
+ 'node',
1250
+ 'astryx',
1251
+ '--json',
1252
+ 'doctor',
1253
+ 'integration',
1254
+ 'docs',
1255
+ ]);
1256
+
1257
+ const parsed = JSON.parse(logCalls.join('\n'));
1258
+ const unresolved = parsed.data.issues
1259
+ .filter(issue => issue.code === 'invalid_doc_graph')
1260
+ .map(issue => issue.message);
1261
+ expect(unresolved).toHaveLength(1);
1262
+ expect(unresolved[0]).toContain('"component:AcmeMissing" names no doc');
1263
+ });
1264
+
428
1265
  it('docs reports an intentional replacement as info', async () => {
429
1266
  const [coreTopic] = Object.keys(discoverBuiltinTopics());
430
1267
  writeDocIntegration({name: 'acme-setup', replaces: coreTopic});