@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4

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 (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -23,8 +23,12 @@
23
23
 
24
24
  import * as fs from 'node:fs';
25
25
  import * as path from 'node:path';
26
+ import {parse as parseModule} from '@babel/parser';
26
27
  import {isValidSemver} from '../env/semver.mjs';
28
+ import {findProjectCoreDir} from '../fs/paths.mjs';
29
+ import {KNOWN_INTEGRATION_KEYS} from '../../authoring/integration/schema.mjs';
27
30
  import {
31
+ contributionFiles,
28
32
  findSourceOnlyCandidates,
29
33
  findTemplateSourceOnlyCandidates,
30
34
  } from './contribution-inventory.mjs';
@@ -48,6 +52,12 @@ import {MANIFEST_BASENAMES} from './integrations.mjs';
48
52
  import {discoverIntegrationCodemods} from '../../assets/codemods/integration-discovery.mjs';
49
53
  import {discoverIntegrationTemplatesForOne} from '../discovery/template-adapter.mjs';
50
54
  import * as componentDiscovery from '../discovery/component-discovery.mjs';
55
+ import {loadComponentDoc} from '../discovery/component-loader.mjs';
56
+ import {
57
+ moduleExportNames,
58
+ moduleExportsName,
59
+ moduleExportVerdict,
60
+ } from '../discovery/module-exports.mjs';
51
61
  import {discoverIntegrationDocs} from '../discovery/docs-discovery.mjs';
52
62
  import {discoverIntegrationThemes} from '../discovery/theme-discovery.mjs';
53
63
 
@@ -75,6 +85,32 @@ export function issueWarning(code, message) {
75
85
  return {code, severity: 'warning', message};
76
86
  }
77
87
 
88
+ /** Issues `issueListed` made: listed in validate's `layout` report only. */
89
+ const LISTED = new WeakSet();
90
+
91
+ /**
92
+ * A layout finding added after the last minor release: validate lists it, in
93
+ * its `layout` report, but it is never an issue, so nothing counts it and
94
+ * `integration check --strict` does not fail on it. A new failing finding
95
+ * joins only in a minor release (architecture:cli-surface INV35).
96
+ * @param {string} code
97
+ * @param {string} message
98
+ * @returns {Issue}
99
+ */
100
+ export function issueListed(code, message) {
101
+ const issue = issueWarning(code, message);
102
+ LISTED.add(issue);
103
+ return issue;
104
+ }
105
+
106
+ /**
107
+ * Whether `issueListed` made this issue.
108
+ * @param {Issue} issue
109
+ */
110
+ export function isListedIssue(issue) {
111
+ return LISTED.has(issue);
112
+ }
113
+
78
114
  /**
79
115
  * Report the manifest keys this CLI does not know.
80
116
  *
@@ -92,11 +128,36 @@ export function issueWarning(code, message) {
92
128
  function checkUnknownKeys(integration, issues) {
93
129
  const keys = integration.__unknownKeys ?? [];
94
130
  if (keys.length === 0) return;
131
+ // A key one edit away from a known one is almost always a typo: say so
132
+ // first, since "a newer CLI" sends the author to upgrade for nothing.
133
+ const typos = keys
134
+ .map(key => ({key, known: nearestKnownKey(key)}))
135
+ .filter(each => each.known != null);
136
+ for (const {key, known} of typos) {
137
+ // The manifest object already declares the key it is a near miss of:
138
+ // renaming would give it twice, so the stray key goes. Read from what
139
+ // the manifest says, not the loaded integration, which fills some keys
140
+ // in (providerId from the package name).
141
+ const alreadySet = (integration.__declaredKeys ?? []).includes(
142
+ /** @type {string} */ (known),
143
+ );
144
+ const fix = alreadySet
145
+ ? `Fix: remove "${key}" from ${manifestName(integration)}; it already sets "${known}".`
146
+ : `Fix: rename "${key}" to "${known}" in ${manifestName(integration)}.`;
147
+ issues.push(
148
+ issueWarning(
149
+ 'unknown_manifest_key',
150
+ `This CLI does not know the manifest key "${key}", so it is ignored. Did you mean \`${known}\`? ${fix} If it is a key from a newer Astryx CLI instead, upgrading @astryxdesign/cli picks it up.`,
151
+ ),
152
+ );
153
+ }
154
+ const rest = keys.filter(key => !typos.some(each => each.key === key));
155
+ if (rest.length === 0) return;
95
156
  issues.push(
96
157
  issueWarning(
97
158
  'unknown_manifest_key',
98
- `This CLI does not know the manifest ${keys.length === 1 ? 'key' : 'keys'} ` +
99
- `${keys.map(key => `"${key}"`).join(', ')}, so ${keys.length === 1 ? 'it is' : 'they are'} ` +
159
+ `This CLI does not know the manifest ${rest.length === 1 ? 'key' : 'keys'} ` +
160
+ `${rest.map(key => `"${key}"`).join(', ')}, so ${rest.length === 1 ? 'it is' : 'they are'} ` +
100
161
  `ignored — everything else in the manifest still applies. This usually ` +
101
162
  `means the integration was published against a newer Astryx CLI than ` +
102
163
  `this project resolves; upgrading @astryxdesign/cli picks it up.`,
@@ -104,6 +165,60 @@ function checkUnknownKeys(integration, issues) {
104
165
  );
105
166
  }
106
167
 
168
+ /** @param {{__manifestFile?: string}} integration */
169
+ function manifestName(integration) {
170
+ return integration.__manifestFile
171
+ ? path.basename(integration.__manifestFile)
172
+ : 'the integration manifest';
173
+ }
174
+
175
+ /**
176
+ * The known manifest key `key` is a near miss of: a different letter case, or
177
+ * at most two letters added, dropped or changed (`component`, `doc`,
178
+ * `tempaltes`). Null when no known key is that close, or two are.
179
+ * @param {string} key
180
+ * @returns {string | null}
181
+ */
182
+ export function nearestKnownKey(key) {
183
+ const lower = key.toLowerCase();
184
+ const limit = lower.length <= 4 ? 1 : 2;
185
+ /** @type {string[]} */
186
+ const close = [];
187
+ for (const known of KNOWN_INTEGRATION_KEYS) {
188
+ const distance = editDistance(lower, known.toLowerCase());
189
+ if (distance <= limit) close.push(known);
190
+ }
191
+ return close.length === 1 ? close[0] : null;
192
+ }
193
+
194
+ /**
195
+ * Levenshtein distance, with a swap of two neighbours counted as one edit.
196
+ * @param {string} a
197
+ * @param {string} b
198
+ */
199
+ function editDistance(a, b) {
200
+ /** @type {number[][]} */
201
+ const d = Array.from({length: a.length + 1}, (_, i) =>
202
+ Array.from({length: b.length + 1}, (_, j) =>
203
+ i === 0 ? j : j === 0 ? i : 0,
204
+ ),
205
+ );
206
+ for (let i = 1; i <= a.length; i++) {
207
+ for (let j = 1; j <= b.length; j++) {
208
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
209
+ d[i][j] = Math.min(
210
+ d[i - 1][j] + 1,
211
+ d[i][j - 1] + 1,
212
+ d[i - 1][j - 1] + cost,
213
+ );
214
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
215
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
216
+ }
217
+ }
218
+ }
219
+ return d[a.length][b.length];
220
+ }
221
+
107
222
  /**
108
223
  * Why a declared root cannot be read as a folder, or null when it can.
109
224
  * A missing root keeps its own issue; a root that is a file or that the
@@ -139,11 +254,14 @@ export function rootProblem(root) {
139
254
  * declared-but-missing root is a `missing_root` error, a file is an
140
255
  * `invalid_root` error, and a folder that cannot be read is an
141
256
  * `unreadable_root` error. The contribution checks skip all three.
142
- * @param {{components?: string, templates?: string, codemods?: string, docs?: string, themes?: string}} resolved
257
+ * A readable root reached through a symlinked folder is a `symlinked_root`
258
+ * warning: npm pack does not follow it, so the published package loses it.
259
+ * @param {{components?: string, templates?: string, codemods?: string, docs?: string, themes?: string, __manifestFile?: string}} resolved
143
260
  * absolute resolved roots (undefined when not declared)
144
261
  * @param {Issue[]} issues
262
+ * @param {string} [packageDir] the package the roots belong to
145
263
  */
146
- function checkRoots(resolved, issues) {
264
+ function checkRoots(resolved, issues, packageDir) {
147
265
  const kinds = /** @type {const} */ ([
148
266
  'components',
149
267
  'templates',
@@ -155,7 +273,28 @@ function checkRoots(resolved, issues) {
155
273
  const root = resolved[kind];
156
274
  if (root == null) continue;
157
275
  const problem = rootProblem(root);
158
- if (problem == null) continue;
276
+ if (problem == null) {
277
+ const link = packageDir ? symlinkedFolder(root, packageDir) : null;
278
+ if (link && packageDir) {
279
+ const shown = slash(path.relative(packageDir, link.path));
280
+ const realPackage = fs.realpathSync(packageDir);
281
+ const target = slash(path.relative(realPackage, link.target));
282
+ const realRoot = slash(
283
+ path.relative(realPackage, fs.realpathSync(root)),
284
+ );
285
+ const outside = target.startsWith('../') || path.isAbsolute(target);
286
+ const fix = outside
287
+ ? 'replace the link with a real folder holding its files'
288
+ : `replace the link with the folder itself (move ${target}/ to ${shown}/), or set \`${kind}: './${realRoot}'\` in ${manifestName(resolved)}`;
289
+ issues.push(
290
+ issueListed(
291
+ 'symlinked_root',
292
+ `Declared ${kind} root "${slash(path.relative(packageDir, root))}" is reached through the symlink "${shown}" (to "${target}"). Astryx follows it locally, but npm pack does not follow a symlinked folder, so the published package ships none of these files. Fix: ${fix}.`,
293
+ ),
294
+ );
295
+ }
296
+ continue;
297
+ }
159
298
  if (problem.kind === 'missing') {
160
299
  issues.push(
161
300
  issueError(
@@ -181,6 +320,41 @@ function checkRoots(resolved, issues) {
181
320
  }
182
321
  }
183
322
 
323
+ /** @param {string} file */
324
+ function slash(file) {
325
+ return file.split(path.sep).join('/');
326
+ }
327
+
328
+ /**
329
+ * The first folder on the way from the package to `root` (the root itself
330
+ * included) that is a symlink, with where it points, or null.
331
+ * @param {string} root absolute
332
+ * @param {string} packageDir absolute
333
+ * @returns {{path: string, target: string} | null}
334
+ */
335
+ export function symlinkedFolder(root, packageDir) {
336
+ const relative = path.relative(packageDir, root);
337
+ if (
338
+ relative === '' ||
339
+ relative.startsWith('..') ||
340
+ path.isAbsolute(relative)
341
+ ) {
342
+ return null;
343
+ }
344
+ let current = packageDir;
345
+ for (const segment of relative.split(path.sep)) {
346
+ current = path.join(current, segment);
347
+ try {
348
+ if (fs.lstatSync(current).isSymbolicLink()) {
349
+ return {path: current, target: fs.realpathSync(current)};
350
+ }
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
355
+ return null;
356
+ }
357
+
184
358
  /**
185
359
  * Validate the integration's codemods via the landed discovery. Discovery is
186
360
  * strict (throws on bad export / duplicate id); we convert any throw into an
@@ -304,6 +478,551 @@ async function checkTemplates(integration, issues) {
304
478
  checkTemplateSourcesWithoutDoc(integration, issues);
305
479
  }
306
480
 
481
+ /**
482
+ * The component names each of a package's templates gives in `exampleFor`
483
+ * and `componentsUsed`, judged against the components `astryx component`
484
+ * finds: the package's own, and every doc it resolves in Core. A name the
485
+ * template imports needs no doc of its own. A name neither has is an
486
+ * `unknown_block_component` finding, since the template list and the
487
+ * block's search keywords then give a name no one can look up. A name the
488
+ * template may reach through anything the walk does not model, and, without
489
+ * Core installed, a name the package does not have, cannot be judged and is
490
+ * listed in `unchecked` instead.
491
+ * @param {LoadedIntegration} integration
492
+ * @returns {Promise<{findings: Array<{docPath: string, name: string, message: string}>, unchecked: Array<{docPath: string, name: string, reason: string}>}>}
493
+ */
494
+ async function judgeBlockComponents(integration) {
495
+ /** @type {Array<{docPath: string, name: string, message: string}>} */
496
+ const findings = [];
497
+ /** @type {Array<{docPath: string, name: string, reason: string}>} */
498
+ const unchecked = [];
499
+ const root = integration.templates;
500
+ if (!root || rootProblem(root)) return {findings, unchecked};
501
+ let templates;
502
+ try {
503
+ ({templates} = await discoverIntegrationTemplatesForOne(integration));
504
+ } catch {
505
+ return {findings, unchecked};
506
+ }
507
+ /** @type {Set<string>} */
508
+ let own;
509
+ try {
510
+ own = new Set(
511
+ componentDiscovery
512
+ .discoverIntegrationComponents(integration)
513
+ .map(record => record.name),
514
+ );
515
+ } catch {
516
+ return {findings, unchecked};
517
+ }
518
+ // The Core installed beside the package, never the one the CLI carries:
519
+ // a name is judged against the Core the package's users install.
520
+ const coreDir = findProjectCoreDir(integration.__packageDir ?? root);
521
+ /** @type {Map<string, boolean>} */
522
+ const inCore = new Map();
523
+ /** @param {string} name */
524
+ const coreHas = name => {
525
+ if (!coreDir) return false;
526
+ if (!inCore.has(name)) {
527
+ let found;
528
+ try {
529
+ found = componentDiscovery.findComponentReadme(coreDir, name) != null;
530
+ } catch {
531
+ found = false;
532
+ }
533
+ inCore.set(name, found);
534
+ }
535
+ return /** @type {boolean} */ (inCore.get(name));
536
+ };
537
+ for (const template of templates) {
538
+ const fields = [
539
+ ...(typeof template.exampleFor === 'string'
540
+ ? [{field: 'exampleFor', name: template.exampleFor}]
541
+ : []),
542
+ ...(Array.isArray(template.componentsUsed) ? template.componentsUsed : [])
543
+ .filter(name => typeof name === 'string')
544
+ .map(name => ({field: 'componentsUsed', name})),
545
+ ];
546
+ if (fields.length === 0) continue;
547
+ const imported = importedNames(template.filePath, integration);
548
+ for (const {field, name} of fields) {
549
+ if (own.has(name) || imported.names.has(name)) continue;
550
+ if (coreHas(name)) continue;
551
+ // A name the template may reach through a namespace import, or
552
+ // through a local module this read could not follow, is not judged.
553
+ if (imported.unread.length > 0) {
554
+ unchecked.push({
555
+ docPath: template.docPath,
556
+ name,
557
+ reason: imported.unread[0],
558
+ });
559
+ continue;
560
+ }
561
+ if (!coreDir) {
562
+ unchecked.push({
563
+ docPath: template.docPath,
564
+ name,
565
+ reason:
566
+ 'Core is not installed, so its component names cannot be checked.',
567
+ });
568
+ continue;
569
+ }
570
+ findings.push({
571
+ docPath: template.docPath,
572
+ name,
573
+ message: `Template "${template.dirName}" names "${name}" in ${field}, but neither this package nor Core has a component by that name and the template does not import it, so \`astryx template --list\` and the block's search keywords give a component name no one can look up. Fix: use the component's exact name, or remove "${name}" from ${field}.`,
574
+ });
575
+ }
576
+ }
577
+ return {findings, unchecked};
578
+ }
579
+
580
+ /**
581
+ * Visit every node under `node`, depth first.
582
+ * @param {any} node
583
+ * @param {(node: any) => void} visit
584
+ */
585
+ function walkNodes(node, visit) {
586
+ if (node == null || typeof node !== 'object') return;
587
+ if (Array.isArray(node)) {
588
+ for (const each of node) walkNodes(each, visit);
589
+ return;
590
+ }
591
+ if (typeof node.type === 'string') visit(node);
592
+ for (const [key, value] of Object.entries(node)) {
593
+ if (
594
+ key === 'loc' ||
595
+ key === 'start' ||
596
+ key === 'end' ||
597
+ key === 'extra' ||
598
+ key === 'leadingComments' ||
599
+ key === 'trailingComments'
600
+ ) {
601
+ continue;
602
+ }
603
+ if (value && typeof value === 'object') walkNodes(value, visit);
604
+ }
605
+ }
606
+
607
+ /** The extensions a local import may leave off, in the order tried. */
608
+ const LOCAL_MODULE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs'];
609
+
610
+ /**
611
+ * Libraries that render no component of this package or of Core: React's
612
+ * own exports and StyleX. Importing them, even as a namespace
613
+ * (`import * as stylex`), leaves no name unjudged.
614
+ */
615
+ const RENDERLESS_SOURCES = ['react', 'react-dom', '@stylexjs/stylex'];
616
+
617
+ /**
618
+ * Packages a template may import from without leaving its names unjudged:
619
+ * Core and the renderless libraries. Any other package may render components
620
+ * this check does not see, such as a grid that renders its cards.
621
+ */
622
+ const KNOWN_SOURCES = ['@astryxdesign/core', ...RENDERLESS_SOURCES];
623
+
624
+ /** @param {string} source */
625
+ function renderlessSource(source) {
626
+ return RENDERLESS_SOURCES.some(
627
+ known => source === known || source.startsWith(`${known}/`),
628
+ );
629
+ }
630
+
631
+ /**
632
+ * Whether an import source is one whose components this check sees: Core, a
633
+ * library that renders none, or a relative helper (followed and read in full
634
+ * on its own). The package's own name is followed like a helper too.
635
+ * @param {string} source
636
+ */
637
+ function knownSource(source) {
638
+ if (source.startsWith('.')) return true;
639
+ return KNOWN_SOURCES.some(
640
+ known => source === known || source.startsWith(`${known}/`),
641
+ );
642
+ }
643
+
644
+ /**
645
+ * Every name a template imports, as imported and as bound locally, read
646
+ * without running it, with the names its local helper modules import (a
647
+ * relative import or re-export is followed, so a barrel such as
648
+ * `parts/index.ts` counts). A name is judged only where the template reaches
649
+ * components solely through named imports and re-exports this walk reads:
650
+ * `unread` gives the reason for anything else it meets, which leaves an
651
+ * unresolved name unjudged. That covers a default or namespace import, an
652
+ * alias of an imported binding, a member read off one (in JSX or not,
653
+ * optional or computed), a rendered component it cannot trace to an import,
654
+ * `require()`, a dynamic `import()`, an `export *` from another package, and
655
+ * a local module it cannot read. So does any `default` binding (`import X`,
656
+ * `{default as X}`, `export {default as X} from`) and any import from a
657
+ * package other than Core or a library that renders no component (see
658
+ * KNOWN_SOURCES). An import of the package's own name is followed like a
659
+ * relative helper, through its package.json `exports`. Type-only imports
660
+ * render nothing and are skipped, as is a namespace or default import of a
661
+ * library that renders no component.
662
+ * @param {string} file
663
+ * @param {LoadedIntegration} integration
664
+ * @returns {{names: Set<string>, unread: string[]}}
665
+ */
666
+ function importedNames(file, integration) {
667
+ const packageName = integration.name;
668
+ /** @type {Set<string>} */
669
+ const names = new Set();
670
+ /** @type {string[]} */
671
+ const unread = [];
672
+ const top = path.dirname(file);
673
+ const shownTop = path.basename(file);
674
+ /** @type {Set<string>} */
675
+ const seen = new Set();
676
+ /** @param {string} current */
677
+ const visit = current => {
678
+ if (seen.has(current)) return;
679
+ seen.add(current);
680
+ const shown = slash(path.relative(top, current)) || shownTop;
681
+ let ast;
682
+ try {
683
+ ast = parseModule(fs.readFileSync(current, 'utf8'), {
684
+ sourceType: 'module',
685
+ plugins: ['typescript', 'jsx'],
686
+ errorRecovery: true,
687
+ });
688
+ } catch {
689
+ unread.push(
690
+ `${shown} could not be read, so the components it renders are not known.`,
691
+ );
692
+ return;
693
+ }
694
+ // A file that parses only with errors recovered may be missing parts.
695
+ if (/** @type {any} */ (ast).errors?.length > 0) {
696
+ unread.push(
697
+ `${shown} does not parse cleanly, so the components it renders are not known.`,
698
+ );
699
+ }
700
+ /** @type {Map<string, string>} local binding -> the module it came from */
701
+ const bindings = new Map();
702
+ for (const node of ast.program.body) {
703
+ const reexport =
704
+ (node.type === 'ExportNamedDeclaration' && node.source != null) ||
705
+ node.type === 'ExportAllDeclaration';
706
+ if (node.type !== 'ImportDeclaration' && !reexport) continue;
707
+ // A type-only import or re-export renders nothing.
708
+ if (
709
+ /** @type {any} */ (node).importKind === 'type' ||
710
+ /** @type {any} */ (node).exportKind === 'type'
711
+ ) {
712
+ continue;
713
+ }
714
+ const source = String(/** @type {any} */ (node).source.value);
715
+ // The package's own name: read like a relative helper, below.
716
+ const own =
717
+ typeof packageName === 'string' &&
718
+ packageName !== '' &&
719
+ (source === packageName || source.startsWith(`${packageName}/`));
720
+ if (!own && !knownSource(source)) {
721
+ unread.push(
722
+ `${shown} imports from "${source}", another package whose components may render ones this check does not see.`,
723
+ );
724
+ }
725
+ if (node.type === 'ImportDeclaration') {
726
+ for (const specifier of node.specifiers) {
727
+ if (/** @type {any} */ (specifier).importKind === 'type') continue;
728
+ if (specifier.type === 'ImportNamespaceSpecifier') {
729
+ // `import * as stylex`: a library that renders no component.
730
+ if (renderlessSource(source)) continue;
731
+ unread.push(
732
+ `${shown} imports "${source}" as a namespace (${specifier.local.name}), whose component names this check does not read.`,
733
+ );
734
+ continue;
735
+ }
736
+ if (
737
+ specifier.type === 'ImportDefaultSpecifier' &&
738
+ !renderlessSource(source)
739
+ ) {
740
+ // A default may be any component, under any name.
741
+ unread.push(
742
+ `${shown} imports the default of "${source}" as ${specifier.local.name}, which may be any component.`,
743
+ );
744
+ }
745
+ names.add(specifier.local.name);
746
+ bindings.set(specifier.local.name, source);
747
+ if (specifier.type === 'ImportSpecifier') {
748
+ const imported = specifier.imported;
749
+ const importedName =
750
+ imported.type === 'Identifier' ? imported.name : imported.value;
751
+ if (importedName === 'default') {
752
+ unread.push(
753
+ `${shown} imports the default of "${source}" as ${specifier.local.name}, which may be any component.`,
754
+ );
755
+ } else {
756
+ names.add(importedName);
757
+ }
758
+ }
759
+ }
760
+ } else if (node.type === 'ExportNamedDeclaration') {
761
+ // A barrel: `export {Body} from './Body'` passes Body on, and what
762
+ // Body renders comes from the module it names.
763
+ for (const each of node.specifiers) {
764
+ const specifier = /** @type {any} */ (each);
765
+ if (specifier.exportKind === 'type') continue;
766
+ if (specifier.type !== 'ExportSpecifier') {
767
+ // `export * as UI from`, `export UI from`.
768
+ unread.push(
769
+ `${shown} re-exports "${source}" as a whole (${specifier.exported.type === 'Identifier' ? specifier.exported.name : specifier.exported.value}), whose component names this check does not read.`,
770
+ );
771
+ continue;
772
+ }
773
+ const local =
774
+ specifier.local.type === 'Identifier'
775
+ ? specifier.local.name
776
+ : specifier.local.value;
777
+ const exported =
778
+ specifier.exported.type === 'Identifier'
779
+ ? specifier.exported.name
780
+ : specifier.exported.value;
781
+ if (local === 'default') {
782
+ unread.push(
783
+ `${shown} re-exports the default of "${source}" as ${exported}, which may be any component.`,
784
+ );
785
+ } else {
786
+ names.add(local);
787
+ }
788
+ names.add(exported);
789
+ }
790
+ } else if (!source.startsWith('.') && !own) {
791
+ // `export * from` another package passes on names this check
792
+ // does not list.
793
+ unread.push(
794
+ `${shown} re-exports everything from "${source}", whose component names this check does not read.`,
795
+ );
796
+ continue;
797
+ } else if (/** @type {any} */ (node).exported) {
798
+ // `export * as UI from './parts'`.
799
+ unread.push(
800
+ `${shown} re-exports "${source}" as a whole, whose component names this check does not read.`,
801
+ );
802
+ }
803
+ if (!source.startsWith('.') && !own) continue;
804
+ if (own) {
805
+ const resolved = componentDiscovery.ownPackageModule(
806
+ integration,
807
+ source,
808
+ );
809
+ /** @type {boolean} */
810
+ let isFile;
811
+ try {
812
+ isFile = resolved != null && fs.statSync(resolved).isFile();
813
+ } catch {
814
+ isFile = false;
815
+ }
816
+ if (
817
+ resolved &&
818
+ isFile &&
819
+ LOCAL_MODULE_EXTENSIONS.includes(path.extname(resolved))
820
+ ) {
821
+ visit(resolved);
822
+ } else {
823
+ unread.push(
824
+ `${shown} imports "${source}", this package's own name, which resolves to no source this check can read.`,
825
+ );
826
+ }
827
+ continue;
828
+ }
829
+ const base = path.resolve(path.dirname(current), source);
830
+ const target = [
831
+ base,
832
+ ...LOCAL_MODULE_EXTENSIONS.map(extension => `${base}${extension}`),
833
+ ...LOCAL_MODULE_EXTENSIONS.map(extension =>
834
+ path.join(base, `index${extension}`),
835
+ ),
836
+ ].find(candidate => {
837
+ try {
838
+ return fs.statSync(candidate).isFile();
839
+ } catch {
840
+ return false;
841
+ }
842
+ });
843
+ if (!target) {
844
+ unread.push(
845
+ `${shown} imports "${source}", which resolves to no file this check can read.`,
846
+ );
847
+ } else if (LOCAL_MODULE_EXTENSIONS.includes(path.extname(target))) {
848
+ visit(target);
849
+ }
850
+ }
851
+ // Anything this check does not model leaves the names unjudged: what
852
+ // the module renders through a binding, an alias, a member read, a
853
+ // component held in a variable, or a module loaded at runtime.
854
+ /** @type {Set<string>} functions and classes the module declares */
855
+ const declared = new Set();
856
+ walkNodes(ast.program, node => {
857
+ if (
858
+ (node.type === 'FunctionDeclaration' ||
859
+ node.type === 'ClassDeclaration') &&
860
+ node.id?.name
861
+ ) {
862
+ declared.add(node.id.name);
863
+ } else if (
864
+ node.type === 'VariableDeclarator' &&
865
+ node.id?.type === 'Identifier' &&
866
+ [
867
+ 'ArrowFunctionExpression',
868
+ 'FunctionExpression',
869
+ 'ClassExpression',
870
+ ].includes(node.init?.type)
871
+ ) {
872
+ declared.add(node.id.name);
873
+ }
874
+ });
875
+ const from = (/** @type {string} */ name) =>
876
+ bindings.has(name) ? ` (imported from "${bindings.get(name)}")` : '';
877
+ walkNodes(ast.program, node => {
878
+ if (node.type === 'JSXMemberExpression') {
879
+ let object = node.object;
880
+ while (object?.type === 'JSXMemberExpression') object = object.object;
881
+ const name = object?.name ?? 'an object';
882
+ unread.push(
883
+ `${shown} renders members of ${name}${from(name)}, whose names this check does not read.`,
884
+ );
885
+ } else if (
886
+ node.type === 'JSXOpeningElement' &&
887
+ node.name?.type === 'JSXIdentifier' &&
888
+ /^[A-Z]/u.test(node.name.name) &&
889
+ !names.has(node.name.name) &&
890
+ !declared.has(node.name.name)
891
+ ) {
892
+ unread.push(
893
+ `${shown} renders ${node.name.name}, a value this check does not trace to an import.`,
894
+ );
895
+ } else if (
896
+ node.type === 'VariableDeclarator' &&
897
+ node.init?.type === 'Identifier' &&
898
+ bindings.has(node.init.name)
899
+ ) {
900
+ // `const UI = Lib`, `const {Panel} = Lib`: an alias of an import.
901
+ if (node.id?.type === 'Identifier') {
902
+ bindings.set(
903
+ node.id.name,
904
+ /** @type {string} */ (bindings.get(node.init.name)),
905
+ );
906
+ }
907
+ unread.push(
908
+ `${shown} reads ${node.init.name}${from(node.init.name)} into another name, which this check does not follow.`,
909
+ );
910
+ } else if (
911
+ (node.type === 'MemberExpression' ||
912
+ node.type === 'OptionalMemberExpression') &&
913
+ node.object?.type === 'Identifier' &&
914
+ bindings.has(node.object.name)
915
+ ) {
916
+ // `UI.Panel`, `UI?.Panel`, `UI['Panel']`, `createElement(UI.Panel)`.
917
+ unread.push(
918
+ `${shown} reads members of ${node.object.name}${from(node.object.name)}, whose names this check does not read.`,
919
+ );
920
+ } else if (
921
+ node.type === 'CallExpression' &&
922
+ node.callee?.type === 'Identifier' &&
923
+ node.callee.name === 'require'
924
+ ) {
925
+ const argument = node.arguments?.[0];
926
+ const spec =
927
+ argument?.type === 'StringLiteral'
928
+ ? `"${argument.value}"`
929
+ : 'a module';
930
+ unread.push(
931
+ `${shown} loads ${spec} with require(), whose component names this check does not read.`,
932
+ );
933
+ } else if (
934
+ node.type === 'ImportExpression' ||
935
+ (node.type === 'CallExpression' && node.callee?.type === 'Import')
936
+ ) {
937
+ const argument = node.source ?? node.arguments?.[0];
938
+ const spec =
939
+ argument?.type === 'StringLiteral'
940
+ ? `"${argument.value}"`
941
+ : 'a module';
942
+ unread.push(
943
+ `${shown} loads ${spec} with a dynamic import(), whose component names this check does not read.`,
944
+ );
945
+ }
946
+ });
947
+ };
948
+ visit(file);
949
+ return {names, unread};
950
+ }
951
+
952
+ /**
953
+ * The local name a module's default export declares (`export default
954
+ * function AcmeWidget()`, `export default class AcmeWidget`), or of the
955
+ * binding it exports as default (`export default AcmeWidget`), or null.
956
+ * @param {string} file
957
+ * @returns {string | null}
958
+ */
959
+ function defaultBinding(file) {
960
+ let ast;
961
+ try {
962
+ ast = parseModule(fs.readFileSync(file, 'utf8'), {
963
+ sourceType: 'module',
964
+ plugins: ['typescript', 'jsx'],
965
+ errorRecovery: true,
966
+ });
967
+ } catch {
968
+ return null;
969
+ }
970
+ for (const node of ast.program.body) {
971
+ if (node.type !== 'ExportDefaultDeclaration') continue;
972
+ const declaration = /** @type {any} */ (node.declaration);
973
+ if (typeof declaration?.id?.name === 'string') return declaration.id.name;
974
+ if (declaration?.type === 'Identifier') return declaration.name;
975
+ }
976
+ return null;
977
+ }
978
+
979
+ /**
980
+ * A note: information beside validate's issues that fails nothing, with the
981
+ * fix in its message.
982
+ * @typedef {{code: string, message: string}} Note
983
+ */
984
+
985
+ /**
986
+ * @param {string} code
987
+ * @param {{message: string}} finding
988
+ * @returns {Note}
989
+ */
990
+ function note(code, finding) {
991
+ return {code, message: finding.message};
992
+ }
993
+
994
+ /**
995
+ * The block component names this check could not judge, shaped like the
996
+ * doc type report so `integration check` counts them as not compared: each
997
+ * name a template gives in `exampleFor` or `componentsUsed` that neither the
998
+ * package nor Core has, when the template reaches components through
999
+ * anything but named imports and re-exports from the package, Core, or
1000
+ * helpers read in full (see importedNames), or when Core is not installed to
1001
+ * look it up in; its findings are always empty. A name judged unknown is an
1002
+ * `unknown_block_component` note, with its fix in the message: validate
1003
+ * lists notes beside its issues, and nothing counts them, so `--strict` does
1004
+ * not fail on it until the next minor release (architecture:cli-surface
1005
+ * INV35).
1006
+ * @param {LoadedIntegration} integration
1007
+ * @returns {Promise<{report: {status: 'checked', findings: [], unchecked: Array<{file: string, name: string, reason: string}>}, notes: Note[]}>}
1008
+ */
1009
+ export async function blockComponentsCheck(integration) {
1010
+ const {findings, unchecked} = await judgeBlockComponents(integration);
1011
+ const base = integration.__packageDir ?? integration.templates ?? '';
1012
+ return {
1013
+ report: {
1014
+ status: 'checked',
1015
+ findings: [],
1016
+ unchecked: unchecked.map(entry => ({
1017
+ file: slash(path.relative(base, entry.docPath)),
1018
+ name: entry.name,
1019
+ reason: entry.reason,
1020
+ })),
1021
+ },
1022
+ notes: findings.map(entry => note('unknown_block_component', entry)),
1023
+ };
1024
+ }
1025
+
307
1026
  /**
308
1027
  * Warn about a `.tsx` directly in the templates root with no same-stem template
309
1028
  * metadata: template discovery pairs sources with docs, so `astryx template`
@@ -340,6 +1059,8 @@ async function checkComponents(integration, issues) {
340
1059
  if (!integration.components || rootProblem(integration.components)) return;
341
1060
  const discover = componentDiscovery.discoverValidIntegrationComponents;
342
1061
  if (typeof discover !== 'function') return; // feature not present yet
1062
+ const root = /** @type {string} */ (integration.components);
1063
+ const files = componentDocFiles(root);
343
1064
  try {
344
1065
  const {discovered, errors} = await discover(integration);
345
1066
  /** @type {import('./contribution-fixes.mjs').FixContext | undefined} */
@@ -352,13 +1073,75 @@ async function checkComponents(integration, issues) {
352
1073
  continue;
353
1074
  }
354
1075
  const docPath = record.docPath;
355
- let fix = `Fix: add ${record.name}.tsx beside ${docPath ? path.basename(docPath) : `${record.name}.doc.mjs`}.`;
1076
+ // Reported once, below, as the case mismatch it is.
1077
+ if (docPath && files.caseMismatch.has(docPath)) continue;
1078
+ const docName = docPath
1079
+ ? path.basename(docPath)
1080
+ : `${record.name}.doc.mjs`;
1081
+ let message = error.message;
1082
+ let fix = `Fix: add ${record.name}.tsx beside ${docName}.`;
356
1083
  const stamp = docPath ? readContributionStamp(docPath, false) : null;
357
1084
  if (stamp && stamp.type !== 'component' && integration.__packageDir) {
358
1085
  context ??= createFixContext(integration.__packageDir, integration);
359
1086
  fix = notAComponentFix(context, docPath, stamp);
1087
+ } else {
1088
+ const other = docPath ? otherSourceFor(docPath) : null;
1089
+ if (other) {
1090
+ message = `${message.replace(/\.$/u, '')}: found ${other}, but a component source must be a .tsx file.`;
1091
+ fix = `Fix: rename ${other} to ${record.name}.tsx (and point its package export at the new file).`;
1092
+ }
1093
+ }
1094
+ issues.push(issueError('invalid_component', `${message} ${fix}`));
1095
+ }
1096
+ /** @type {Set<string>} sources a case-only rename is reported for */
1097
+ const reportedSources = new Set();
1098
+ for (const [docPath, source] of files.caseMismatch) {
1099
+ const doc = path.basename(docPath);
1100
+ const renamed = `${source.slice(0, -'.tsx'.length)}${doc.slice(docStemOf(doc).length)}`;
1101
+ const shown = slash(path.relative(root, docPath));
1102
+ const linux = `They pair on a case-insensitive disk (macOS, Windows) but not on Linux, where CI and most installs run`;
1103
+ const fix = `Fix: rename ${doc} to ${renamed}.`;
1104
+ // Where discovery still pairs the doc with its source (a
1105
+ // case-insensitive disk), the component works here: listed, as
1106
+ // validate passed it before. A hidden doc that does not pair leaves its
1107
+ // source without a doc, which the source check below reports as it
1108
+ // always has (architecture:cli-surface INV37).
1109
+ const pairs = componentDiscovery.integrationSourceForDoc(docPath) != null;
1110
+ const hidden = componentDiscovery.isHiddenComponentDoc(docPath);
1111
+ if (hidden && !pairs) continue;
1112
+ reportedSources.add(path.join(path.dirname(docPath), source));
1113
+ if (hidden) {
1114
+ issues.push(
1115
+ issueListed(
1116
+ 'source_without_component_doc',
1117
+ `Hidden component doc "${shown}" and its source "${source}" differ only in letter case. ${linux}. ${fix}`,
1118
+ ),
1119
+ );
1120
+ } else if (pairs) {
1121
+ issues.push(
1122
+ issueListed(
1123
+ 'source_without_component_doc',
1124
+ `Component doc "${shown}" and its source "${source}" differ only in letter case. ${linux}, so there the component is missing. ${fix}`,
1125
+ ),
1126
+ );
1127
+ } else {
1128
+ issues.push(
1129
+ issueError(
1130
+ 'invalid_component',
1131
+ `Component doc "${shown}" and its source "${source}" differ only in letter case. ${linux}, so here the component is missing. ${fix}`,
1132
+ ),
1133
+ );
360
1134
  }
361
- issues.push(issueError('invalid_component', `${error.message} ${fix}`));
1135
+ }
1136
+ for (const {read, ignored} of files.duplicates) {
1137
+ const readName = path.basename(read);
1138
+ const ignoredNames = ignored.map(file => path.basename(file));
1139
+ issues.push(
1140
+ issueListed(
1141
+ 'duplicate_component_doc',
1142
+ `Component "${docStemOf(readName)}" has ${ignored.length + 1} docs: ${[readName, ...ignoredNames].join(', ')}. Astryx reads ${readName} and ignores ${ignoredNames.join(' and ')}. Fix: delete ${ignoredNames.join(' and ')}, moving anything only ${ignored.length === 1 ? 'it says' : 'they say'} into ${readName}.`,
1143
+ ),
1144
+ );
362
1145
  }
363
1146
  const overlap = integration.__packageDir
364
1147
  ? newComponentDocNote(
@@ -372,16 +1155,13 @@ async function checkComponents(integration, issues) {
372
1155
  // A hidden doc still pairs with its source; discovery just skips it.
373
1156
  if (
374
1157
  ['.doc.ts', '.doc.mjs', '.doc.js'].some(suffix =>
375
- fs.existsSync(
376
- path.join(
377
- /** @type {string} */ (integration.components),
378
- `${name}${suffix}`,
379
- ),
380
- ),
1158
+ fs.existsSync(path.join(root, `${name}${suffix}`)),
381
1159
  )
382
1160
  ) {
383
1161
  continue;
384
1162
  }
1163
+ // A doc whose name differs only in case is reported above.
1164
+ if (reportedSources.has(path.join(root, `${name}.tsx`))) continue;
385
1165
  issues.push(
386
1166
  issueWarning(
387
1167
  'source_without_component_doc',
@@ -396,6 +1176,251 @@ async function checkComponents(integration, issues) {
396
1176
  }
397
1177
  }
398
1178
 
1179
+ const COMPONENT_DOC_SUFFIXES = ['.doc.ts', '.doc.mjs', '.doc.js'];
1180
+
1181
+ /** @param {string} file a doc file name */
1182
+ function docStemOf(file) {
1183
+ const suffix = COMPONENT_DOC_SUFFIXES.find(each => file.endsWith(each));
1184
+ return suffix ? file.slice(0, -suffix.length) : file;
1185
+ }
1186
+
1187
+ /**
1188
+ * A same-stem source discovery does not read: `.jsx`, `.js`, `.ts` or `.mjs`.
1189
+ * @param {string} docPath
1190
+ * @returns {string | null} its file name
1191
+ */
1192
+ function otherSourceFor(docPath) {
1193
+ const stem = docStemOf(path.basename(docPath));
1194
+ const dir = path.dirname(docPath);
1195
+ return (
1196
+ ['.jsx', '.js', '.ts', '.mjs']
1197
+ .map(extension => `${stem}${extension}`)
1198
+ .find(file => fs.existsSync(path.join(dir, file))) ?? null
1199
+ );
1200
+ }
1201
+
1202
+ /**
1203
+ * The component docs under a root, read from the real directory listings:
1204
+ * those beside a source whose name differs only in letter case (which an
1205
+ * existence check on a case-insensitive disk would call paired), and stems
1206
+ * with more than one doc, with the doc discovery reads.
1207
+ * @param {string} root
1208
+ */
1209
+ function componentDocFiles(root) {
1210
+ /** @type {Map<string, string>} doc path -> the source's real file name */
1211
+ const caseMismatch = new Map();
1212
+ /** @type {Array<{read: string, ignored: string[]}>} */
1213
+ const duplicates = [];
1214
+ /** @param {string} dir */
1215
+ const walk = dir => {
1216
+ let entries;
1217
+ try {
1218
+ entries = fs.readdirSync(dir, {withFileTypes: true});
1219
+ } catch {
1220
+ return;
1221
+ }
1222
+ const names = new Set(entries.map(entry => entry.name));
1223
+ /** @type {Map<string, string[]>} */
1224
+ const byStem = new Map();
1225
+ for (const entry of entries) {
1226
+ const full = path.join(dir, entry.name);
1227
+ if (entry.isDirectory()) {
1228
+ if (entry.name !== 'node_modules' && entry.name !== '__tests__') {
1229
+ walk(full);
1230
+ }
1231
+ continue;
1232
+ }
1233
+ const stem = docStemOf(entry.name);
1234
+ if (stem === entry.name) continue;
1235
+ byStem.set(stem, [...(byStem.get(stem) ?? []), entry.name]);
1236
+ const source = `${stem}.tsx`;
1237
+ if (names.has(source)) continue;
1238
+ const other = [...names].find(
1239
+ name => name !== source && name.toLowerCase() === source.toLowerCase(),
1240
+ );
1241
+ // The source's own doc, if it has one, is its pair.
1242
+ if (
1243
+ other &&
1244
+ !COMPONENT_DOC_SUFFIXES.some(suffix =>
1245
+ names.has(`${other.slice(0, -'.tsx'.length)}${suffix}`),
1246
+ )
1247
+ ) {
1248
+ caseMismatch.set(full, other);
1249
+ }
1250
+ }
1251
+ for (const docs of byStem.values()) {
1252
+ if (docs.length < 2) continue;
1253
+ const ordered = COMPONENT_DOC_SUFFIXES.map(suffix =>
1254
+ docs.find(doc => doc.endsWith(suffix)),
1255
+ ).filter(doc => doc != null);
1256
+ duplicates.push({
1257
+ read: path.join(dir, /** @type {string} */ (ordered[0])),
1258
+ ignored: ordered
1259
+ .slice(1)
1260
+ .map(doc => path.join(dir, /** @type {string} */ (doc))),
1261
+ });
1262
+ }
1263
+ };
1264
+ walk(root);
1265
+ return {caseMismatch, duplicates};
1266
+ }
1267
+
1268
+ /**
1269
+ * Judge each component doc's `name` against the module its printed import
1270
+ * names: a finding when that module provably does not export it, and an
1271
+ * entry in `unchecked` when it cannot be judged, so it is listed instead.
1272
+ * @param {LoadedIntegration} integration
1273
+ * @param {Array<{name: string, docPath: string, sourcePath: string|null}>} components
1274
+ * @param {string} root the components root
1275
+ * @param {Map<string, string>} skip docs already reported, by path
1276
+ * @param {Array<{docPath: string, name: string, message: string}>} findings
1277
+ * @param {Array<{docPath: string, name: string, reason: string}>} unchecked
1278
+ */
1279
+ async function judgeComponentDocNames(
1280
+ integration,
1281
+ components,
1282
+ root,
1283
+ skip,
1284
+ findings,
1285
+ unchecked,
1286
+ ) {
1287
+ const base = integration.__packageDir ?? root;
1288
+ for (const record of components) {
1289
+ if (componentDiscovery.isIntegrationMember(record)) continue;
1290
+ if (skip.has(record.docPath)) continue;
1291
+ // A doc with no source is its own error.
1292
+ if (record.sourcePath == null) continue;
1293
+ let doc;
1294
+ try {
1295
+ doc = await loadComponentDoc(record.docPath);
1296
+ } catch {
1297
+ continue;
1298
+ }
1299
+ const name = doc?.name;
1300
+ if (typeof name !== 'string' || name === record.name) continue;
1301
+ // The module the printed import names: the doc's import, the published
1302
+ // subpath, or the package root, resolved through package.json "exports".
1303
+ // Without a package to resolve it in, the same-stem source.
1304
+ const printed = componentDiscovery.importedModule(
1305
+ integration,
1306
+ record.docPath,
1307
+ doc,
1308
+ );
1309
+ if (printed === null) {
1310
+ unchecked.push({
1311
+ docPath: record.docPath,
1312
+ name,
1313
+ reason:
1314
+ 'Its import names another package, or resolves to no file this check can read.',
1315
+ });
1316
+ continue;
1317
+ }
1318
+ const module = printed ?? record.sourcePath;
1319
+ const shown = slash(path.relative(base, module));
1320
+ // Only what the code contradicts: the module must provably not export
1321
+ // the name the doc gives.
1322
+ const judged = moduleExportVerdict(module, name);
1323
+ if (judged.verdict === 'exports') continue;
1324
+ if (judged.verdict === 'unknown') {
1325
+ unchecked.push({docPath: record.docPath, name, reason: judged.reason});
1326
+ continue;
1327
+ }
1328
+ // A source whose only export is a default, read because no printed
1329
+ // import could be resolved: the package's entry may publish it under
1330
+ // any name, so the name is not judged. A printed import that names a
1331
+ // default-only module does not export the name, which is a mismatch.
1332
+ const exportedNames = moduleExportNames(module);
1333
+ const defaultOnly =
1334
+ exportedNames != null &&
1335
+ exportedNames.length === 1 &&
1336
+ exportedNames[0] === 'default';
1337
+ if (defaultOnly && printed === undefined) {
1338
+ unchecked.push({
1339
+ docPath: record.docPath,
1340
+ name,
1341
+ reason: `${shown} exports a default, which the package entry may publish under any name.`,
1342
+ });
1343
+ continue;
1344
+ }
1345
+ const file = path.basename(record.docPath);
1346
+ // The name to propose: the file name when the module exports it, else
1347
+ // the one component the module exports.
1348
+ let fix;
1349
+ if (defaultOnly) {
1350
+ fix =
1351
+ defaultBinding(module) === name
1352
+ ? `Fix: add \`export {${name}};\` to ${shown}, so it exports ${name} by name as well, or set the doc's import to the module that publishes it as ${name}.`
1353
+ : `Fix: bind the default to ${name} in ${shown} and export it by name too (\`const ${name} = …; export default ${name}; export {${name}};\`), or set the doc's import to the module that publishes it as ${name}.`;
1354
+ } else if (moduleExportsName(module, record.name)) {
1355
+ fix = `Fix: set name: '${record.name}' in ${file}.`;
1356
+ } else {
1357
+ const exported = (exportedNames ?? []).filter(each =>
1358
+ /^[A-Z][A-Za-z0-9]*$/u.test(each),
1359
+ );
1360
+ fix =
1361
+ exported.length === 1
1362
+ ? `Fix: set name: '${exported[0]}' in ${file}, the component ${shown} exports.`
1363
+ : `Fix: set name in ${file} to the component ${shown} exports${exported.length > 1 ? ` (${exported.join(', ')})` : ''}.`;
1364
+ }
1365
+ findings.push({
1366
+ docPath: record.docPath,
1367
+ name,
1368
+ message: `Component doc "${slash(path.relative(root, record.docPath))}" sets name: '${name}', but ${shown}, the module its import names, does not export ${name}, so Astryx lists the component as "${record.name}" (its file name), and \`astryx component ${record.name}\` prints \`import {${name}}\`. ${fix}`,
1369
+ });
1370
+ }
1371
+ }
1372
+
1373
+ /**
1374
+ * The component doc names the check could not judge, shaped like the doc type
1375
+ * report so `integration check` counts them as not compared: each doc whose
1376
+ * `name` differs from the name it is listed under and whose source's exports
1377
+ * cannot all be read without running it; its findings are always empty. A
1378
+ * name the module its printed import names provably lacks is a
1379
+ * `doc_name_mismatch` note, with its fix in the message: validate lists
1380
+ * notes beside its issues, and nothing counts them, so `--strict` does not
1381
+ * fail on it until the next minor release (architecture:cli-surface INV35).
1382
+ * @param {LoadedIntegration} integration
1383
+ * @returns {Promise<{report: {status: 'checked', findings: [], unchecked: Array<{file: string, name: string, reason: string}>}, notes: Note[]}>}
1384
+ */
1385
+ export async function componentDocNamesCheck(integration) {
1386
+ /** @type {Array<{docPath: string, name: string, message: string}>} */
1387
+ const findings = [];
1388
+ /** @type {Array<{docPath: string, name: string, reason: string}>} */
1389
+ const unchecked = [];
1390
+ const root = integration.components;
1391
+ if (root && !rootProblem(root)) {
1392
+ try {
1393
+ const {components} =
1394
+ await componentDiscovery.discoverValidIntegrationComponents(
1395
+ integration,
1396
+ );
1397
+ await judgeComponentDocNames(
1398
+ integration,
1399
+ components,
1400
+ root,
1401
+ componentDocFiles(root).caseMismatch,
1402
+ findings,
1403
+ unchecked,
1404
+ );
1405
+ } catch {
1406
+ // A components root discovery cannot read is reported as an issue.
1407
+ }
1408
+ }
1409
+ const base = integration.__packageDir ?? root ?? '';
1410
+ return {
1411
+ report: {
1412
+ status: 'checked',
1413
+ findings: [],
1414
+ unchecked: unchecked.map(entry => ({
1415
+ file: slash(path.relative(base, entry.docPath)),
1416
+ name: entry.name,
1417
+ reason: entry.reason,
1418
+ })),
1419
+ },
1420
+ notes: findings.map(entry => note('doc_name_mismatch', entry)),
1421
+ };
1422
+ }
1423
+
399
1424
  /**
400
1425
  * Validate the integration's doc topics via the landed discovery. A doc that
401
1426
  * cannot be loaded, is not a usable topic, or collides with a sibling is
@@ -431,18 +1456,123 @@ async function checkDocs(integration, issues) {
431
1456
  async function checkThemes(integration, issues) {
432
1457
  if (!integration.themes || rootProblem(integration.themes)) return;
433
1458
  try {
1459
+ // A theme may name itself differently from its folder (its built files
1460
+ // then carry that name), so a name that differs is no finding here:
1461
+ // verify judges it against the files the package exports.
434
1462
  await discoverIntegrationThemes(integration);
435
1463
  } catch (err) {
436
1464
  issues.push(issueError('invalid_theme', /** @type {any} */ (err).message));
437
1465
  }
438
1466
  }
439
1467
 
1468
+ /**
1469
+ * The `name` a theme source passes to defineTheme for its exported theme,
1470
+ * when the file says it as a string literal: `export const oceanTheme =
1471
+ * defineTheme({name: 'ocean', ...})`. Null whenever only running the module
1472
+ * could tell (a variable, a factory, a spread after the name, `extends`
1473
+ * alone), so nothing is judged that a build would decide.
1474
+ * @param {string} file
1475
+ * @param {string} exportName
1476
+ * @returns {string | null}
1477
+ */
1478
+ export function literalThemeName(file, exportName) {
1479
+ let ast;
1480
+ try {
1481
+ ast = parseModule(fs.readFileSync(file, 'utf8'), {
1482
+ sourceType: 'module',
1483
+ plugins: ['typescript', 'jsx'],
1484
+ errorRecovery: true,
1485
+ });
1486
+ } catch {
1487
+ return null;
1488
+ }
1489
+ /** @type {any} */
1490
+ let init = null;
1491
+ for (const node of ast.program.body) {
1492
+ const declaration =
1493
+ node.type === 'ExportNamedDeclaration' ? node.declaration : node;
1494
+ if (declaration?.type !== 'VariableDeclaration') continue;
1495
+ for (const declarator of declaration.declarations) {
1496
+ if (
1497
+ declarator.id.type === 'Identifier' &&
1498
+ declarator.id.name === exportName
1499
+ ) {
1500
+ init = declarator.init;
1501
+ }
1502
+ }
1503
+ }
1504
+ while (
1505
+ init?.type === 'TSAsExpression' ||
1506
+ init?.type === 'TSSatisfiesExpression'
1507
+ ) {
1508
+ init = init.expression;
1509
+ }
1510
+ if (init?.type !== 'CallExpression') return null;
1511
+ const callee =
1512
+ init.callee.type === 'Identifier'
1513
+ ? init.callee.name
1514
+ : init.callee.type === 'MemberExpression' &&
1515
+ init.callee.property.type === 'Identifier'
1516
+ ? init.callee.property.name
1517
+ : null;
1518
+ if (callee !== 'defineTheme') return null;
1519
+ const [config] = init.arguments;
1520
+ if (config?.type !== 'ObjectExpression') return null;
1521
+ /** @type {string | null} */
1522
+ let name = null;
1523
+ for (const property of config.properties) {
1524
+ if (property.type === 'SpreadElement') {
1525
+ name = null;
1526
+ continue;
1527
+ }
1528
+ if (property.type !== 'ObjectProperty' || property.computed) continue;
1529
+ const key =
1530
+ property.key.type === 'Identifier'
1531
+ ? property.key.name
1532
+ : property.key.type === 'StringLiteral'
1533
+ ? property.key.value
1534
+ : null;
1535
+ if (key !== 'name') continue;
1536
+ name =
1537
+ property.value.type === 'StringLiteral' ? property.value.value : null;
1538
+ }
1539
+ return name;
1540
+ }
1541
+
1542
+ /**
1543
+ * A warning for each contribution file a reader reads (the docs and sources
1544
+ * `integration verify` expects in the tarball) that the user running astryx
1545
+ * cannot read; files no reader takes, such as `.DS_Store`, are not looked at: Astryx cannot check it, and npm pack stops on it. A
1546
+ * warning, so validate keeps its exit code; `integration verify` fails on the
1547
+ * pack itself.
1548
+ * @param {LoadedIntegration} integration
1549
+ * @param {Issue[]} issues
1550
+ */
1551
+ function checkUnreadableFiles(integration, issues) {
1552
+ const packageDir = integration.__packageDir;
1553
+ if (!packageDir) return;
1554
+ for (const shown of contributionFiles(integration, packageDir)) {
1555
+ try {
1556
+ fs.accessSync(path.join(packageDir, shown), fs.constants.R_OK);
1557
+ } catch (err) {
1558
+ const code = String(/** @type {any} */ (err)?.code ?? 'unknown error');
1559
+ issues.push(
1560
+ issueListed(
1561
+ 'unreadable_file',
1562
+ `Could not read "${shown}" (${code}), so Astryx cannot check it and npm pack fails on it. Fix: make it readable (chmod a+r ${shown}), or delete it if it does not belong in the package.`,
1563
+ ),
1564
+ );
1565
+ }
1566
+ }
1567
+ }
1568
+
440
1569
  /**
441
1570
  * Run every contribution validator against a loaded-integration-shaped object.
442
1571
  * @param {LoadedIntegration} integration
443
1572
  * @param {Issue[]} issues
444
1573
  */
445
1574
  async function runContributionChecks(integration, issues) {
1575
+ checkUnreadableFiles(integration, issues);
446
1576
  await checkCodemods(integration, issues);
447
1577
  await checkTemplates(integration, issues);
448
1578
  await checkComponents(integration, issues);
@@ -461,6 +1591,29 @@ async function runContributionChecks(integration, issues) {
461
1591
  * @returns {Promise<Issue[]>}
462
1592
  */
463
1593
  export async function validateLoadedIntegration(loaded) {
1594
+ return (await validateLoadedIntegrationListed(loaded)).issues;
1595
+ }
1596
+
1597
+ /**
1598
+ * Every contribution check, split: `issues` as validateLoadedIntegration
1599
+ * returns them, and `listed`, the layout findings validate lists in its
1600
+ * `layout` report without counting them.
1601
+ * @param {LoadedIntegration} loaded
1602
+ * @returns {Promise<{issues: Issue[], listed: Issue[]}>}
1603
+ */
1604
+ export async function validateLoadedIntegrationListed(loaded) {
1605
+ const all = await allLoadedIntegrationIssues(loaded);
1606
+ return {
1607
+ issues: all.filter(issue => !LISTED.has(issue)),
1608
+ listed: all.filter(issue => LISTED.has(issue)),
1609
+ };
1610
+ }
1611
+
1612
+ /**
1613
+ * @param {LoadedIntegration} loaded
1614
+ * @returns {Promise<Issue[]>}
1615
+ */
1616
+ async function allLoadedIntegrationIssues(loaded) {
464
1617
  /** @type {Issue[]} */
465
1618
  const issues = [];
466
1619
  if (!loaded || typeof loaded !== 'object') return issues;
@@ -497,8 +1650,10 @@ export async function validateLoadedIntegration(loaded) {
497
1650
  codemods: loaded.codemods,
498
1651
  docs: loaded.docs,
499
1652
  themes: loaded.themes,
1653
+ __manifestFile: loaded.__manifestFile,
500
1654
  },
501
1655
  issues,
1656
+ loaded.__packageDir,
502
1657
  );
503
1658
  await runContributionChecks(loaded, issues);
504
1659
  return issues;