@astryxdesign/cli 0.6.4 → 0.6.5-canary.031021b

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 (288) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +97 -90
  3. package/api/build/build.doc.mjs +6 -1
  4. package/api/build/build.test.mjs +22 -0
  5. package/api/build/kit/kit.mjs +44 -5
  6. package/api/component/_adapter.d.mts +25 -0
  7. package/api/component/_adapter.mjs +59 -5
  8. package/api/component/component.d.mts +6 -3
  9. package/api/component/component.doc.mjs +37 -17
  10. package/api/component/component.mjs +249 -9
  11. package/api/component/component.type.d.mts +25 -0
  12. package/api/component/component.type.mjs +44 -0
  13. package/api/discover/_adapter.d.mts +114 -6
  14. package/api/discover/_adapter.mjs +372 -17
  15. package/api/discover/_adapter.test.mjs +215 -0
  16. package/api/discover/_catalog-view.d.mts +115 -0
  17. package/api/discover/_catalog-view.mjs +203 -0
  18. package/api/discover/_catalog-view.test.mjs +128 -0
  19. package/api/discover/detail/detail.d.mts +18 -6
  20. package/api/discover/detail/detail.mjs +67 -13
  21. package/api/discover/detail/detail.test.mjs +85 -0
  22. package/api/discover/detail/item/item.d.mts +26 -0
  23. package/api/discover/detail/item/item.mjs +78 -0
  24. package/api/discover/detail/item/item.test.mjs +73 -0
  25. package/api/discover/discover.d.mts +3 -9
  26. package/api/discover/discover.doc.mjs +61 -18
  27. package/api/discover/discover.mjs +220 -36
  28. package/api/discover/discover.test.mjs +11 -2
  29. package/api/discover/discover.type.d.mts +147 -8
  30. package/api/discover/discover.type.mjs +102 -12
  31. package/api/discover/list/list.d.mts +20 -6
  32. package/api/discover/list/list.mjs +45 -12
  33. package/api/discover/list/list.test.mjs +46 -0
  34. package/api/discover/search/search.d.mts +18 -16
  35. package/api/discover/search/search.mjs +102 -56
  36. package/api/discover/search/search.test.mjs +144 -10
  37. package/api/docs/_adapter.d.mts +8 -3
  38. package/api/docs/_adapter.mjs +14 -6
  39. package/api/docs/docOverlays.test.mjs +27 -1
  40. package/api/docs/docs.doc.mjs +2 -2
  41. package/api/doctor/doctor.d.mts +8 -3
  42. package/api/doctor/doctor.doc.mjs +17 -8
  43. package/api/doctor/doctor.mjs +90 -9
  44. package/api/doctor/doctor.test.mjs +122 -10
  45. package/api/doctor/doctor.type.d.mts +1 -1
  46. package/api/doctor/doctor.type.mjs +1 -1
  47. package/api/gap-report/gap-report.doc.mjs +19 -10
  48. package/api/hook/hook.doc.mjs +6 -3
  49. package/api/index.d.mts +1 -0
  50. package/api/index.mjs +5 -3
  51. package/api/init/init.doc.mjs +17 -12
  52. package/api/integration/add-helpers.d.mts +5 -2
  53. package/api/integration/add-helpers.mjs +36 -9
  54. package/api/integration/add-theme.mjs +22 -1
  55. package/api/integration/add-theme.test.mjs +34 -0
  56. package/api/integration/authoring-checks.mjs +2 -2
  57. package/api/integration/integrationPackCheck.doc.mjs +3 -3
  58. package/api/integration/pack-check.lifecycle-output.test.mjs +107 -0
  59. package/api/integration/pack-check.mjs +82 -9
  60. package/api/integration/pack-check.test.mjs +90 -0
  61. package/api/integration/pack-check.type.mjs +1 -1
  62. package/api/json/assertResponse.doc.mjs +1 -1
  63. package/api/json/isError.doc.mjs +1 -1
  64. package/api/search/search.d.mts +27 -1
  65. package/api/search/search.doc.mjs +2 -2
  66. package/api/search/search.mjs +228 -16
  67. package/api/swizzle/swizzle.doc.mjs +7 -5
  68. package/api/template/copy/copy.mjs +1 -1
  69. package/api/template/copy/copy.test.mjs +9 -0
  70. package/api/template/template.doc.mjs +2 -1
  71. package/api/theme/add/add.mjs +17 -25
  72. package/api/theme/add/add.rollback.test.mjs +158 -0
  73. package/api/theme/add/add.staging.test.mjs +40 -23
  74. package/api/theme/build/build.family.test.mjs +7 -12
  75. package/api/theme/build/build.mjs +8 -18
  76. package/api/theme/build/build.rollback.test.mjs +148 -0
  77. package/api/theme/generateTonalPalette.doc.mjs +1 -2
  78. package/api/theme/listThemes.doc.mjs +1 -1
  79. package/api/theme/themeAdd.doc.mjs +9 -10
  80. package/api/theme/themeBuild.doc.mjs +13 -13
  81. package/api/theme/themeList.doc.mjs +1 -1
  82. package/api/theme/themeListAvailable.doc.mjs +2 -1
  83. package/api/theme/themePaletteGenerate.doc.mjs +15 -8
  84. package/api/theme/themeTargets.doc.mjs +3 -2
  85. package/api/theme/themeTemplate.doc.mjs +2 -1
  86. package/api/upgrade/run/files-changed.test.mjs +111 -0
  87. package/api/upgrade/run/run.mjs +5 -3
  88. package/api/upgrade/upgrade.doc.mjs +24 -22
  89. package/api/upgrade/upgrade.type.mjs +2 -2
  90. package/assets/codemods/__tests__/runner.test.mjs +3 -1
  91. package/assets/codemods/file-count.test.mjs +163 -0
  92. package/assets/codemods/integration-runner.mjs +3 -3
  93. package/assets/codemods/runner.mjs +5 -4
  94. package/assets/docs/README.md +4 -2
  95. package/assets/docs/browser-support.doc.mjs +11 -11
  96. package/assets/docs/color.doc.mjs +8 -2
  97. package/assets/docs/elevation.doc.mjs +6 -4
  98. package/assets/docs/getting-started.doc.mjs +5 -16
  99. package/assets/docs/icons.doc.mjs +2 -21
  100. package/assets/docs/illustrations.doc.mjs +7 -15
  101. package/assets/docs/internationalization.doc.mjs +7 -5
  102. package/assets/docs/layout.doc.dense.mjs +130 -82
  103. package/assets/docs/layout.doc.mjs +133 -77
  104. package/assets/docs/migration.doc.mjs +19 -21
  105. package/assets/docs/motion.doc.mjs +16 -3
  106. package/assets/docs/principles.doc.dense.mjs +5 -5
  107. package/assets/docs/principles.doc.mjs +8 -0
  108. package/assets/docs/principles.doc.zh.mjs +6 -6
  109. package/assets/docs/shape.doc.mjs +8 -3
  110. package/assets/docs/spacing.doc.mjs +7 -2
  111. package/assets/docs/styling-libraries.doc.mjs +6 -2
  112. package/assets/docs/styling.doc.mjs +19 -23
  113. package/assets/docs/theme.doc.dense.mjs +58 -18
  114. package/assets/docs/theme.doc.mjs +57 -47
  115. package/assets/docs/theme.doc.zh.mjs +9 -8
  116. package/assets/docs/tokens.doc.dense.mjs +2 -2
  117. package/assets/docs/tokens.doc.mjs +389 -8
  118. package/assets/docs/tokens.doc.zh.mjs +2 -2
  119. package/assets/docs/tree/add-a-component.doc.mjs +75 -0
  120. package/assets/docs/tree/add-a-theme.doc.mjs +85 -0
  121. package/assets/docs/tree/add-a-topic.doc.mjs +144 -0
  122. package/assets/docs/tree/agent-guidance.doc.mjs +138 -0
  123. package/assets/docs/tree/block-template.doc.mjs +130 -0
  124. package/assets/docs/tree/build-the-template.doc.mjs +28 -0
  125. package/assets/docs/tree/building-blocks.doc.mjs +46 -0
  126. package/assets/docs/tree/check-your-docs.doc.mjs +137 -0
  127. package/assets/docs/tree/checks.doc.mjs +119 -0
  128. package/assets/docs/tree/codemods.doc.mjs +147 -0
  129. package/assets/docs/tree/component-family.doc.mjs +113 -0
  130. package/assets/docs/tree/component-imports.doc.mjs +69 -0
  131. package/assets/docs/tree/component-lookups.doc.mjs +149 -0
  132. package/assets/docs/tree/components.doc.mjs +23 -0
  133. package/assets/docs/tree/configuration.doc.mjs +23 -0
  134. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +182 -0
  135. package/assets/docs/tree/define-the-theme.doc.mjs +118 -0
  136. package/assets/docs/tree/describe-the-component.doc.mjs +57 -0
  137. package/assets/docs/tree/docs.doc.mjs +21 -0
  138. package/assets/docs/tree/document-the-template.doc.mjs +28 -0
  139. package/assets/docs/tree/document-the-theme.doc.mjs +68 -0
  140. package/assets/docs/tree/export-template-assets.doc.mjs +147 -0
  141. package/assets/docs/tree/extend-or-replace.doc.mjs +103 -0
  142. package/assets/docs/tree/fonts-and-assets.doc.mjs +106 -0
  143. package/assets/docs/tree/generate-a-palette.doc.mjs +66 -0
  144. package/assets/docs/tree/grade-template-with-agent.doc.mjs +105 -0
  145. package/assets/docs/tree/help.doc.mjs +16 -0
  146. package/assets/docs/tree/integrations.doc.mjs +25 -451
  147. package/assets/docs/tree/links.doc.mjs +98 -0
  148. package/assets/docs/tree/package-and-test.doc.mjs +32 -0
  149. package/assets/docs/tree/page-template.doc.mjs +71 -0
  150. package/assets/docs/tree/publishing.doc.mjs +111 -0
  151. package/assets/docs/tree/quick-start.doc.mjs +272 -0
  152. package/assets/docs/tree/replace-a-core-component.doc.mjs +104 -0
  153. package/assets/docs/tree/replace-a-core-template.doc.mjs +172 -0
  154. package/assets/docs/tree/sections-and-placement.doc.mjs +108 -0
  155. package/assets/docs/tree/see-it-in-an-app.doc.mjs +59 -0
  156. package/assets/docs/tree/ship.doc.mjs +16 -0
  157. package/assets/docs/tree/short-and-findable.doc.mjs +108 -0
  158. package/assets/docs/tree/single-component.doc.mjs +165 -0
  159. package/assets/docs/tree/start-a-template.doc.mjs +143 -0
  160. package/assets/docs/tree/subcomponent.doc.mjs +115 -0
  161. package/assets/docs/tree/template-assets.doc.mjs +64 -0
  162. package/assets/docs/tree/template-doc-overview.doc.mjs +109 -0
  163. package/assets/docs/tree/template-fonts.doc.mjs +102 -0
  164. package/assets/docs/tree/template-grading-rubric.doc.mjs +452 -0
  165. package/assets/docs/tree/template-icons.doc.mjs +97 -0
  166. package/assets/docs/tree/template-images-media.doc.mjs +127 -0
  167. package/assets/docs/tree/template-styles.doc.mjs +93 -0
  168. package/assets/docs/tree/templates.doc.mjs +34 -0
  169. package/assets/docs/tree/test-in-an-app.doc.mjs +115 -0
  170. package/assets/docs/tree/test-template-in-app.doc.mjs +128 -0
  171. package/assets/docs/tree/themes.doc.mjs +39 -0
  172. package/assets/docs/tree/troubleshooting.doc.mjs +149 -0
  173. package/assets/docs/tree/upgrading.doc.mjs +103 -0
  174. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +51 -0
  175. package/assets/docs/tree/verify-packed-template.doc.mjs +77 -0
  176. package/assets/docs/tree/versioning.doc.mjs +161 -0
  177. package/assets/docs/tree/write-good-templates.doc.mjs +64 -0
  178. package/assets/docs/tree/write-the-template-file.doc.mjs +154 -0
  179. package/assets/docs/typography.doc.mjs +24 -4
  180. package/assets/docs/working-with-ai.doc.mjs +30 -22
  181. package/assets/templates/blocks/components/InternationalizationProvider/InternationalizationProvider01ShippedLocale.tsx +1 -1
  182. package/authoring/config/config.doc.mjs +9 -1
  183. package/authoring/config/parse.d.mts +2 -0
  184. package/authoring/config/parse.mjs +19 -0
  185. package/authoring/config/parse.test.mjs +8 -0
  186. package/authoring/config/type.ts +11 -0
  187. package/authoring/discover/discover.doc.d.mts +13 -0
  188. package/authoring/discover/discover.doc.mjs +138 -0
  189. package/authoring/discover/parse.d.mts +24 -0
  190. package/authoring/discover/parse.mjs +128 -0
  191. package/authoring/discover/parse.test.mjs +124 -0
  192. package/authoring/discover/type.ts +87 -0
  193. package/authoring/doctypes/_schema.d.mts +3 -2
  194. package/authoring/doctypes/_schema.mjs +6 -0
  195. package/authoring/doctypes/base/graph-fields.doc.mjs +3 -3
  196. package/authoring/doctypes/base/type.ts +4 -2
  197. package/authoring/doctypes/component/component.doc.mjs +6 -0
  198. package/authoring/doctypes/component/type.ts +8 -0
  199. package/authoring/doctypes/reference/reference.doc.mjs +7 -0
  200. package/authoring/doctypes/reference/type.ts +5 -0
  201. package/authoring/doctypes/schema/schema.doc.mjs +2 -2
  202. package/authoring/doctypes/template/template.doc.mjs +1 -1
  203. package/authoring/doctypes/template/type.ts +2 -2
  204. package/authoring/index.d.mts +1 -0
  205. package/authoring/index.d.ts +10 -0
  206. package/authoring/index.mjs +1 -0
  207. package/authoring/integration/integration.doc.mjs +12 -10
  208. package/clients/cli/commands/component/index.mjs +152 -55
  209. package/clients/cli/commands/component-batch.test.mjs +341 -0
  210. package/clients/cli/commands/component-ownership.test.mjs +89 -0
  211. package/clients/cli/commands/component.doc.mjs +27 -9
  212. package/clients/cli/commands/discover.doc.mjs +53 -9
  213. package/clients/cli/commands/discover.mjs +393 -118
  214. package/clients/cli/commands/discover.sources.test.mjs +267 -0
  215. package/clients/cli/commands/docs.doc.mjs +1 -1
  216. package/clients/cli/commands/docs.mjs +60 -17
  217. package/clients/cli/commands/doctor-integration-docs.doc.mjs +3 -2
  218. package/clients/cli/commands/doctor-integration.test.mjs +53 -0
  219. package/clients/cli/commands/doctor.doc.mjs +3 -1
  220. package/clients/cli/commands/doctor.mjs +49 -5
  221. package/clients/cli/commands/gap-report.doc.mjs +10 -9
  222. package/clients/cli/commands/init.doc.mjs +9 -6
  223. package/clients/cli/commands/integration-add.doc.mjs +9 -9
  224. package/clients/cli/commands/integration-authoring.test.mjs +61 -10
  225. package/clients/cli/commands/integration-pack.doc.mjs +5 -9
  226. package/clients/cli/commands/integration-real-world.test.mjs +1 -1
  227. package/clients/cli/commands/integration-verify.doc.mjs +22 -0
  228. package/clients/cli/commands/integration.doc.mjs +4 -4
  229. package/clients/cli/commands/integration.mjs +74 -43
  230. package/clients/cli/commands/manifest.doc.mjs +1 -1
  231. package/clients/cli/commands/search.doc.mjs +10 -3
  232. package/clients/cli/commands/search.mjs +21 -2
  233. package/clients/cli/commands/search.test.mjs +21 -4
  234. package/clients/cli/commands/swizzle.doc.mjs +1 -1
  235. package/clients/cli/commands/template.doc.mjs +1 -1
  236. package/clients/cli/commands/text-json-parity.test.mjs +7 -1
  237. package/clients/cli/commands/theme-add.doc.mjs +1 -1
  238. package/clients/cli/commands/theme-palette-generate.doc.mjs +3 -2
  239. package/clients/cli/commands/theme-palette.doc.mjs +1 -2
  240. package/clients/cli/commands/theme-targets.doc.mjs +2 -2
  241. package/clients/cli/commands/theme.doc.mjs +2 -1
  242. package/clients/cli/commands/upgrade.doc.mjs +62 -3
  243. package/clients/cli/index.mjs +28 -6
  244. package/clients/cli/lib/define-command.mjs +28 -4
  245. package/clients/cli/lib/define-command.test.mjs +54 -0
  246. package/clients/cli/lib/exit-codes.test.mjs +17 -1
  247. package/clients/cli/lib/json-shim.mjs +24 -14
  248. package/clients/cli/lib/manifest.mjs +18 -5
  249. package/clients/cli/lib/manifest.test.mjs +5 -2
  250. package/clients/cli/lib/parse-error-format.test.mjs +81 -0
  251. package/foundation/agent-docs/agent-docs.mjs +1 -1
  252. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  253. package/foundation/discovery/authoring-self-docs.test.mjs +6 -2
  254. package/foundation/discovery/cli-self-docs.mjs +16 -2
  255. package/foundation/discovery/cli-self-docs.test.mjs +20 -0
  256. package/foundation/discovery/docs-discovery.mjs +5 -1
  257. package/foundation/discovery/docs-discovery.test.mjs +21 -0
  258. package/foundation/discovery/docs-section-key.d.mts +1 -1
  259. package/foundation/discovery/docs-section-key.mjs +1 -1
  260. package/foundation/doc-compiler/doc-loads.test.mjs +3 -2
  261. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  262. package/foundation/doc-compiler/tree.d.mts +4 -0
  263. package/foundation/doc-compiler/tree.mjs +6 -1
  264. package/foundation/integrations/cli-requirement.d.mts +26 -6
  265. package/foundation/integrations/cli-requirement.mjs +46 -11
  266. package/foundation/integrations/cli-requirement.test.mjs +7 -2
  267. package/foundation/integrations/contribution-inventory.mjs +1 -1
  268. package/foundation/integrations/integrations.d.mts +14 -1
  269. package/foundation/integrations/integrations.mjs +41 -1
  270. package/foundation/integrations/integrations.test.mjs +31 -0
  271. package/foundation/response/batch.type.d.mts +33 -0
  272. package/foundation/response/batch.type.mjs +34 -0
  273. package/foundation/response/error-codes.doc.mjs +6 -8
  274. package/foundation/response/error-codes.test.mjs +30 -5
  275. package/foundation/response/response-types.doc.d.mts +4 -3
  276. package/foundation/response/response-types.doc.mjs +40 -10
  277. package/foundation/response/response-types.doc.test.mjs +23 -0
  278. package/foundation/response/response.doc.mjs +11 -10
  279. package/package.json +9 -9
  280. package/api/docs/docs.test.mjs +0 -243
  281. package/api/docs/integration-tree.test.mjs +0 -555
  282. package/api/docs/integrationDocs.test.mjs +0 -314
  283. package/api/search/search.test.mjs +0 -512
  284. package/assets/docs/tree/integrations.test.mjs +0 -62
  285. package/assets/docs/tree/writing-docs.doc.mjs +0 -286
  286. package/clients/cli/commands/docs.test.mjs +0 -294
  287. package/foundation/agent-docs/agent-docs.test.mjs +0 -1159
  288. package/foundation/doc-compiler/tree.test.mjs +0 -598
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Hosts the atomic staged-write transaction, package.json files-array
7
7
  * maintenance, package-dir resolution, and project-path normalization that
8
- * every `integration add <kind>` command needs. Stateless and side-effect-free
8
+ * every `integration add <kind>` command needs. `theme add` and `theme build`
9
+ * write through the same transaction. Stateless and side-effect-free
9
10
  * outside of {@link applyWrites}.
10
11
  */
11
12
 
@@ -40,7 +41,7 @@ export function findPackageDir(startDir) {
40
41
  /**
41
42
  * @typedef {object} WritePlan
42
43
  * @property {string} path
43
- * @property {string} contents
44
+ * @property {string | Buffer} contents a Buffer is written byte for byte
44
45
  * @property {boolean} createOnly
45
46
  * @property {Buffer} [expectedOriginal] bytes captured before validation;
46
47
  * a different current file is a concurrent edit and must never be overwritten
@@ -192,21 +193,29 @@ export function packageJsonUpdate(
192
193
 
193
194
  // ── Atomic staged-write transaction ─────────────────────────────────
194
195
 
195
- /** @param {string} file */
196
+ /**
197
+ * @param {string} file
198
+ * @returns {boolean} false when the file is still there
199
+ */
196
200
  function removeTemporary(file) {
197
201
  try {
198
202
  fs.rmSync(file, {force: true});
203
+ return true;
199
204
  } catch {
200
205
  // Best effort. The transaction error remains the actionable failure.
206
+ return false;
201
207
  }
202
208
  }
203
209
 
204
210
  /**
205
211
  * Restore writes that already published. Best-effort so callers preserve
206
- * the original actionable error.
212
+ * the original actionable error; returns every path it could not put back.
207
213
  * @param {Array<WritePlan & {temporary: string, original: Buffer|null, mode: number}>} published
214
+ * @returns {string[]}
208
215
  */
209
216
  function rollbackWrites(published) {
217
+ /** @type {string[]} */
218
+ const unrestored = [];
210
219
  for (const plan of [...published].reverse()) {
211
220
  let restore = null;
212
221
  try {
@@ -225,12 +234,18 @@ function rollbackWrites(published) {
225
234
  fs.renameSync(restore, plan.path);
226
235
  restore = null;
227
236
  }
228
- } catch {
229
- // Best effort. A concurrent edit belongs to its writer, not this rollback.
237
+ } catch (error) {
238
+ // A created file that is already gone needs nothing. Any other failure
239
+ // leaves this call's bytes, or no bytes, where the original was.
240
+ const gone =
241
+ error instanceof Error &&
242
+ /** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT';
243
+ if (!(gone && plan.original == null)) unrestored.push(plan.path);
230
244
  } finally {
231
245
  if (restore != null) removeTemporary(restore);
232
246
  }
233
247
  }
248
+ return unrestored;
234
249
  }
235
250
 
236
251
  /** @param {string} file */
@@ -325,10 +340,22 @@ export function applyWrites(plans) {
325
340
  }
326
341
  published.push(plan);
327
342
  }
328
- return () => rollbackWrites(published);
343
+ return () => {
344
+ rollbackWrites(published);
345
+ };
329
346
  } catch (error) {
330
- for (const plan of staged) removeTemporary(plan.temporary);
331
- rollbackWrites(published);
347
+ const leftovers = staged
348
+ .filter(plan => !removeTemporary(plan.temporary))
349
+ .map(plan => plan.temporary);
350
+ const unrestored = rollbackWrites(published);
351
+ if (error instanceof Error) {
352
+ if (unrestored.length > 0) {
353
+ error.message += ` Could not restore: ${unrestored.join(', ')}.`;
354
+ }
355
+ if (leftovers.length > 0) {
356
+ error.message += ` Could not remove temporary files: ${leftovers.join(', ')}.`;
357
+ }
358
+ }
332
359
  throw error;
333
360
  }
334
361
  }
@@ -24,6 +24,10 @@ import {
24
24
  import {loadManifestObject} from '../../foundation/integrations/integrations.mjs';
25
25
  import {themeDescriptorSource} from '../../foundation/integrations/theme-descriptor.mjs';
26
26
  import {assertContributionVisible} from '../../foundation/integrations/contribution-inventory.mjs';
27
+ import {
28
+ themesCliProblem,
29
+ withDocsTreeCli,
30
+ } from '../../foundation/integrations/cli-requirement.mjs';
27
31
  import {
28
32
  applyWrites,
29
33
  findPackageDir,
@@ -233,11 +237,28 @@ export async function integrationAddTheme(name, options = {}) {
233
237
  createOnly: true,
234
238
  },
235
239
  ];
236
- const packageUpdate = packageJsonUpdate(
240
+ let packageUpdate = packageJsonUpdate(
237
241
  packageFile,
238
242
  rootPath,
239
243
  path.basename(manifestFile),
240
244
  );
245
+ // A CLI older than the one that reads typed theme descriptors rejects the
246
+ // themes root and withholds the package's themes and docs. Declare the CLI
247
+ // that reads them as a peer, so an older one is flagged at install instead.
248
+ {
249
+ const expectedOriginal =
250
+ packageUpdate?.expectedOriginal ?? fs.readFileSync(packageFile);
251
+ const text = packageUpdate?.contents ?? expectedOriginal.toString('utf-8');
252
+ const current = JSON.parse(text);
253
+ if (themesCliProblem(current) != null) {
254
+ packageUpdate = {
255
+ contents:
256
+ JSON.stringify(withDocsTreeCli(current), null, 2) +
257
+ (text.endsWith('\n') ? '\n' : ''),
258
+ expectedOriginal,
259
+ };
260
+ }
261
+ }
241
262
  if (packageUpdate != null) {
242
263
  plans.push({
243
264
  path: packageFile,
@@ -77,6 +77,40 @@ describe('integrationAddTheme', () => {
77
77
  expect((await validateLocalIntegration(tmpDir)).issues).toEqual([]);
78
78
  });
79
79
 
80
+ it('declares the optional CLI peer that reads typed theme descriptors', async () => {
81
+ setup({includeFiles: false});
82
+ const result = await integrationAddTheme('ocean', {cwd: tmpDir});
83
+ expect(result.data.files).toContain('package.json');
84
+ const pkg = JSON.parse(
85
+ fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8'),
86
+ );
87
+ expect(pkg.peerDependencies).toEqual({'@astryxdesign/cli': '>=0.7.0'});
88
+ expect(pkg.peerDependenciesMeta).toEqual({
89
+ '@astryxdesign/cli': {optional: true},
90
+ });
91
+ });
92
+
93
+ it('keeps a CLI peer that already reads typed theme descriptors', async () => {
94
+ const pkg = {
95
+ name: '@acme/themes',
96
+ version: '1.0.0',
97
+ peerDependencies: {'@astryxdesign/cli': '^0.7.2'},
98
+ };
99
+ fs.writeFileSync(
100
+ path.join(tmpDir, 'package.json'),
101
+ `${JSON.stringify(pkg, null, 2)}\n`,
102
+ );
103
+ fs.writeFileSync(
104
+ path.join(tmpDir, 'astryx.integration.mjs'),
105
+ 'export default {};\n',
106
+ );
107
+ const result = await integrationAddTheme('ocean', {cwd: tmpDir});
108
+ expect(result.data.files).not.toContain('package.json');
109
+ expect(
110
+ JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8')),
111
+ ).toEqual(pkg);
112
+ });
113
+
80
114
  it('dry-runs the identical receipt without writing anything', async () => {
81
115
  setup();
82
116
  const beforeManifest = fs.readFileSync(
@@ -368,11 +368,11 @@ export async function integrationDocConflicts(pkg, options = {}) {
368
368
  // and placed guides this package adds to the docs tree, and every link in
369
369
  // its docs (spec:AST-046, spec:AST-047).
370
370
  if (errors.length === 0) {
371
- for (const message of await packageDocsProblems(
371
+ for (const {severity, message} of await packageDocsProblems(
372
372
  /** @type {{name: string}} */ (resolved.integration),
373
373
  discovered,
374
374
  )) {
375
- issues.push({code: 'invalid_doc_graph', severity: 'warning', message});
375
+ issues.push({code: 'invalid_doc_graph', severity, message});
376
376
  }
377
377
 
378
378
  // A reference block includes content rather than linking to it, so one
@@ -7,13 +7,13 @@ export const doc = {
7
7
  name: 'integrationPackCheck',
8
8
  namespace: 'cli/api',
9
9
  displayName: 'integrationPackCheck()',
10
- summary: 'Prove an integration package survives npm packing.',
10
+ summary: 'Check an integration package the way npm will publish it.',
11
11
  description:
12
12
  "Validates the local integration, runs the package lifecycle, packs with npm, checks required files against npm's authoritative tarball list, extracts the real tarball into a scratch consumer, compares local and packed contribution inventories, and resolves every packed component through its documented public import to verify that module exports the component. A package that ships a namespace doc or a placed guide, or has a template that sets `replaces`, fails unless its `@astryxdesign/cli` peer range admits only CLIs that read them.",
13
13
  importPath: '@astryxdesign/cli/api',
14
14
  signature:
15
15
  'integrationPackCheck(options?: IntegrationPackCheckOptions): Promise<IntegrationPackCheckResponse>',
16
- keywords: ['integration', 'pack', 'check', 'publish', 'tarball', 'consumer'],
16
+ keywords: ['verify', 'pack', 'publish', 'tarball', 'consumer'],
17
17
  params: [
18
18
  {
19
19
  name: 'options.cwd',
@@ -31,6 +31,6 @@ export const doc = {
31
31
  examples: [
32
32
  {label: 'Check the local package', code: 'await integrationPackCheck();'},
33
33
  ],
34
- command: 'integration pack',
34
+ command: 'integration verify',
35
35
  related: ['integrationAdd', 'validateIntegration'],
36
36
  };
@@ -0,0 +1,107 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {integrationPackCheck} from './pack-check.mjs';
7
+
8
+ let tmpDir;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-pack-check-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, {recursive: true, force: true});
16
+ });
17
+
18
+ /** @param {Record<string, string>} scripts */
19
+ function writeThemePackage(scripts) {
20
+ fs.writeFileSync(
21
+ path.join(tmpDir, 'package.json'),
22
+ `${JSON.stringify(
23
+ {
24
+ name: '@acme/widgets',
25
+ version: '1.0.0',
26
+ files: ['astryx.integration.mjs', 'themes'],
27
+ peerDependencies: {'@astryxdesign/cli': '>=0.7.0'},
28
+ peerDependenciesMeta: {'@astryxdesign/cli': {optional: true}},
29
+ scripts,
30
+ },
31
+ null,
32
+ 2,
33
+ )}\n`,
34
+ );
35
+ fs.writeFileSync(
36
+ path.join(tmpDir, 'astryx.integration.mjs'),
37
+ "export default {themes: './themes'};\n",
38
+ );
39
+ const root = path.join(tmpDir, 'themes');
40
+ fs.mkdirSync(path.join(root, 'ocean'), {recursive: true});
41
+ fs.writeFileSync(
42
+ path.join(root, 'ocean', 'oceanTheme.ts'),
43
+ "import {defineTheme} from '@astryxdesign/core/theme';\n\nexport const oceanTheme = defineTheme({name: 'ocean'});\n",
44
+ );
45
+ fs.writeFileSync(
46
+ path.join(root, 'ocean', 'oceanTheme.doc.mjs'),
47
+ `/** @type {import('@astryxdesign/cli/authoring').ThemeDoc} */
48
+ export default {type: 'theme', name: 'ocean', displayName: 'Ocean', description: 'Ocean theme.', maintained: true};
49
+ `,
50
+ );
51
+ }
52
+
53
+ describe('integrationPackCheck with lifecycle script output', () => {
54
+ it('checks the tarball when lifecycle scripts print to stdout', async () => {
55
+ writeThemePackage({
56
+ prepack: 'node -e "console.log(\'building the package\')"',
57
+ prepare: 'node -e "console.log(\'preparing\')"',
58
+ postpack: 'node -e "console.log(\'packed\')"',
59
+ });
60
+
61
+ const result = await integrationPackCheck({cwd: tmpDir});
62
+
63
+ expect(result.data.issues).toEqual([]);
64
+ expect(result.data.packable).toBe(true);
65
+ expect(result.data.tarball?.filename).toBe('acme-widgets-1.0.0.tgz');
66
+ expect(result.data.contributions.packed).toEqual(
67
+ result.data.contributions.local,
68
+ );
69
+ });
70
+
71
+ it('still runs a chatty lifecycle script and compares what it packed', async () => {
72
+ const renameTheme = [
73
+ "console.log('renaming ocean to storm')",
74
+ "const fs=require('fs')",
75
+ "fs.renameSync('themes/ocean','themes/storm')",
76
+ "const p='themes/storm/oceanTheme.doc.mjs'",
77
+ "let x=fs.readFileSync(p,'utf8')",
78
+ "x=x.replace(/name: 'ocean'/, 'name: '+String.fromCharCode(39)+'storm'+String.fromCharCode(39))",
79
+ 'fs.writeFileSync(p,x)',
80
+ ].join(';');
81
+ writeThemePackage({prepack: `node -e "${renameTheme}"`});
82
+
83
+ const result = await integrationPackCheck({cwd: tmpDir});
84
+ const codes = result.data.issues.map(issue => issue.code);
85
+
86
+ expect(codes).not.toContain('pack_failed');
87
+ expect(codes).toContain('identity_not_packed');
88
+ expect(codes).toContain('packed_identity_unexpected');
89
+ expect(result.data.packable).toBe(false);
90
+ });
91
+
92
+ it('keeps a failing lifecycle script output in the pack_failed issue', async () => {
93
+ writeThemePackage({
94
+ prepack:
95
+ 'node -e "console.error(\'prepack exploded\'); process.exit(3)"',
96
+ });
97
+
98
+ const result = await integrationPackCheck({cwd: tmpDir});
99
+ const failure = result.data.issues.find(
100
+ issue => issue.code === 'pack_failed',
101
+ );
102
+
103
+ expect(result.data.packable).toBe(false);
104
+ expect(failure?.message).toContain('npm pack failed (exit 3)');
105
+ expect(failure?.message).toContain('prepack exploded');
106
+ });
107
+ });
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file `astryx integration pack --check` — verify an integration package is
4
+ * @file `astryx integration verify` — verify an integration package is
5
5
  * ready to publish by cross-referencing its declared contributions against the
6
6
  * real npm tarball.
7
7
  *
@@ -30,8 +30,13 @@ import {resolvePackageDir} from '../../foundation/integrations/integrations.mjs'
30
30
  import {
31
31
  docsTreeCliProblem,
32
32
  replacesCliProblem,
33
+ sectionIdsCliProblem,
34
+ themesCliProblem,
33
35
  } from '../../foundation/integrations/cli-requirement.mjs';
34
- import {discoverIntegrationDocs} from '../../foundation/discovery/docs-discovery.mjs';
36
+ import {
37
+ discoverIntegrationDocs,
38
+ loadTopicModule,
39
+ } from '../../foundation/discovery/docs-discovery.mjs';
35
40
  import {
36
41
  discoverIntegrationComponents,
37
42
  resolveIntegrationImportPath,
@@ -162,6 +167,22 @@ export function parseNpmPackOutput(output) {
162
167
  };
163
168
  }
164
169
 
170
+ /**
171
+ * Summarize the JSON error npm prints on stdout when `--json` pack fails.
172
+ * @param {string} output
173
+ * @returns {string}
174
+ */
175
+ function npmPackErrorDetail(output) {
176
+ try {
177
+ const error = JSON.parse(output)?.error;
178
+ return [error?.summary, error?.detail]
179
+ .filter(part => typeof part === 'string' && part.trim() !== '')
180
+ .join(': ');
181
+ } catch {
182
+ return (output || '').trim();
183
+ }
184
+ }
185
+
165
186
  /**
166
187
  * Run `npm pack --json` with output directed to `destDir` so no preexisting
167
188
  * tgz is overwritten. This intentionally runs the package lifecycle, matching
@@ -175,16 +196,25 @@ export function parseNpmPackOutput(output) {
175
196
  function runNpmPack(packageDir, destDir) {
176
197
  const result = spawnSync(
177
198
  'npm',
178
- ['pack', '--json', '--silent', `--pack-destination=${destDir}`],
199
+ [
200
+ 'pack',
201
+ '--json',
202
+ '--silent',
203
+ // Lifecycle scripts still run; background mode keeps their output off
204
+ // the stdout that carries the JSON result.
205
+ '--foreground-scripts=false',
206
+ `--pack-destination=${destDir}`,
207
+ ],
179
208
  {cwd: packageDir, encoding: 'utf-8', timeout: 60_000},
180
209
  );
181
210
  if (result.error) {
182
211
  throw new Error(`Could not start npm pack: ${result.error.message}`);
183
212
  }
184
213
  if (result.status !== 0) {
185
- const stderr = (result.stderr || '').trim();
214
+ const detail =
215
+ (result.stderr || '').trim() || npmPackErrorDetail(result.stdout);
186
216
  throw new Error(
187
- `npm pack failed (exit ${result.status})${stderr ? `: ${stderr}` : ''}.`,
217
+ `npm pack failed (exit ${result.status})${detail ? `: ${detail}` : ''}.`,
188
218
  );
189
219
  }
190
220
  return parseNpmPackOutput(result.stdout);
@@ -308,6 +338,23 @@ function moduleExportsName(file, exportName, seen = new Set()) {
308
338
  return found;
309
339
  }
310
340
 
341
+ /**
342
+ * Whether any of these doc files has a section that sets `id`.
343
+ * @param {string[]} files
344
+ * @returns {Promise<boolean>}
345
+ */
346
+ async function setsSectionIds(files) {
347
+ for (const file of files) {
348
+ if (typeof file !== 'string') continue;
349
+ const doc = /** @type {any} */ (await loadTopicModule(file).catch(() => null));
350
+ const sections = Array.isArray(doc?.sections) ? doc.sections : [];
351
+ if (sections.some((/** @type {any} */ section) => section?.id != null)) {
352
+ return true;
353
+ }
354
+ }
355
+ return false;
356
+ }
357
+
311
358
  /**
312
359
  * Resolve package specifiers through Node's real ESM resolver from the scratch
313
360
  * consumer. Resolution does not execute the target module, so source `.tsx`
@@ -600,19 +647,31 @@ export async function integrationPackCheck(options = {}) {
600
647
  // (spec:AST-046 FR11): an older CLI can hide every doc topic the package
601
648
  // ships, so the declared CLI range must admit only CLIs that read it.
602
649
  if (loaded.docs) {
603
- const {namespaces, guides} = await discoverIntegrationDocs(loaded).catch(
604
- () => ({namespaces: [], guides: []}),
605
- );
650
+ const {records, namespaces, guides} = await discoverIntegrationDocs(
651
+ loaded,
652
+ ).catch(() => ({records: [], namespaces: [], guides: []}));
606
653
  const problem =
607
654
  namespaces.length > 0 || guides.length > 0
608
655
  ? docsTreeCliProblem(pkg)
609
656
  : null;
610
657
  if (problem != null) {
611
658
  issues.push(error('docs_tree_needs_cli', problem));
659
+ } else if (
660
+ await setsSectionIds([
661
+ ...records.map(record => record.path),
662
+ ...guides.map(guide => /** @type {any} */ (guide.ref).topicFile),
663
+ ])
664
+ ) {
665
+ // A section `id` is also a field an older CLI rejects, hiding the
666
+ // package's doc topics; the same peer range fixes both.
667
+ const idProblem = sectionIdsCliProblem(pkg);
668
+ if (idProblem != null) {
669
+ issues.push(error('section_ids_need_cli', idProblem));
670
+ }
612
671
  }
613
672
  }
614
673
  // A template that sets `replaces` needs a CLI that reads the field
615
- // (spec:AST-035): an older CLI withholds the package's templates and docs.
674
+ // (spec:AST-035): an older CLI drops that template and hides the package's docs.
616
675
  if (loaded.templates) {
617
676
  const found = await discoverIntegrationTemplatesForOne(loaded).catch(
618
677
  () => ({templates: [], errors: []}),
@@ -625,6 +684,12 @@ export async function integrationPackCheck(options = {}) {
625
684
  const problem = setsReplaces ? replacesCliProblem(pkg) : null;
626
685
  if (problem != null) issues.push(error('replaces_needs_cli', problem));
627
686
  }
687
+ // A theme needs a CLI that reads typed theme descriptors: an older CLI
688
+ // rejects the themes root and withholds the package's themes and docs.
689
+ if (localIdentities.themes.length > 0) {
690
+ const problem = themesCliProblem(pkg);
691
+ if (problem != null) issues.push(error('themes_need_cli', problem));
692
+ }
628
693
 
629
694
  // Temp resources — always cleaned up
630
695
  const tgzTmpDir = fs.mkdtempSync(
@@ -657,6 +722,14 @@ export async function integrationPackCheck(options = {}) {
657
722
  // package's already-installed dependencies. The unique suffix makes
658
723
  // concurrent checks independent.
659
724
  scratchBase = fs.mkdtempSync(path.join(packageDir, '.astryx-pack-check-'));
725
+ // The consumer's own package.json makes it the package scope for its
726
+ // imports. Without one, Node resolves the package's name through the
727
+ // SOURCE package.json (self-reference), so an export target left out of
728
+ // the tarball would still resolve.
729
+ fs.writeFileSync(
730
+ path.join(scratchBase, 'package.json'),
731
+ `${JSON.stringify({name: 'astryx-verify-consumer', private: true})}\n`,
732
+ );
660
733
 
661
734
  // Cross-reference file inventory vs pack list
662
735
  if (!packResult.packedPaths.has(fileInv.manifest)) {
@@ -36,6 +36,8 @@ function writePackage({
36
36
  scripts,
37
37
  } = {}) {
38
38
  const pkg = {name, version};
39
+ // A theme needs a CLI that reads typed theme descriptors.
40
+ if (themes) pkg.peerDependencies = {'@astryxdesign/cli': '>=0.7.0'};
39
41
  if (files !== undefined) pkg.files = files;
40
42
  if (scripts !== undefined) pkg.scripts = scripts;
41
43
  fs.writeFileSync(
@@ -94,6 +96,30 @@ describe('integrationPackCheck', () => {
94
96
  );
95
97
  });
96
98
 
99
+ it('fails a package that ships a theme on a CLI range that cannot read it', async () => {
100
+ writePackage({files: ['astryx.integration.mjs', 'themes']});
101
+ const pkgFile = path.join(tmpDir, 'package.json');
102
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf-8'));
103
+ delete pkg.peerDependencies;
104
+ fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
105
+
106
+ const missing = await integrationPackCheck({cwd: tmpDir});
107
+ expect(missing.data.packable).toBe(false);
108
+ expect(missing.data.issues).toContainEqual(
109
+ expect.objectContaining({
110
+ code: 'themes_need_cli',
111
+ message: expect.stringContaining('ships a theme'),
112
+ }),
113
+ );
114
+
115
+ pkg.peerDependencies = {'@astryxdesign/cli': '^0.6.3'};
116
+ fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
117
+ const old = await integrationPackCheck({cwd: tmpDir});
118
+ expect(old.data.issues).toContainEqual(
119
+ expect.objectContaining({code: 'themes_need_cli'}),
120
+ );
121
+ });
122
+
97
123
  it('fails when a theme entry omits its inferred runtime export', async () => {
98
124
  writePackage({files: ['astryx.integration.mjs', 'themes']});
99
125
  fs.writeFileSync(
@@ -193,6 +219,39 @@ describe('integrationPackCheck', () => {
193
219
  );
194
220
  });
195
221
 
222
+ it('resolves public imports in the packed package, not the source', async () => {
223
+ // The root export's target is left out of `files`: the source package
224
+ // resolves it, but an app that installs the tarball cannot.
225
+ fs.writeFileSync(
226
+ path.join(tmpDir, 'package.json'),
227
+ `${JSON.stringify({
228
+ name: '@acme/widgets',
229
+ version: '1.0.0',
230
+ files: ['astryx.integration.mjs'],
231
+ exports: {'.': './index.mjs'},
232
+ })}\n`,
233
+ );
234
+ fs.writeFileSync(
235
+ path.join(tmpDir, 'index.mjs'),
236
+ "export {AcmeWidget} from './components/AcmeWidget.tsx';\n",
237
+ );
238
+ await integrationAddComponent('AcmeWidget', {cwd: tmpDir});
239
+ const docFile = path.join(tmpDir, 'components', 'AcmeWidget.doc.mjs');
240
+ const doc = fs.readFileSync(docFile, 'utf-8');
241
+ expect(doc).toContain('@acme/widgets/components/AcmeWidget');
242
+ fs.writeFileSync(
243
+ docFile,
244
+ doc.replace('@acme/widgets/components/AcmeWidget', '@acme/widgets'),
245
+ );
246
+
247
+ const result = await integrationPackCheck({cwd: tmpDir});
248
+
249
+ expect(result.data.packable).toBe(false);
250
+ expect(result.data.issues).toContainEqual(
251
+ expect.objectContaining({code: 'component_export_missing'}),
252
+ );
253
+ });
254
+
196
255
  it('fails when packed template source is hidden by package exports', async () => {
197
256
  writePackage({
198
257
  manifest: "export default {templates: './templates'};\n",
@@ -336,6 +395,37 @@ describe('integrationPackCheck', () => {
336
395
  expect(await codes()).not.toContain('docs_tree_needs_cli');
337
396
  }, 120_000);
338
397
 
398
+ it('fails a package whose doc section sets id on a CLI range that rejects the field', async () => {
399
+ writePackage({manifest: "export default {docs: './docs'};\n", themes: false});
400
+ fs.mkdirSync(path.join(tmpDir, 'docs'), {recursive: true});
401
+ const topic = (/** @type {string} */ section) =>
402
+ `export default {type: 'generic', name: 'notes', title: 'Notes', description: 'Notes.', sections: [${section}]};\n`;
403
+ const file = path.join(tmpDir, 'docs', 'notes.doc.mjs');
404
+ const codes = async () =>
405
+ (await integrationPackCheck({cwd: tmpDir})).data.issues.map(
406
+ (/** @type {{code: string}} */ issue) => issue.code,
407
+ );
408
+ fs.writeFileSync(
409
+ file,
410
+ topic("{title: 'Take notes', content: [{type: 'prose', text: 'Notes.'}]}"),
411
+ );
412
+ expect(await codes()).not.toContain('section_ids_need_cli');
413
+ // A fresh file name: the module loader caches a path once it is imported.
414
+ fs.rmSync(file);
415
+ fs.writeFileSync(
416
+ path.join(tmpDir, 'docs', 'notes-with-ids.doc.mjs'),
417
+ topic(
418
+ "{id: 'take-notes', title: 'Take notes', content: [{type: 'prose', text: 'Notes.'}]}",
419
+ ),
420
+ );
421
+ expect(await codes()).toContain('section_ids_need_cli');
422
+ const pkgFile = path.join(tmpDir, 'package.json');
423
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf-8'));
424
+ pkg.peerDependencies = {'@astryxdesign/cli': '>=0.7.0'};
425
+ fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
426
+ expect(await codes()).not.toContain('section_ids_need_cli');
427
+ }, 120_000);
428
+
339
429
  it('fails a package with a template that sets replaces on a CLI range that rejects the field', async () => {
340
430
  writePackage({manifest: "export default {templates: './templates'};\n", themes: false});
341
431
  fs.mkdirSync(path.join(tmpDir, 'templates'), {recursive: true});
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file Colocated types for `astryx integration pack --check`.
4
+ * @file Colocated types for `astryx integration verify`.
5
5
  */
6
6
 
7
7
  /**
@@ -49,7 +49,7 @@ export const doc = {
49
49
  throws: [
50
50
  {
51
51
  code: 'Error',
52
- when: 'the CLI returned an error envelope (the CLI message is rethrown), or the response `type` is not expectedType',
52
+ when: 'a plain Error with no code: the CLI returned an error envelope (its message is rethrown; code and suggestions are dropped), or the response `type` is not expectedType',
53
53
  },
54
54
  ],
55
55
  examples: [
@@ -15,7 +15,7 @@ export const doc = {
15
15
  displayName: 'isError()',
16
16
  summary: 'Did the CLI return an error envelope?',
17
17
  description:
18
- 'Tests a parsed response for an `error` key. Branch on this before touching `data`: ' +
18
+ 'Tests a parsed response for an `error` key. Branch on this before touching `data`, ' +
19
19
  'and prefer the stable `code` field over matching the human-readable message, which is ' +
20
20
  'not a contract. Note this returns a plain boolean, not a TypeScript type predicate, so ' +
21
21
  'it does not narrow on its own: cast to the matching *Response type to get typed access.',