@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
@@ -19,6 +19,8 @@ import {
19
19
  discoverRegistryReceipts,
20
20
  reconcileRegistryCompositions,
21
21
  } from './registry.mjs';
22
+ import {upgrade} from '../upgrade.mjs';
23
+ import {logger} from '../../logger.mjs';
22
24
 
23
25
  const ITEM = {
24
26
  name: 'example-button-basic',
@@ -824,3 +826,86 @@ describe('registry composition upgrades', () => {
824
826
  expect(fs.readFileSync(sourcePath, 'utf8')).toBe('old\n');
825
827
  });
826
828
  });
829
+
830
+ describe('copied-composition paths name files from the folder the command ran in', () => {
831
+ it('below the project root, and from the scanned source folder at the root', async () => {
832
+ const {sourcePath} = seed({
833
+ base: 'value = 1\n',
834
+ current: 'value = local\n',
835
+ });
836
+ const fetchImpl = remote('value = upstream\n');
837
+ const origin = path.join(project, 'apps', 'web');
838
+
839
+ const [atRoot] = (await reconcile({}, {fetchImpl})).summary.items[0].files;
840
+ const [below] = (await reconcile({}, {fetchImpl, origin})).summary.items[0]
841
+ .files;
842
+
843
+ expect(atRoot).toMatchObject({
844
+ path: SOURCE_TARGET,
845
+ conflictFile: `${SOURCE_TARGET}.astryx-conflict`,
846
+ });
847
+ expect(below).toMatchObject({
848
+ path: `../../src/${SOURCE_TARGET}`,
849
+ conflictFile: `../../src/${SOURCE_TARGET}.astryx-conflict`,
850
+ });
851
+ expect(path.resolve(origin, below.path)).toBe(sourcePath);
852
+ });
853
+
854
+ it('for an invalid receipt too', async () => {
855
+ const value = receipt('base\n');
856
+ value.files[0].sha256 = '0'.repeat(64);
857
+ seed({base: 'base\n', receiptValue: value});
858
+ const fetchImpl = remote('new\n');
859
+ const origin = path.join(project, 'docs');
860
+
861
+ const [atRoot] = (await reconcile({}, {fetchImpl})).summary.items;
862
+ const [below] = (await reconcile({}, {fetchImpl, origin})).summary.items;
863
+
864
+ expect(atRoot.message).toContain(`Invalid receipt ${RECEIPT_TARGET}:`);
865
+ expect(below.message).toContain(
866
+ `Invalid receipt ../src/${RECEIPT_TARGET}:`,
867
+ );
868
+ });
869
+
870
+ it('through upgrade(), with --registry and in a run, which name the project root', async () => {
871
+ fs.writeFileSync(
872
+ path.join(project, 'package.json'),
873
+ JSON.stringify({
874
+ name: 'app',
875
+ dependencies: {'@astryxdesign/core': '0.6.0'},
876
+ }),
877
+ );
878
+ const core = path.join(project, 'node_modules', '@astryxdesign', 'core');
879
+ fs.mkdirSync(core, {recursive: true});
880
+ fs.writeFileSync(
881
+ path.join(core, 'package.json'),
882
+ JSON.stringify({name: '@astryxdesign/core', version: '0.6.0'}),
883
+ );
884
+ const frontend = path.join(project, 'frontend');
885
+ fs.mkdirSync(frontend);
886
+ seed({base: 'old\n'});
887
+ vi.stubGlobal('fetch', remote('new\n'));
888
+ const log = vi.spyOn(logger, 'log');
889
+ try {
890
+ const registry = await upgrade({registry: true}, {cwd: frontend});
891
+ const run = await upgrade({from: '0.6.0'}, {cwd: frontend});
892
+ const atRoot = await upgrade({registry: true}, {cwd: project});
893
+
894
+ expect(registry.data.items[0].files[0].path).toBe(
895
+ `../src/${SOURCE_TARGET}`,
896
+ );
897
+ expect(run.data.registryCompositions.items[0].files[0].path).toBe(
898
+ `../src/${SOURCE_TARGET}`,
899
+ );
900
+ expect(atRoot.data.items[0].files[0].path).toBe(SOURCE_TARGET);
901
+ expect(
902
+ log.mock.calls.filter(([line]) =>
903
+ String(line).startsWith('Project root:'),
904
+ ),
905
+ ).toEqual([['Project root: ..'], ['Project root: ..']]);
906
+ } finally {
907
+ vi.unstubAllGlobals();
908
+ log.mockRestore();
909
+ }
910
+ });
911
+ });
@@ -7,9 +7,11 @@
7
7
  * on failure. Progress is emitted through the shared `logger` (silent by default).
8
8
  *
9
9
  * @param {import('../_adapter.mjs').UpgradeOptions} [options]
10
- * @param {{cwd?: string}} [ctx]
10
+ * @param {{cwd?: string, origin?: string}} [ctx] `origin` is the folder the
11
+ * command ran in, when `cwd` is the project root above it
11
12
  * @returns {Promise<import('../upgrade.type.mjs').UpgradeStatusResponse | import('../upgrade.type.mjs').UpgradeRunResponse>}
12
13
  */
13
- export function run(options?: import("../_adapter.mjs").UpgradeOptions, { cwd }?: {
14
+ export function run(options?: import("../_adapter.mjs").UpgradeOptions, { cwd, origin }?: {
14
15
  cwd?: string;
16
+ origin?: string;
15
17
  }): Promise<import("../upgrade.type.mjs").UpgradeStatusResponse | import("../upgrade.type.mjs").UpgradeRunResponse>;
@@ -115,10 +115,11 @@ function loadFileProtection(cwd) {
115
115
  * on failure. Progress is emitted through the shared `logger` (silent by default).
116
116
  *
117
117
  * @param {import('../_adapter.mjs').UpgradeOptions} [options]
118
- * @param {{cwd?: string}} [ctx]
118
+ * @param {{cwd?: string, origin?: string}} [ctx] `origin` is the folder the
119
+ * command ran in, when `cwd` is the project root above it
119
120
  * @returns {Promise<import('../upgrade.type.mjs').UpgradeStatusResponse | import('../upgrade.type.mjs').UpgradeRunResponse>}
120
121
  */
121
- export async function run(options = {}, {cwd = process.cwd()} = {}) {
122
+ export async function run(options = {}, {cwd = process.cwd(), origin} = {}) {
122
123
  // Resolve the source dir against the API's cwd (not process.cwd()) so a
123
124
  // programmatic caller in another directory scans the right tree. Confine it to
124
125
  // cwd: --apply rewrites files in place, so a `..`-escaping or out-of-tree
@@ -152,6 +153,12 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
152
153
  const sourcePathFound = fs.existsSync(path_);
153
154
 
154
155
  const currentVersion = /** @type {string} */ (options.from);
156
+ // Named first, so a run that stops on the Core lookup below still says which
157
+ // folder it searched (spec:AST-040 FR12).
158
+ if (origin != null) {
159
+ logger.log(`Project root: ${path.relative(origin, cwd) || '.'}`);
160
+ logger.log(`Source folder: ${path.relative(origin, path_) || '.'}`);
161
+ }
155
162
  const installed = detectInstalledTargetVersion(cwd);
156
163
  if (!installed) {
157
164
  // Outside a project there is no version to install (spec AST-081 FR3).
@@ -170,6 +177,7 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
170
177
  {apply, path: path_},
171
178
  {
172
179
  cwd,
180
+ origin,
173
181
  expectedVersion:
174
182
  installed.packageName === '@astryxdesign/core'
175
183
  ? targetVersion
@@ -192,6 +200,7 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
192
200
  const registryResult = await reconcileCompositions();
193
201
  const agentDocsPlan = await prepareAgentDocsRefresh({
194
202
  cwd,
203
+ origin,
195
204
  installedVersion: targetVersion,
196
205
  apply,
197
206
  });
@@ -396,6 +405,7 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
396
405
  // No codemods in range — safe to render agent docs from current state.
397
406
  const agentDocsPlan = await prepareAgentDocsRefresh({
398
407
  cwd,
408
+ origin,
399
409
  installedVersion: targetVersion,
400
410
  apply,
401
411
  });
@@ -671,6 +681,7 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
671
681
  if (protectedFiles.length === 0) {
672
682
  const agentDocsPlan = await prepareAgentDocsRefresh({
673
683
  cwd,
684
+ origin,
674
685
  installedVersion: targetVersion,
675
686
  apply,
676
687
  fresh: true,
@@ -3,18 +3,41 @@
3
3
  /**
4
4
  * @file Colocated tests for the upgrade.run leaf — the --path scan dir must be
5
5
  * confined to cwd (upgrade rewrites source in place with --apply, so an escaping
6
- * or out-of-tree path must be rejected), and the receipt must say whether that
7
- * dir existed at all.
6
+ * or out-of-tree path must be rejected), the receipt must say whether that
7
+ * dir existed at all, a run from any folder in a project upgrades that
8
+ * project (the nearest folder whose package.json declares Astryx, never the
9
+ * home folder) and names files relative to the folder it ran in, and the
10
+ * target Core is the project's own.
8
11
  */
9
12
 
10
- import {describe, it, expect, beforeEach, afterEach} from 'vitest';
13
+ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
11
14
  import * as fs from 'node:fs';
12
15
  import * as os from 'node:os';
13
16
  import * as path from 'node:path';
14
17
  import {upgrade} from '../upgrade.mjs';
18
+ import {relativeToOrigin} from '../_adapter.mjs';
19
+ import {logger} from '../../logger.mjs';
20
+ import {generateCompressedIndex} from '../../../foundation/agent-docs/agent-docs.mjs';
15
21
 
16
22
  const SLOW = 30_000;
17
23
 
24
+ /**
25
+ * Run `fn` with `home` as the home folder.
26
+ * @template T
27
+ * @param {string} home @param {() => Promise<T>} fn
28
+ * @returns {Promise<T>}
29
+ */
30
+ async function withHome(home, fn) {
31
+ const previous = process.env.HOME;
32
+ process.env.HOME = home;
33
+ try {
34
+ return await fn();
35
+ } finally {
36
+ if (previous === undefined) delete process.env.HOME;
37
+ else process.env.HOME = previous;
38
+ }
39
+ }
40
+
18
41
  function seedProject(dir) {
19
42
  fs.writeFileSync(
20
43
  path.join(dir, 'package.json'),
@@ -95,3 +118,394 @@ describe('upgrade.run — sourcePathFound', () => {
95
118
  expect(res.data.sourcePathFound).toBe(true);
96
119
  }, SLOW);
97
120
  });
121
+
122
+ /** Core 0.7.0 renamed this prop; its codemod renames it on Typeahead. */
123
+ const OLD_PROP_SOURCE =
124
+ "import {Typeahead} from '@astryxdesign/core';\n" +
125
+ 'export const App = () => <Typeahead label="Fruit" emptySearchResultsText="None" />;\n';
126
+
127
+ /**
128
+ * A project with Core 0.7.0 installed and src/App.tsx using a prop that 0.7.0
129
+ * renamed.
130
+ * @param {string} dir
131
+ */
132
+ function seedOldPropProject(dir) {
133
+ fs.writeFileSync(
134
+ path.join(dir, 'package.json'),
135
+ JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}),
136
+ );
137
+ const core = path.join(dir, 'node_modules', '@astryxdesign', 'core');
138
+ fs.mkdirSync(core, {recursive: true});
139
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({name: '@astryxdesign/core', version: '0.7.0'}));
140
+ fs.mkdirSync(path.join(dir, 'src', 'l1', 'l2'), {recursive: true});
141
+ fs.writeFileSync(path.join(dir, 'src', 'App.tsx'), OLD_PROP_SOURCE);
142
+ }
143
+
144
+ describe('upgrade.run — a run from a subfolder upgrades the project', () => {
145
+ let dir;
146
+ beforeEach(() => {
147
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upg-scope-'));
148
+ seedOldPropProject(dir);
149
+ });
150
+ afterEach(() => fs.rmSync(dir, {recursive: true, force: true}));
151
+
152
+ const appSource = () => fs.readFileSync(path.join(dir, 'src', 'App.tsx'), 'utf-8');
153
+
154
+ it.each([['src', 'App.tsx'], ['src/l1/l2', '../../App.tsx']])(
155
+ 'scans the project src by default when run from %s',
156
+ async (folder, reported) => {
157
+ const res = await upgrade({from: '0.6.8', apply: true}, {cwd: path.join(dir, folder)});
158
+
159
+ expect(res.type).toBe('upgrade.run');
160
+ expect(res.data.sourcePathFound).toBe(true);
161
+ expect(res.data.filesChanged).toBe(1);
162
+ expect(res.data.modifiedFiles).toEqual([reported]);
163
+ expect(appSource()).toContain('emptySearchText="None"');
164
+ },
165
+ SLOW,
166
+ );
167
+
168
+ it('reads a path you name relative to the folder you ran in', async () => {
169
+ const res = await upgrade({from: '0.6.8', path: '../..', apply: true}, {cwd: path.join(dir, 'src', 'l1', 'l2')});
170
+
171
+ expect(res.type).toBe('upgrade.run');
172
+ expect(res.data.filesChanged).toBe(1);
173
+ expect(appSource()).toContain('emptySearchText="None"');
174
+ }, SLOW);
175
+
176
+ it('still rejects a path outside the project', async () => {
177
+ await expect(
178
+ upgrade({from: '0.6.8', path: '../../..'}, {cwd: path.join(dir, 'src', 'l1')}),
179
+ ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
180
+ expect(appSource()).toBe(OLD_PROP_SOURCE);
181
+ }, SLOW);
182
+
183
+ it("refreshes the agent docs at the project root, not the folder it ran in", async () => {
184
+ fs.writeFileSync(
185
+ path.join(dir, 'AGENTS.md'),
186
+ `# Agents\n\n${generateCompressedIndex('0.6.8')}\n`,
187
+ );
188
+
189
+ const res = await upgrade({from: '0.6.8', apply: true}, {cwd: path.join(dir, 'src', 'l1')});
190
+
191
+ expect(res.type).toBe('upgrade.run');
192
+ expect(res.data.agentDocs.action).toBe('refreshed');
193
+ expect(res.data.agentDocs.files).toEqual(['../../AGENTS.md']);
194
+ expect(fs.existsSync(path.join(dir, 'src', 'l1', 'AGENTS.md'))).toBe(false);
195
+ }, SLOW);
196
+ });
197
+
198
+ describe('upgrade.run — the result names files relative to the folder it ran in', () => {
199
+ let dir;
200
+ beforeEach(() => {
201
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upg-receipt-'));
202
+ seedOldPropProject(dir);
203
+ fs.writeFileSync(path.join(dir, 'src', 'Generated.tsx'), `// @generated\n${OLD_PROP_SOURCE}`);
204
+ fs.writeFileSync(path.join(dir, 'src', 'Vendored.tsx'), OLD_PROP_SOURCE);
205
+ fs.writeFileSync(path.join(dir, '.gitattributes'), 'src/Vendored.tsx linguist-generated\n');
206
+ fs.writeFileSync(path.join(dir, 'AGENTS.md'), `# Agents\n\n${generateCompressedIndex('0.6.8')}\n`);
207
+ fs.mkdirSync(path.join(dir, 'frontend'), {recursive: true});
208
+ });
209
+ afterEach(() => fs.rmSync(dir, {recursive: true, force: true}));
210
+
211
+ it.each([
212
+ ['frontend', '../'],
213
+ ['.', ''],
214
+ ])('from %s, every file path resolves from that folder', async (folder, up) => {
215
+ const cwd = path.join(dir, folder);
216
+
217
+ const res = await upgrade({from: '0.6.8'}, {cwd});
218
+
219
+ expect(res.type).toBe('upgrade.run');
220
+ expect(res.data.modifiedFiles).toEqual([`${up}src/App.tsx`]);
221
+ expect(res.data.protectedFiles.map(item => item.file)).toEqual([
222
+ `${up}src/Generated.tsx`,
223
+ `${up}src/Vendored.tsx`,
224
+ ]);
225
+ for (const file of [...res.data.modifiedFiles, ...res.data.protectedFiles.map(item => item.file)]) {
226
+ expect(fs.existsSync(path.join(cwd, file))).toBe(true);
227
+ }
228
+ // The file a declaration names resolves from that folder too.
229
+ const vendored = res.data.protectedFiles.find(item => item.file.endsWith('Vendored.tsx'));
230
+ expect(vendored?.declarations).toEqual([`linguist-generated in ${up}.gitattributes:1`]);
231
+ expect(fs.existsSync(path.join(cwd, `${up}.gitattributes`))).toBe(true);
232
+ }, SLOW);
233
+
234
+ it('leaves a result from a run at the project root as it is', () => {
235
+ const result = {type: 'upgrade.run', data: {modifiedFiles: ['src/App.tsx']}};
236
+
237
+ expect(relativeToOrigin(result, {cwd: dir})).toBe(result);
238
+ });
239
+
240
+ it('rewrites every project-relative path and passes the copied-composition summary through', () => {
241
+ const registryCompositions = {items: [{item: 'example', path: 'components/Example.tsx', files: []}]};
242
+ const result = relativeToOrigin(
243
+ {
244
+ type: 'upgrade.run',
245
+ data: {
246
+ agentDocs: {status: 'stale', files: ['AGENTS.md']},
247
+ modifiedFiles: ['src/App.tsx'],
248
+ protectedFiles: [
249
+ {
250
+ file: 'src/Generated.tsx',
251
+ codemods: ['a'],
252
+ declarations: [
253
+ '@generated marker',
254
+ 'excluded by .gitignore',
255
+ 'excluded by src/.hgignore:3',
256
+ 'linguist-generated in .gitattributes:1',
257
+ 'symbolic link at src/linked',
258
+ 'installed dependency metadata node_modules/x',
259
+ ],
260
+ },
261
+ ],
262
+ declinedCandidates: [{file: 'src/Loose.tsx', reason: 'unproven'}],
263
+ errors: [{file: '.', codemod: 'post-codemod-hook', error: 'failed'}],
264
+ registryCompositions,
265
+ },
266
+ },
267
+ {cwd: dir, origin: path.join(dir, 'apps', 'web')},
268
+ );
269
+
270
+ expect(result.data).toEqual({
271
+ agentDocs: {status: 'stale', files: ['../../AGENTS.md']},
272
+ modifiedFiles: ['../../src/App.tsx'],
273
+ protectedFiles: [
274
+ {
275
+ file: '../../src/Generated.tsx',
276
+ codemods: ['a'],
277
+ declarations: [
278
+ '@generated marker',
279
+ 'excluded by ../../.gitignore',
280
+ 'excluded by ../../src/.hgignore:3',
281
+ 'linguist-generated in ../../.gitattributes:1',
282
+ 'symbolic link at ../../src/linked',
283
+ 'installed dependency metadata node_modules/x',
284
+ ],
285
+ },
286
+ ],
287
+ declinedCandidates: [{file: '../../src/Loose.tsx', reason: 'unproven'}],
288
+ errors: [{file: '../..', codemod: 'post-codemod-hook', error: 'failed'}],
289
+ registryCompositions,
290
+ });
291
+ });
292
+ });
293
+
294
+ describe("upgrade.run — the target Core is the project's own", () => {
295
+ let dir;
296
+ beforeEach(() => {
297
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upg-own-core-'));
298
+ seedOldPropProject(dir);
299
+ });
300
+ afterEach(() => fs.rmSync(dir, {recursive: true, force: true}));
301
+
302
+ /**
303
+ * A project nested in `dir`, with src/App.tsx using the renamed prop.
304
+ * @param {Record<string, string>} dependencies
305
+ */
306
+ function nestedProject(dependencies) {
307
+ const nested = path.join(dir, 'apps', 'web');
308
+ fs.mkdirSync(path.join(nested, 'src'), {recursive: true});
309
+ fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({name: 'web', dependencies}));
310
+ fs.writeFileSync(path.join(nested, 'src', 'App.tsx'), OLD_PROP_SOURCE);
311
+ return nested;
312
+ }
313
+
314
+ it.each([
315
+ ['declares no Astryx package', {}],
316
+ ['declares a Core it has not installed', {'@astryxdesign/core': '0.6.8'}],
317
+ ])(
318
+ 'does not upgrade a project that %s to the Core of the home folder above it',
319
+ async (_label, dependencies) => {
320
+ const nested = nestedProject(dependencies);
321
+
322
+ await withHome(dir, () =>
323
+ expect(
324
+ upgrade({from: '0.6.8', apply: true}, {cwd: nested}),
325
+ ).rejects.toMatchObject({code: 'ERR_VERSION_DETECT'}),
326
+ );
327
+ expect(fs.readFileSync(path.join(nested, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
328
+ expect(fs.readFileSync(path.join(dir, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
329
+ },
330
+ SLOW,
331
+ );
332
+
333
+ it('names the folder it searched before stopping on a missing Core, from a subfolder (AST-040 FR12)', async () => {
334
+ fs.rmSync(path.join(dir, 'node_modules'), {recursive: true, force: true});
335
+ const sub = path.join(dir, 'src', 'l1');
336
+ const log = vi.spyOn(logger, 'log');
337
+ try {
338
+ await expect(upgrade({from: '0.6.8'}, {cwd: sub})).rejects.toMatchObject({
339
+ code: 'ERR_VERSION_DETECT',
340
+ });
341
+ const lines = log.mock.calls.map(([line]) => String(line));
342
+ expect(lines).toContain('Project root: ../..');
343
+ expect(lines.indexOf('Project root: ../..')).toBeLessThan(
344
+ lines.indexOf('Aborted\n'),
345
+ );
346
+ } finally {
347
+ log.mockRestore();
348
+ }
349
+ }, SLOW);
350
+
351
+ it('does not upgrade a nested project that declares a Core it has not installed to the Core above it', async () => {
352
+ const nested = nestedProject({'@astryxdesign/core': '0.6.8'});
353
+
354
+ await expect(
355
+ upgrade({from: '0.6.8', apply: true}, {cwd: nested}),
356
+ ).rejects.toMatchObject({code: 'ERR_VERSION_DETECT'});
357
+ expect(fs.readFileSync(path.join(nested, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
358
+ }, SLOW);
359
+
360
+ it.each([
361
+ ['an override at the workspace root forces', {overrides: {'@astryxdesign/core': '0.7.0'}}, {'@astryxdesign/core': '~0.6.8'}],
362
+ ['a resolution at the workspace root forces', {resolutions: {'@astryxdesign/core': '0.7.0'}}, {'@astryxdesign/core': '0.6.8'}],
363
+ ['only a stale peer range names', {}, null],
364
+ ['the member declares another version of, not yet installed', {}, {'@astryxdesign/core': '0.9.0'}],
365
+ ])(
366
+ 'upgrades a workspace member to a hoisted Core that %s',
367
+ async (_label, rootFields, dependencies) => {
368
+ fs.writeFileSync(
369
+ path.join(dir, 'package.json'),
370
+ JSON.stringify({name: 'root', private: true, workspaces: ['apps/*'], ...rootFields}),
371
+ );
372
+ const member = dependencies
373
+ ? nestedProject(dependencies)
374
+ : nestedProject({});
375
+ if (!dependencies) {
376
+ fs.writeFileSync(
377
+ path.join(member, 'package.json'),
378
+ JSON.stringify({name: 'web', peerDependencies: {'@astryxdesign/core': '^0.6.8'}}),
379
+ );
380
+ }
381
+
382
+ const res = await upgrade({from: '0.6.8', apply: true}, {cwd: member});
383
+
384
+ expect(res.type).toBe('upgrade.run');
385
+ expect(res.data.to).toBe('0.7.0');
386
+ expect(res.data.modifiedFiles).toEqual(['src/App.tsx']);
387
+ expect(fs.readFileSync(path.join(member, 'src', 'App.tsx'), 'utf-8')).toContain('emptySearchText="None"');
388
+ },
389
+ SLOW,
390
+ );
391
+
392
+ it('does not upgrade a project its workspace does not list to the hoisted Core', async () => {
393
+ fs.writeFileSync(
394
+ path.join(dir, 'package.json'),
395
+ JSON.stringify({name: 'root', private: true, workspaces: ['packages/*']}),
396
+ );
397
+ const project = nestedProject({'@astryxdesign/core': '0.9.0'});
398
+
399
+ await expect(
400
+ upgrade({from: '0.6.8', apply: true}, {cwd: project}),
401
+ ).rejects.toMatchObject({code: 'ERR_VERSION_DETECT'});
402
+ expect(fs.readFileSync(path.join(project, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
403
+ }, SLOW);
404
+
405
+ it.each([[['apps/*']], [['{apps/*,tools/*}']]])('upgrades a member that declares no Astryx package, in its own src, when the workspace lists %j', async workspaces => {
406
+ fs.writeFileSync(
407
+ path.join(dir, 'package.json'),
408
+ JSON.stringify({name: 'root', private: true, workspaces, dependencies: {'@astryxdesign/core': '0.7.0'}}),
409
+ );
410
+ const member = nestedProject({});
411
+
412
+ const res = await upgrade({from: '0.6.8', apply: true}, {cwd: path.join(member, 'src')});
413
+
414
+ expect(res.type).toBe('upgrade.run');
415
+ expect(res.data.modifiedFiles).toEqual(['App.tsx']);
416
+ expect(fs.readFileSync(path.join(member, 'src', 'App.tsx'), 'utf-8')).toContain('emptySearchText="None"');
417
+ expect(fs.readFileSync(path.join(dir, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
418
+ }, SLOW);
419
+ });
420
+
421
+ describe('upgrade.run — the project is the nearest folder that declares Astryx', () => {
422
+ let home;
423
+ beforeEach(() => {
424
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'upg-home-'));
425
+ });
426
+ afterEach(() => fs.rmSync(home, {recursive: true, force: true}));
427
+
428
+ // Astryx installed in a home folder whose src holds loose files and another
429
+ // project, run from a folder with no package.json.
430
+ it('does not upgrade the home folder from a folder inside it', async () => {
431
+ seedOldPropProject(home);
432
+ fs.mkdirSync(path.join(home, 'src', 'repoA', 'src'), {recursive: true});
433
+ fs.writeFileSync(
434
+ path.join(home, 'src', 'repoA', 'package.json'),
435
+ JSON.stringify({name: 'repoA', dependencies: {'@astryxdesign/core': '0.6.8'}}),
436
+ );
437
+ fs.writeFileSync(path.join(home, 'src', 'repoA', 'src', 'a.tsx'), OLD_PROP_SOURCE);
438
+ const desktop = path.join(home, 'Desktop');
439
+ fs.mkdirSync(desktop, {recursive: true});
440
+
441
+ await withHome(home, () =>
442
+ expect(
443
+ upgrade({from: '0.6.8', apply: true}, {cwd: desktop}),
444
+ ).rejects.toMatchObject({code: 'ERR_VERSION_DETECT'}),
445
+ );
446
+ expect(fs.readFileSync(path.join(home, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
447
+ expect(fs.readFileSync(path.join(home, 'src', 'repoA', 'src', 'a.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
448
+ }, SLOW);
449
+
450
+ // Installing Core does not make a folder a project.
451
+ it('treats a folder that installs Core without declaring it as part of the project above it', async () => {
452
+ seedOldPropProject(home);
453
+ const tool = path.join(home, 'tools', 'codegen');
454
+ const core = path.join(tool, 'node_modules', '@astryxdesign', 'core');
455
+ fs.mkdirSync(core, {recursive: true});
456
+ fs.writeFileSync(path.join(tool, 'package.json'), JSON.stringify({name: 'codegen'}));
457
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({name: '@astryxdesign/core', version: '0.9.0'}));
458
+
459
+ const res = await upgrade({from: '0.6.8', apply: true}, {cwd: tool});
460
+
461
+ expect(res.type).toBe('upgrade.run');
462
+ expect(res.data.to).toBe('0.7.0');
463
+ expect(res.data.modifiedFiles).toEqual(['../../src/App.tsx']);
464
+ }, SLOW);
465
+
466
+ it('names the project root and the folder it scans before a dry run in a folder that installs Core without declaring it', async () => {
467
+ seedOldPropProject(home);
468
+ const tool = path.join(home, 'tools', 'codegen');
469
+ const core = path.join(tool, 'node_modules', '@astryxdesign', 'core');
470
+ fs.mkdirSync(core, {recursive: true});
471
+ fs.writeFileSync(path.join(tool, 'package.json'), JSON.stringify({name: 'codegen'}));
472
+ fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({name: '@astryxdesign/core', version: '0.9.0'}));
473
+ const log = vi.spyOn(logger, 'log');
474
+ try {
475
+ const res = await upgrade({from: '0.6.8'}, {cwd: tool});
476
+
477
+ expect(res.type).toBe('upgrade.run');
478
+ const lines = log.mock.calls.map(([line]) => String(line));
479
+ expect(lines).toContain('Project root: ../..');
480
+ expect(lines).toContain(`Source folder: ${path.join('..', '..', 'src')}`);
481
+ expect(fs.readFileSync(path.join(home, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
482
+ } finally {
483
+ log.mockRestore();
484
+ }
485
+ }, SLOW);
486
+
487
+ it('upgrades the home folder as a folder outside any project, when run there', async () => {
488
+ seedOldPropProject(home);
489
+
490
+ const res = await withHome(home, () => upgrade({from: '0.6.8', apply: true}, {cwd: home}));
491
+
492
+ expect(res.type).toBe('upgrade.run');
493
+ expect(res.data.modifiedFiles).toEqual(['src/App.tsx']);
494
+ expect(fs.readFileSync(path.join(home, 'src', 'App.tsx'), 'utf-8')).toContain('emptySearchText="None"');
495
+ }, SLOW);
496
+
497
+ it('does not leave a repository for the project around it', async () => {
498
+ seedOldPropProject(home);
499
+ const repo = path.join(home, 'vendor', 'repo');
500
+ fs.mkdirSync(path.join(repo, '.git'), {recursive: true});
501
+ fs.mkdirSync(path.join(repo, 'src'), {recursive: true});
502
+ fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({name: 'repo'}));
503
+ fs.writeFileSync(path.join(repo, 'src', 'App.tsx'), OLD_PROP_SOURCE);
504
+
505
+ await expect(
506
+ upgrade({from: '0.6.8', apply: true}, {cwd: repo}),
507
+ ).rejects.toMatchObject({code: 'ERR_VERSION_DETECT'});
508
+ expect(fs.readFileSync(path.join(repo, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
509
+ expect(fs.readFileSync(path.join(home, 'src', 'App.tsx'), 'utf-8')).toBe(OLD_PROP_SOURCE);
510
+ }, SLOW);
511
+ });
@@ -77,8 +77,9 @@ export const doc = {
77
77
  {
78
78
  name: 'options.path',
79
79
  type: 'string',
80
- description: 'Source directory to scan.',
81
- default: './src',
80
+ description:
81
+ 'Source directory to scan. A path you name, in any spelling, is relative to `ctx.cwd`. When it is omitted, the upgrade scans `./src` at the project root.',
82
+ default: './src at the project root',
82
83
  },
83
84
  {
84
85
  name: 'options.installDeps',
@@ -103,7 +104,8 @@ export const doc = {
103
104
  {
104
105
  name: 'ctx.cwd',
105
106
  type: 'string',
106
- description: 'Directory to run the upgrade in.',
107
+ description:
108
+ 'Any folder in the project to upgrade. The upgrade runs at the project root: the nearest folder at or above it whose package.json declares an Astryx package, or that a workspace whose root declares one lists, found without passing a repository root or counting the home folder. With no such folder, it runs in `ctx.cwd`. File paths in the result are relative to `ctx.cwd`, except that a run at the project root names copied-composition paths relative to the scanned source directory.',
107
109
  default: 'process.cwd()',
108
110
  },
109
111
  ],
@@ -135,10 +137,13 @@ export const doc = {
135
137
  when: '`from` is missing (and neither `list` nor `registry` is set); `list` and `registry` are both set; `list` is combined with `from`, `force`, `apply`, `path` (non-default), `codemod`, `skipCodemod`, `integration` or `installDeps`; `registry` is combined with `from`, `force`, `codemod`, `skipCodemod`, `integration` or `installDeps`; an `integration` specifier is invalid or not installed; or astryx.config fails to load or validate and no pending config codemod repairs it',
136
138
  },
137
139
  {code: 'ERR_INVALID_VERSION', when: '`from` is not a valid semver string'},
138
- {code: 'ERR_PATH_TRAVERSAL', when: '`path` resolves outside cwd'},
140
+ {
141
+ code: 'ERR_PATH_TRAVERSAL',
142
+ when: '`path` resolves outside the project root',
143
+ },
139
144
  {
140
145
  code: 'ERR_VERSION_DETECT',
141
- when: 'neither @astryxdesign/core nor legacy @xds/core is installed in cwd; with `registry`, only when copied-composition receipts exist and @astryxdesign/core is not installed',
146
+ when: "the project has installed neither @astryxdesign/core nor legacy @xds/core in its own node_modules, nor, for a member its workspace lists, in a folder up to the workspace root (with no Astryx project, in `ctx.cwd`'s own node_modules); with `registry`, only when copied-composition receipts exist and @astryxdesign/core is not installed",
142
147
  },
143
148
  {
144
149
  code: 'ERR_DEP_MISSING',