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

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 (134) hide show
  1. package/README.md +9 -8
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +14 -0
  4. package/api/component/_adapter.mjs +48 -0
  5. package/api/component/component.mjs +10 -2
  6. package/api/component/integration-export-name.test.mjs +233 -8
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +2 -1
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +48 -3
  33. package/api/integration/pack-check.test.mjs +247 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.integration-check.test.mjs +24 -0
  40. package/api/search/search.mjs +20 -8
  41. package/api/theme/build/build.family.test.mjs +54 -6
  42. package/api/theme/build/build.mjs +114 -17
  43. package/api/theme/themeBuild.doc.mjs +4 -4
  44. package/assets/docs/README.md +10 -0
  45. package/assets/docs/author-a-theme.doc.mjs +1 -1
  46. package/assets/docs/getting-started.doc.mjs +3 -3
  47. package/assets/docs/theme.doc.dense.mjs +1 -1
  48. package/assets/docs/theme.doc.mjs +1 -1
  49. package/assets/docs/theme.doc.zh.mjs +1 -1
  50. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  51. package/assets/docs/tree/block-template.doc.mjs +2 -2
  52. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  53. package/assets/docs/tree/check-your-docs.doc.mjs +44 -3
  54. package/assets/docs/tree/checks.doc.mjs +21 -13
  55. package/assets/docs/tree/component-family.doc.mjs +5 -1
  56. package/assets/docs/tree/component-imports.doc.mjs +1 -1
  57. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  58. package/assets/docs/tree/links.doc.mjs +2 -2
  59. package/assets/docs/tree/page-template.doc.mjs +1 -1
  60. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  61. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  62. package/assets/docs/tree/single-component.doc.mjs +3 -2
  63. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  64. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  65. package/assets/docs/tree/versioning.doc.mjs +1 -0
  66. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  67. package/assets/docs/use-a-theme.doc.mjs +2 -2
  68. package/assets/theme.template.ts +1 -1
  69. package/authoring/doctypes/component/component.doc.mjs +1 -1
  70. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  71. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  72. package/authoring/doctypes/template/parse.d.mts +6 -2
  73. package/authoring/doctypes/template/parse.mjs +3 -1
  74. package/authoring/doctypes/template/template.doc.mjs +1 -1
  75. package/authoring/doctypes/template/type.ts +22 -0
  76. package/authoring/index.d.ts +1 -0
  77. package/authoring/integration/integration.doc.mjs +8 -1
  78. package/authoring/integration/parse.test.mjs +10 -0
  79. package/authoring/integration/schema.d.mts +2 -0
  80. package/authoring/integration/schema.mjs +1 -0
  81. package/authoring/integration/type.ts +4 -0
  82. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  83. package/clients/cli/commands/build-theme.mjs +1 -1
  84. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  85. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  86. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  87. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  88. package/clients/cli/commands/doctor.mjs +190 -0
  89. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  90. package/clients/cli/commands/integration-check.test.mjs +267 -0
  91. package/clients/cli/commands/integration.doc.mjs +6 -2
  92. package/clients/cli/commands/integration.mjs +107 -1
  93. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  94. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  95. package/clients/cli/index.mjs +1 -0
  96. package/foundation/config/project.mjs +82 -0
  97. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  98. package/foundation/discovery/component-discovery.d.mts +108 -17
  99. package/foundation/discovery/component-discovery.export-names.test.mjs +469 -2
  100. package/foundation/discovery/component-discovery.mjs +380 -47
  101. package/foundation/discovery/component-replacement.test.mjs +20 -0
  102. package/foundation/discovery/doc-types.d.mts +157 -0
  103. package/foundation/discovery/doc-types.mjs +1224 -0
  104. package/foundation/discovery/doc-types.test.mjs +652 -0
  105. package/foundation/discovery/docs-discovery.d.mts +81 -0
  106. package/foundation/discovery/docs-discovery.mjs +57 -0
  107. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  108. package/foundation/discovery/integration-doc-files.mjs +231 -0
  109. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  110. package/foundation/discovery/module-exports.d.mts +6 -0
  111. package/foundation/discovery/module-exports.mjs +15 -2
  112. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  113. package/foundation/discovery/package-tsconfig.mjs +228 -0
  114. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  115. package/foundation/discovery/project-typescript.d.mts +23 -0
  116. package/foundation/discovery/project-typescript.mjs +61 -0
  117. package/foundation/discovery/prop-drift.d.mts +278 -0
  118. package/foundation/discovery/prop-drift.mjs +2263 -0
  119. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  120. package/foundation/discovery/type-match.d.mts +53 -0
  121. package/foundation/discovery/type-match.mjs +660 -0
  122. package/foundation/discovery/type-match.test.mjs +169 -0
  123. package/foundation/discovery/type-text.d.mts +68 -0
  124. package/foundation/discovery/type-text.mjs +658 -0
  125. package/foundation/discovery/type-text.test.mjs +196 -0
  126. package/foundation/discovery/unresolved-references.d.mts +62 -0
  127. package/foundation/discovery/unresolved-references.mjs +790 -0
  128. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  129. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  130. package/foundation/doc-compiler/links.d.mts +9 -5
  131. package/foundation/doc-compiler/links.mjs +4 -2
  132. package/foundation/integrations/contribution-inventory.mjs +6 -1
  133. package/foundation/response/response-types.doc.mjs +7 -2
  134. package/package.json +9 -9
@@ -0,0 +1,73 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file FunctionDoc for `integrationCheck()` / `astryx integration check`.
5
+ * Colocated with the API function it documents; the shape source of truth stays
6
+ * in `integration-check.type.mjs`.
7
+ */
8
+
9
+ /** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
10
+ export const doc = {
11
+ type: 'function',
12
+ kind: 'api',
13
+ name: 'integrationCheck',
14
+ namespace: 'cli/api',
15
+ displayName: 'integrationCheck()',
16
+ summary:
17
+ 'Run every source check against the integration package in the current folder, as its build does: validate, components, templates, and docs.',
18
+ description:
19
+ 'Runs `validateIntegration`, `integrationComponentConflicts`, ' +
20
+ '`integrationTemplateConflicts`, and `integrationDocConflicts` against the ' +
21
+ 'integration package in `options.cwd`, and counts each result the way that ' +
22
+ "check's own subcommand exits. The run fails when any of the four would fail on " +
23
+ 'its own. With `strict` it also fails on any warning and on any check that did ' +
24
+ 'not run: a package with no integration manifest, or a comparison a check ' +
25
+ "carries that was skipped, such as one that needs the project's TypeScript. " +
26
+ '`strict` adds no check: it reports those warnings and skipped work again, as ' +
27
+ "errors in the checks' own issue format. " +
28
+ 'A new check, or a new failure code, joins only in a minor release, named in the release notes.',
29
+ importPath: '@astryxdesign/cli/api',
30
+ signature:
31
+ 'integrationCheck(options?: IntegrationCheckOptions): Promise<IntegrationCheckCommandResponse>',
32
+ keywords: ['integration', 'check', 'build', 'strict', 'validate', 'doctor'],
33
+ params: [
34
+ {
35
+ name: 'options.cwd',
36
+ type: 'string',
37
+ default: 'process.cwd()',
38
+ description: 'The integration package folder to check.',
39
+ },
40
+ {
41
+ name: 'options.strict',
42
+ type: 'boolean',
43
+ default: 'false',
44
+ description:
45
+ 'Also fail on any warning and on any check that did not run, not only on errors.',
46
+ },
47
+ ],
48
+ returns: [
49
+ {
50
+ type: 'integration.check',
51
+ description:
52
+ "`data.validated` (false when no integration manifest was found, so nothing was checked), `data.name` and `data.version` of the package, `data.strict`, and `data.checks`, one entry per check in the order they run (`validate`, `components`, `templates`, `docs`), each with `check`, `errors` (what makes that check's own subcommand exit 1), `warnings`, `skipped` (why work did not run), `failed` (whether that subcommand exits 1), and `response`, the envelope the check returns on its own. The totals are `data.errors`, `data.warnings`, and `data.skipped` (each reason prefixed with its check); an issue every check starts from, such as one about the manifest, counts once, and every other finding counts on its own. `data.unchecked` counts the items the checks list as not compared, which never fail it. With `strict`, `data.failures` lists each warning and each piece of skipped work once again as an error in the checks' own issue format ({check, severity: `error`, code, message, component, prop, fix, file, line}, each where it applies); it is empty without `strict`. `data.failed` decides the exit code.",
53
+ },
54
+ ],
55
+ examples: [
56
+ {
57
+ label: 'Check the local package',
58
+ code: 'const r = await integrationCheck();',
59
+ },
60
+ {
61
+ label: 'Fail on warnings too',
62
+ code: 'const {data} = await integrationCheck({strict: true});\nif (data.failed) process.exitCode = 1;',
63
+ },
64
+ ],
65
+ command: 'integration check',
66
+ related: [
67
+ 'validateIntegration',
68
+ 'integrationComponentConflicts',
69
+ 'integrationTemplateConflicts',
70
+ 'integrationDocConflicts',
71
+ 'integrationPackCheck',
72
+ ],
73
+ };
@@ -10,17 +10,45 @@ export const doc = {
10
10
  namespace: 'cli/api',
11
11
  displayName: 'integrationComponentConflicts()',
12
12
  summary:
13
- 'Check integration component names and replaces declarations against Core.',
13
+ 'Check integration component names, replaces declarations, and documented props.',
14
14
  description:
15
15
  'Loads one local or installed integration, compares its component names with ' +
16
16
  'Core, and returns non-blocking conflicts with the exact package-qualified ' +
17
17
  "component command required to keep an intentional overlap. Each component's " +
18
18
  'replaces is checked too: its findings are issues, errors for a package that ' +
19
- 'declares the CLI range that turns replacement on and warnings otherwise.',
19
+ 'declares the CLI range that turns replacement on and warnings otherwise. ' +
20
+ "Each component doc's props are compared with the component's TypeScript props, " +
21
+ "read with the project's own `typescript`: a documented prop the code lacks, a " +
22
+ 'prop the package declares that the doc omits, a wrong `required`, or a type ' +
23
+ 'that does not match. A type is judged only when it reads exactly: the same ' +
24
+ 'text as the source, once whitespace, quotes and union order are set aside (exactly, if it contains `readonly`), ' +
25
+ 'or primitives, literals, arrays and unions of those, or a name for one, on ' +
26
+ 'both sides, a field such as `status.type` included, and for a union of ' +
27
+ "props types, the type of any one member. A field the prop's variants give " +
28
+ "different types is listed when the doc gives only some variants' types. A " +
29
+ 'mutable array type matches a readonly one, because callers can pass it; a ' +
30
+ 'readonly one documented for a mutable array is a mismatch. ' +
31
+ '`Name = …` with a union after it ' +
32
+ "must match in both parts; one value after it (`Size = 'md'`) is read as a " +
33
+ "default. A name only TypeScript's own libraries declare is not resolved, and " +
34
+ '`React.X` resolves through React where the prop is declared. Every ' +
35
+ 'other type is listed as not compared, never reported, and so is a name an ' +
36
+ 'index signature takes unless exactly one the package declares takes it; ' +
37
+ 'with a TypeScript older than 4.8 no type is judged. ' +
38
+ 'Those findings are warnings in `propDrift`; without TypeScript it is ' +
39
+ '`skipped` with a reason.',
20
40
  importPath: '@astryxdesign/cli/api',
21
41
  signature:
22
42
  'integrationComponentConflicts(pkg?: string, options?: IntegrationAuthoringOptions): Promise<IntegrationComponentConflictResponse>',
23
- keywords: ['integration', 'component', 'conflict', 'authoring', 'doctor'],
43
+ keywords: [
44
+ 'integration',
45
+ 'component',
46
+ 'conflict',
47
+ 'authoring',
48
+ 'doctor',
49
+ 'props',
50
+ 'drift',
51
+ ],
24
52
  params: [
25
53
  {name: 'pkg', type: 'string', description: 'Installed package; omit for the local package.'},
26
54
  {name: 'options.cwd', type: 'string', description: 'Resolution directory.'},
@@ -29,11 +57,14 @@ export const doc = {
29
57
  {
30
58
  type: 'integration.component-conflicts',
31
59
  description:
32
- '`validated` (false when no integration manifest was found, so nothing was inspected), integration identity, structural and replaces issues, and Core component-name conflicts with package-qualified commands. An active replacement that keeps the Core name it replaces is not a conflict.',
60
+ '`validated` (false when no integration manifest was found, so nothing was inspected), integration identity, structural and replaces issues, and Core component-name conflicts with package-qualified commands. An active replacement that keeps the Core name it replaces is not a conflict. When the manifest loads and declares a components root, `propDrift` holds the props comparison: `status` (`checked` | `skipped`), `reason` when skipped, `typescript`, `components` compared, `typesCompared` (documented prop types judged), `typesSkipped` (why none was, with a TypeScript older than 4.8), `findings` ({component, prop, kind: `not_in_code` | `not_in_doc` | `required_mismatch` | `type_mismatch`, severity: `warning`, message, doc, source, docType?, codeType?, docRequired?, codeRequired?}), `unchecked` ({component, doc, reason}), `typesNotCompared` ({component, doc, reason, props}: every other documented prop type, grouped by why it was not judged: not a form the check judges exactly (a doc type that does not parse, or a prop a mapped type builds from keys the check cannot trace, included), a field the prop\'s variants give different types, a standard prop such as `className`, or a prop declared outside the package), and `propsNotChecked` when a mapped type builds props from keys the check cannot trace to the package ({component, doc, reason, props}: no warning is drawn for those props).',
33
61
  },
34
62
  ],
35
63
  examples: [
36
- {label: 'Check the local integration', code: 'await integrationComponentConflicts();'},
64
+ {
65
+ label: 'Check the local integration',
66
+ code: 'await integrationComponentConflicts();',
67
+ },
37
68
  ],
38
69
  command: 'doctor integration components',
39
70
  related: ['integrationTemplateConflicts', 'component'],
@@ -52,6 +52,8 @@ import {
52
52
  import {
53
53
  discoverIntegrationComponents,
54
54
  docStem,
55
+ familyImports,
56
+ isIntegrationMember,
55
57
  resolveIntegrationImportPath,
56
58
  } from '../../foundation/discovery/component-discovery.mjs';
57
59
  import {loadComponentReplacements} from '../component/_adapter.mjs';
@@ -381,11 +383,16 @@ async function validatePackedComponentExports(integration, scratchBase) {
381
383
  const issues = [];
382
384
  /** @type {Array<{name: string, specifier: string}>} */
383
385
  const components = [];
386
+ // A family doc backs several records: load it, and report it, once.
387
+ const loads = new Map();
384
388
  for (const record of records) {
389
+ const first = !loads.has(record.docPath);
390
+ if (first) loads.set(record.docPath, loadComponentDoc(record.docPath));
385
391
  let docs;
386
392
  try {
387
- docs = await loadComponentDoc(record.docPath);
393
+ docs = await loads.get(record.docPath);
388
394
  } catch (err) {
395
+ if (!first) continue;
389
396
  issues.push(
390
397
  error(
391
398
  'component_doc_unloadable',
@@ -994,6 +1001,24 @@ export async function integrationPackCheck(options = {}) {
994
1001
  const fileInv = computeRequiredFiles(loaded);
995
1002
  const {identities: localIdentities, errors: localDiscoveryErrors} =
996
1003
  await collectIdentities(loaded);
1004
+ // A family lists its members from the module its import names, which the
1005
+ // pack lifecycle may build or rebuild, so they are compared as local
1006
+ // discovery lists them after the pack.
1007
+ const familiesBefore = familyImports(loaded).map(family => family.docPath);
1008
+ /**
1009
+ * @param {Array<{name: string, docPath: string|null}>} records
1010
+ * @param {Set<string>} families
1011
+ */
1012
+ const membersOf = (records, families) =>
1013
+ records
1014
+ .filter(
1015
+ r =>
1016
+ r.docPath != null &&
1017
+ families.has(r.docPath) &&
1018
+ isIntegrationMember(r),
1019
+ )
1020
+ .map(r => r.name);
1021
+ const recordsBefore = discoverIntegrationComponents(loaded);
997
1022
  for (const discoveryError of localDiscoveryErrors) {
998
1023
  issues.push(
999
1024
  error(
@@ -1254,7 +1279,27 @@ export async function integrationPackCheck(options = {}) {
1254
1279
  );
1255
1280
  }
1256
1281
 
1257
- issues.push(...compareIdentities(localIdentities, packedIdentities));
1282
+ const families = new Set([
1283
+ ...familiesBefore,
1284
+ ...familyImports(loaded).map(family => family.docPath),
1285
+ ]);
1286
+ let localComponents = localIdentities.components;
1287
+ if (families.size > 0) {
1288
+ const stale = new Set(membersOf(recordsBefore, families));
1289
+ const built = new Set(
1290
+ membersOf(discoverIntegrationComponents(loaded), families),
1291
+ );
1292
+ const {identities: afterPack} = await collectIdentities(loaded);
1293
+ localComponents = [
1294
+ ...new Set([
1295
+ ...localComponents.filter(name => !stale.has(name)),
1296
+ ...afterPack.components.filter(name => built.has(name)),
1297
+ ]),
1298
+ ].sort();
1299
+ }
1300
+ // The receipt reports the local inventory that was compared.
1301
+ const comparedLocal = {...localIdentities, components: localComponents};
1302
+ issues.push(...compareIdentities(comparedLocal, packedIdentities));
1258
1303
  issues.push(
1259
1304
  ...(await validatePackedThemeExports(
1260
1305
  loaded,
@@ -1276,7 +1321,7 @@ export async function integrationPackCheck(options = {}) {
1276
1321
  pkgVersion,
1277
1322
  fileInv,
1278
1323
  packResult,
1279
- localIdentities,
1324
+ comparedLocal,
1280
1325
  packedIdentities,
1281
1326
  issues,
1282
1327
  );
@@ -786,6 +786,253 @@ describe('integrationPackCheck', () => {
786
786
  ]);
787
787
  });
788
788
 
789
+ it('checks each family member against the module its family doc imports', async () => {
790
+ fs.writeFileSync(
791
+ path.join(tmpDir, 'package.json'),
792
+ `${JSON.stringify({
793
+ name: '@acme/widgets',
794
+ version: '1.0.0',
795
+ files: ['astryx.integration.mjs', 'components'],
796
+ exports: {'./tabs': './components/tabs/index.ts'},
797
+ })}\n`,
798
+ );
799
+ fs.writeFileSync(
800
+ path.join(tmpDir, 'astryx.integration.mjs'),
801
+ "export default {components: './components'};\n",
802
+ );
803
+ const dir = path.join(tmpDir, 'components', 'tabs');
804
+ fs.mkdirSync(dir, {recursive: true});
805
+ fs.writeFileSync(
806
+ path.join(dir, 'AcmeTabs.tsx'),
807
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTabsLegacy() { return null; }\n',
808
+ );
809
+ fs.writeFileSync(
810
+ path.join(dir, 'tab.tsx'),
811
+ 'export function AcmeTab() { return null; }\n',
812
+ );
813
+ // The module apps import exports AcmeTabs and AcmeTab, not AcmeTabsLegacy.
814
+ fs.writeFileSync(
815
+ path.join(dir, 'index.ts'),
816
+ "export {AcmeTabs} from './AcmeTabs';\nexport {AcmeTab} from './tab';\n",
817
+ );
818
+ fs.writeFileSync(
819
+ path.join(dir, 'AcmeTabs.doc.mjs'),
820
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabs', description: 'x', props: []}, {name: 'AcmeTab', description: 'x', props: []}, {name: 'AcmeTabsLegacy', description: 'x', props: []}]};\n",
821
+ );
822
+
823
+ const result = await integrationPackCheck({cwd: tmpDir});
824
+
825
+ expect(result.data.issues).toEqual([]);
826
+ expect(result.data.packable).toBe(true);
827
+ expect(result.data.contributions.packed.components).toEqual([
828
+ 'AcmeTab',
829
+ 'AcmeTabs',
830
+ ]);
831
+ });
832
+
833
+ it.each([
834
+ 'a clean checkout',
835
+ 'a stale build',
836
+ 'a stale build that exports more',
837
+ ])(
838
+ 'reads members a family imports from output its pack lifecycle builds, from %s',
839
+ async start => {
840
+ fs.writeFileSync(
841
+ path.join(tmpDir, 'package.json'),
842
+ `${JSON.stringify({
843
+ name: '@acme/widgets',
844
+ version: '1.0.0',
845
+ files: ['astryx.integration.mjs', 'components', 'dist'],
846
+ exports: {'./tabs': './dist/tabs/index.mjs'},
847
+ scripts: {prepack: 'node build.mjs'},
848
+ })}\n`,
849
+ );
850
+ fs.writeFileSync(
851
+ path.join(tmpDir, 'astryx.integration.mjs'),
852
+ "export default {components: './components'};\n",
853
+ );
854
+ // The build writes the module the family imports; a clean checkout has none.
855
+ fs.writeFileSync(
856
+ path.join(tmpDir, 'build.mjs'),
857
+ "import fs from 'node:fs';\nfs.mkdirSync('dist/tabs', {recursive: true});\nfs.writeFileSync('dist/tabs/index.mjs', \"export {AcmeTabs, AcmeTab} from '../../components/tabs/AcmeTabs.tsx';\\n\");\n",
858
+ );
859
+ const dir = path.join(tmpDir, 'components', 'tabs');
860
+ fs.mkdirSync(dir, {recursive: true});
861
+ fs.writeFileSync(
862
+ path.join(dir, 'AcmeTabs.tsx'),
863
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTab() { return null; }\nexport function AcmeTabLegacy() { return null; }\n',
864
+ );
865
+ fs.writeFileSync(
866
+ path.join(dir, 'AcmeTabs.doc.mjs'),
867
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabs', description: 'x', props: []}, {name: 'AcmeTab', description: 'x', props: []}, {name: 'AcmeTabLegacy', description: 'x', props: []}]};\n",
868
+ );
869
+
870
+ if (start !== 'a clean checkout') {
871
+ // An earlier build: before AcmeTab was exported, or while AcmeTabLegacy
872
+ // still was.
873
+ const names =
874
+ start === 'a stale build'
875
+ ? 'AcmeTabs'
876
+ : 'AcmeTabs, AcmeTab, AcmeTabLegacy';
877
+ fs.mkdirSync(path.join(tmpDir, 'dist', 'tabs'), {recursive: true});
878
+ fs.writeFileSync(
879
+ path.join(tmpDir, 'dist', 'tabs', 'index.mjs'),
880
+ `export {${names}} from '../../components/tabs/AcmeTabs.tsx';\n`,
881
+ );
882
+ }
883
+
884
+ const result = await integrationPackCheck({cwd: tmpDir});
885
+
886
+ expect(result.data.issues).toEqual([]);
887
+ expect(result.data.packable).toBe(true);
888
+ expect(result.data.contributions.local.components).toEqual(
889
+ result.data.contributions.packed.components,
890
+ );
891
+ },
892
+ );
893
+
894
+ it.each(['a clean checkout', 'an incremental build'])(
895
+ 'reads members built output re-exports from compiled files, from %s',
896
+ async start => {
897
+ fs.writeFileSync(
898
+ path.join(tmpDir, 'package.json'),
899
+ `${JSON.stringify({
900
+ name: '@acme/widgets',
901
+ version: '1.0.0',
902
+ files: ['astryx.integration.mjs', 'components', 'dist'],
903
+ exports: {'./tabs': './dist/tabs/index.mjs'},
904
+ scripts: {prepack: 'node build.mjs'},
905
+ })}\n`,
906
+ );
907
+ fs.writeFileSync(
908
+ path.join(tmpDir, 'astryx.integration.mjs'),
909
+ "export default {components: './components'};\n",
910
+ );
911
+ // The build compiles each source to its own file and, like an
912
+ // incremental build, rewrites only the files whose output changed.
913
+ const output = {
914
+ 'index.mjs':
915
+ "export {AcmeTabs} from './tabs.mjs';\nexport * from './tab.mjs';\n",
916
+ 'tabs.mjs': 'export function AcmeTabs() { return null; }\n',
917
+ 'tab.mjs': 'export function AcmeTab() { return null; }\n',
918
+ };
919
+ fs.writeFileSync(
920
+ path.join(tmpDir, 'build.mjs'),
921
+ `import fs from 'node:fs';\nconst output = ${JSON.stringify(output)};\nfs.mkdirSync('dist/tabs', {recursive: true});\nfor (const [file, text] of Object.entries(output)) {\n const target = 'dist/tabs/' + file;\n if (!fs.existsSync(target) || fs.readFileSync(target, 'utf-8') !== text) fs.writeFileSync(target, text);\n}\n`,
922
+ );
923
+ const dir = path.join(tmpDir, 'components', 'tabs');
924
+ fs.mkdirSync(dir, {recursive: true});
925
+ fs.writeFileSync(
926
+ path.join(dir, 'AcmeTabs.tsx'),
927
+ 'export function AcmeTabs() { return null; }\n',
928
+ );
929
+ fs.writeFileSync(
930
+ path.join(dir, 'AcmeTab.tsx'),
931
+ 'export function AcmeTab() { return null; }\n',
932
+ );
933
+ fs.writeFileSync(
934
+ path.join(dir, 'AcmeTabs.doc.mjs'),
935
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabs', description: 'x', props: []}, {name: 'AcmeTab', description: 'x', props: []}]};\n",
936
+ );
937
+ if (start === 'an incremental build') {
938
+ // An earlier build from before AcmeTab existed: the file the import
939
+ // names is already current, so the build leaves it untouched.
940
+ fs.mkdirSync(path.join(tmpDir, 'dist', 'tabs'), {recursive: true});
941
+ for (const [file, text] of Object.entries({...output, 'tab.mjs': ''})) {
942
+ fs.writeFileSync(path.join(tmpDir, 'dist', 'tabs', file), text);
943
+ }
944
+ }
945
+
946
+ const result = await integrationPackCheck({cwd: tmpDir});
947
+
948
+ expect(result.data.issues).toEqual([]);
949
+ expect(result.data.packable).toBe(true);
950
+ expect(result.data.contributions.packed.components).toEqual([
951
+ 'AcmeTab',
952
+ 'AcmeTabs',
953
+ ]);
954
+ expect(result.data.contributions.local.components).toEqual(
955
+ result.data.contributions.packed.components,
956
+ );
957
+ },
958
+ );
959
+
960
+ it('passes a member another folder declares, which has no source', async () => {
961
+ fs.writeFileSync(
962
+ path.join(tmpDir, 'package.json'),
963
+ `${JSON.stringify({
964
+ name: '@acme/widgets',
965
+ version: '1.0.0',
966
+ files: ['astryx.integration.mjs', 'components'],
967
+ exports: {'./menu': './components/menu/index.ts'},
968
+ })}\n`,
969
+ );
970
+ fs.writeFileSync(
971
+ path.join(tmpDir, 'astryx.integration.mjs'),
972
+ "export default {components: './components'};\n",
973
+ );
974
+ const menu = path.join(tmpDir, 'components', 'menu');
975
+ const shared = path.join(tmpDir, 'components', 'shared');
976
+ fs.mkdirSync(menu, {recursive: true});
977
+ fs.mkdirSync(shared, {recursive: true});
978
+ fs.writeFileSync(
979
+ path.join(menu, 'AcmeMenu.doc.mjs'),
980
+ "export default {type: 'component', name: 'AcmeMenu', import: '@acme/widgets/menu', description: 'x', components: [{name: 'AcmeMenuItem', description: 'x', props: []}]};\n",
981
+ );
982
+ fs.writeFileSync(
983
+ path.join(menu, 'AcmeMenu.tsx'),
984
+ 'export function AcmeMenu() { return null; }\n',
985
+ );
986
+ fs.writeFileSync(
987
+ path.join(menu, 'index.ts'),
988
+ "export {AcmeMenu} from './AcmeMenu';\nexport {AcmeMenuItem} from '../shared/AcmeMenuItem';\n",
989
+ );
990
+ fs.writeFileSync(
991
+ path.join(shared, 'AcmeMenuItem.tsx'),
992
+ 'export function AcmeMenuItem() { return null; }\n',
993
+ );
994
+
995
+ const result = await integrationPackCheck({cwd: tmpDir});
996
+
997
+ expect(result.data.issues).toEqual([]);
998
+ expect(result.data.packable).toBe(true);
999
+ expect(result.data.contributions.packed.components).toEqual([
1000
+ 'AcmeMenu',
1001
+ 'AcmeMenuItem',
1002
+ ]);
1003
+ });
1004
+
1005
+ it('reports a family doc the loader rejects once, not once per member', async () => {
1006
+ fs.writeFileSync(
1007
+ path.join(tmpDir, 'package.json'),
1008
+ `${JSON.stringify({
1009
+ name: '@acme/widgets',
1010
+ version: '1.0.0',
1011
+ files: ['astryx.integration.mjs', 'components'],
1012
+ })}\n`,
1013
+ );
1014
+ fs.writeFileSync(
1015
+ path.join(tmpDir, 'astryx.integration.mjs'),
1016
+ "export default {components: './components'};\n",
1017
+ );
1018
+ fs.mkdirSync(path.join(tmpDir, 'components'));
1019
+ fs.writeFileSync(
1020
+ path.join(tmpDir, 'components', 'AcmeTabs.tsx'),
1021
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTab() { return null; }\nexport function AcmeTabPanel() { return null; }\n',
1022
+ );
1023
+ // A displayName that is not a string makes the loader reject the doc.
1024
+ fs.writeFileSync(
1025
+ path.join(tmpDir, 'components', 'AcmeTabs.doc.mjs'),
1026
+ "export default {type: 'component', name: 'AcmeTabs', displayName: 1, description: 'x', components: [{name: 'AcmeTab', description: 'x', props: []}, {name: 'AcmeTabPanel', description: 'x', props: []}]};\n",
1027
+ );
1028
+
1029
+ const result = await integrationPackCheck({cwd: tmpDir});
1030
+ const codes = result.data.issues.map(issue => issue.code);
1031
+
1032
+ expect(codes).toContain('component_doc_unloadable');
1033
+ expect(codes.length).toBe(new Set(codes).size);
1034
+ });
1035
+
789
1036
  it('passes a generated component through its exact packed export', async () => {
790
1037
  fs.writeFileSync(
791
1038
  path.join(tmpDir, 'package.json'),
@@ -34,6 +34,7 @@
34
34
  import * as fs from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
  import {assertWithin} from '../../foundation/fs/path-safety.mjs';
37
+ import {integrationDocTypes} from '../docs/_adapter.mjs';
37
38
  import {
38
39
  THEME_SLUG_RE,
39
40
  unreadThemeFolders,
@@ -461,6 +462,12 @@ export async function validateIntegration(pkg, options = {}) {
461
462
  const result = pkg
462
463
  ? await validateInstalledIntegration(pkg, cwd)
463
464
  : await validateLocalIntegration(cwd);
465
+ // Each doc the package ships, against the published authoring type for its
466
+ // kind, read with the project's own compiler. Findings are warnings only.
467
+ const integration = result.found ? result.integration : undefined;
468
+ const docTypes = integration
469
+ ? await integrationDocTypes(integration, cwd)
470
+ : undefined;
464
471
  return {
465
472
  type: 'integration.validate',
466
473
  data: {
@@ -472,6 +479,7 @@ export async function validateIntegration(pkg, options = {}) {
472
479
  issues: result.reason === 'no_package_json'
473
480
  ? [{code: 'no_package', severity: 'error', message: 'No package.json found. Run this command inside a package directory.'}]
474
481
  : result.issues,
482
+ ...(docTypes ? {docTypes} : {}),
475
483
  },
476
484
  };
477
485
  }
@@ -176,6 +176,17 @@ describe('validate-integration API', () => {
176
176
  expect(agentDocsIssues[0].severity).toBe('error');
177
177
  });
178
178
 
179
+ it('reports nothing for a docsiteUrl', async () => {
180
+ const pkgDir = path.join(tmpDir, 'pkg');
181
+ writePackage(pkgDir, {
182
+ manifest: `export default {docsiteUrl: 'https://widgets.example.com'};\n`,
183
+ });
184
+
185
+ const result = await validateLocalIntegration(pkgDir);
186
+
187
+ expect(result.issues).toEqual([]);
188
+ });
189
+
179
190
  it('flags a broken codemod as invalid_codemod error', async () => {
180
191
  const pkgDir = path.join(tmpDir, 'pkg');
181
192
  writePackage(pkgDir, {
@@ -39,5 +39,13 @@ export type ValidateIntegrationResponse = {
39
39
  name: string | null;
40
40
  version: string | null;
41
41
  issues: import("../../foundation/integrations/issue").AstryxIntegrationIssue[];
42
+ docTypes?: DocTypesReport;
42
43
  };
43
44
  };
45
+ /**
46
+ * `fixtures`: how many test fixture docs (under `__tests__` in the
47
+ * components or docs root, which those readers skip) were not checked
48
+ */
49
+ export type DocTypesReport = import("../../foundation/discovery/doc-types.mjs").DocTypesReport & {
50
+ fixtures?: number;
51
+ };
@@ -38,7 +38,18 @@
38
38
  * "healthy" — the two are otherwise byte-identical.
39
39
  * @typedef {object} ValidateIntegrationResponse
40
40
  * @property {'integration.validate'} type
41
- * @property {{validated: boolean, name: string | null, version: string | null, issues: import('../../foundation/integrations/issue').AstryxIntegrationIssue[]}} data
41
+ * @property {{validated: boolean, name: string | null, version: string | null, issues: import('../../foundation/integrations/issue').AstryxIntegrationIssue[], docTypes?: DocTypesReport}} data
42
+ */
43
+
44
+ /**
45
+ * Each doc the package ships, checked against the published authoring type
46
+ * for its kind. Present when the manifest loads. `status: 'skipped'`
47
+ * means nothing was checked (no TypeScript in the project) and `reason` says
48
+ * why. Every finding is a warning.
49
+ *
50
+ * @typedef {import('../../foundation/discovery/doc-types.mjs').DocTypesReport & {fixtures?: number}} DocTypesReport
51
+ * `fixtures`: how many test fixture docs (under `__tests__` in the
52
+ * components or docs root, which those readers skip) were not checked
42
53
  */
43
54
 
44
55
  export {};
@@ -23,11 +23,25 @@ export const doc = {
23
23
  'declared contribution root exists, and runs the codemod/template/component/' +
24
24
  'docs/theme validators, returning every finding as an AstryxIntegrationIssue. A missing ' +
25
25
  'local manifest is guidance (name null, no issues), not an error, so callers ' +
26
- 'can stay exit-0 in a non-integration directory.',
26
+ 'can stay exit-0 in a non-integration directory. It also checks every doc the ' +
27
+ 'package ships under its roots (component, template, docs topic, theme, and translation, served or not) against ' +
28
+ "the published authoring type for its kind, as `tsc` would, with the project's " +
29
+ 'own `typescript`: an unknown field, a missing required field, or a value outside ' +
30
+ "a field's type is a warning in `docTypes` naming the field and a fix. A test " +
31
+ 'fixture under `__tests__` in the components or docs root is counted, not checked. ' +
32
+ 'Without TypeScript, `docTypes` is `skipped` with a reason.',
27
33
  importPath: '@astryxdesign/cli/api',
28
34
  signature:
29
35
  'validateIntegration(pkg?: string, options?: ValidateIntegrationOptions): Promise<ValidateIntegrationResponse>',
30
- keywords: ['integration', 'validate', 'manifest', 'lint', 'check'],
36
+ keywords: [
37
+ 'integration',
38
+ 'validate',
39
+ 'manifest',
40
+ 'lint',
41
+ 'check',
42
+ 'types',
43
+ 'typecheck',
44
+ ],
31
45
  params: [
32
46
  {
33
47
  name: 'pkg',
@@ -46,7 +60,7 @@ export const doc = {
46
60
  {
47
61
  type: 'integration.validate',
48
62
  description:
49
- 'The result envelope: `data.validated`, false when nothing was validated — with no `package.json` at all, `data.issues` carries one `no_package` error-severity issue so the command exits 1; with a `package.json` but no integration manifest, `data.issues` is empty, which means "not looked at", not "healthy"; `data.name` and `data.version` of the validated package (both null when `data.validated` is false; `data.name` is `(local package)` when the local package.json has no readable name), plus `data.issues`, an AstryxIntegrationIssue[] of {code, severity: `warning` | `error`, message}.',
63
+ 'The result envelope: `data.validated`, false when nothing was validated — with no `package.json` at all, `data.issues` carries one `no_package` error-severity issue so the command exits 1; with a `package.json` but no integration manifest, `data.issues` is empty, which means "not looked at", not "healthy"; `data.name` and `data.version` of the validated package (both null when `data.validated` is false; `data.name` is `(local package)` when the local package.json has no readable name), plus `data.issues`, an AstryxIntegrationIssue[] of {code, severity: `warning` | `error`, message}. When the manifest loads, `data.docTypes` holds the doc type check: `status` (`checked` | `skipped`), `reason` when skipped, `typescript`, `docs` (doc values checked), `findings` ({doc, line, export, type, field, problem: `unknown_field` | `missing_field` | `wrong_type`, severity: `warning`, message, fix}), `unchecked` ({doc, reason}: a doc that could not be fully checked, or a root its reader cannot read), and `fixtures` when the components or docs root holds test fixture docs under `__tests__`: how many, none of them checked.',
50
64
  },
51
65
  ],
52
66
  examples: [
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A reader looking for the check a build runs finds
5
+ * `astryx integration check` first.
6
+ */
7
+
8
+ import {describe, expect, it} from 'vitest';
9
+ import {search} from './search.mjs';
10
+
11
+ describe('search finds integration check for CI', () => {
12
+ it.each([
13
+ 'ci',
14
+ 'continuous integration',
15
+ 'typecheck integration',
16
+ 'integration check',
17
+ ])('`%s` ranks it first', async query => {
18
+ const {results} = (await search(query)).data;
19
+ expect(results[0], JSON.stringify(results.slice(0, 3))).toMatchObject({
20
+ domain: 'doc',
21
+ name: 'cli/commands/integration-check',
22
+ });
23
+ });
24
+ });