@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
@@ -4,7 +4,7 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
- import {findCoreDir, findProjectCoreDir, findProjectRoot, listComponents, discoverExternalPackages, existsCaseExact, findInstalledPackage} from './paths.mjs';
7
+ import {findAstryxProject, findCoreDir, findProjectCoreDir, findProjectRoot, listComponents, discoverExternalPackages, existsCaseExact, findInstalledPackage, findNodeModulesPackage, findPackageRoot, findProjectPackageOf, findWorkspaceRoot} from './paths.mjs';
8
8
  import {CLI_ROOT} from './paths.mjs';
9
9
  import {linkThisCliAsProjectCli} from '../../test-utils/release-fixtures.mjs';
10
10
 
@@ -19,13 +19,93 @@ beforeEach(() => {
19
19
  });
20
20
 
21
21
  afterEach(() => {
22
+ delete process.versions.pnp;
22
23
  fs.rmSync(tmpDir, {recursive: true, force: true});
23
24
  });
24
25
 
26
+ /**
27
+ * Write a package.json naming `name` into `dir`, creating the folder.
28
+ * @param {string} dir @param {string} name @param {string} [version]
29
+ */
30
+ function writePackage(dir, name, version = '0.7.0') {
31
+ fs.mkdirSync(dir, {recursive: true});
32
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name, version}));
33
+ return dir;
34
+ }
35
+
36
+ /**
37
+ * Write a project package.json into `dir`, creating the folder.
38
+ * @param {string} dir @param {Record<string, unknown>} [fields]
39
+ */
40
+ function writeProject(dir, fields = {}) {
41
+ fs.mkdirSync(dir, {recursive: true});
42
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: path.basename(dir), ...fields}));
43
+ return dir;
44
+ }
45
+
46
+ /**
47
+ * Run `fn` with `home` as the home folder.
48
+ * @template T
49
+ * @param {string} home @param {() => T} fn
50
+ * @returns {T}
51
+ */
52
+ function withHome(home, fn) {
53
+ const previous = process.env.HOME;
54
+ process.env.HOME = home;
55
+ try {
56
+ return fn();
57
+ } finally {
58
+ if (previous === undefined) delete process.env.HOME;
59
+ else process.env.HOME = previous;
60
+ }
61
+ }
62
+
63
+ /** Declares Core, which makes a package.json an Astryx project. */
64
+ const DECLARES_CORE = {dependencies: {'@astryxdesign/core': '^0.7.0'}};
65
+
66
+ /** @param {string} root @param {string} name @param {string} version */
67
+ function install(root, name, version) {
68
+ return writePackage(path.join(root, 'node_modules', ...name.split('/')), name, version);
69
+ }
70
+
71
+ /**
72
+ * Stand in for the runtime a Yarn Plug'n'Play process exposes:
73
+ * `process.versions.pnp` plus a requirable `pnpapi`. The packages themselves
74
+ * live outside any node_modules, the way Yarn stores them. The top-level
75
+ * afterEach removes `process.versions.pnp` again.
76
+ * @param {Record<string, string>} resolved name -> package directory
77
+ * @returns {string} a project folder inside the PnP runtime
78
+ */
79
+ function withPnp(resolved) {
80
+ const runtime = path.join(tmpDir, 'runtime');
81
+ const api = path.join(runtime, 'node_modules', 'pnpapi');
82
+ fs.mkdirSync(api, {recursive: true});
83
+ fs.writeFileSync(
84
+ path.join(api, 'package.json'),
85
+ JSON.stringify({name: 'pnpapi', version: '1.0.0', main: 'index.js'}),
86
+ );
87
+ fs.writeFileSync(
88
+ path.join(api, 'index.js'),
89
+ `const resolved = ${JSON.stringify(resolved)};\n` +
90
+ 'module.exports = {\n' +
91
+ ' resolveToUnqualified(name) {\n' +
92
+ ' if (!(name in resolved)) {\n' +
93
+ " const e = new Error(name); e.code = 'MODULE_NOT_FOUND'; throw e;\n" +
94
+ ' }\n' +
95
+ ' return resolved[name];\n' +
96
+ ' },\n' +
97
+ '};\n',
98
+ );
99
+ Object.defineProperty(process.versions, 'pnp', {
100
+ value: '3',
101
+ configurable: true,
102
+ });
103
+ return runtime;
104
+ }
105
+
25
106
  describe('findCoreDir', () => {
26
107
  it('finds packages/core walking up directories', () => {
27
- const coreDir = path.join(tmpDir, 'packages', 'core');
28
- fs.mkdirSync(coreDir, {recursive: true});
108
+ const coreDir = writePackage(path.join(tmpDir, 'packages', 'core'), '@astryxdesign/core');
29
109
 
30
110
  const nested = path.join(tmpDir, 'sub', 'deep');
31
111
  fs.mkdirSync(nested, {recursive: true});
@@ -34,8 +114,7 @@ describe('findCoreDir', () => {
34
114
  });
35
115
 
36
116
  it('finds node_modules/@astryxdesign/core fallback', () => {
37
- const nmCore = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
38
- fs.mkdirSync(nmCore, {recursive: true});
117
+ const nmCore = install(tmpDir, '@astryxdesign/core', '0.7.0');
39
118
 
40
119
  expect(findCoreDir(tmpDir)).toBe(nmCore);
41
120
  });
@@ -48,22 +127,111 @@ describe('findCoreDir', () => {
48
127
  // This copy's carried Core is the workspace Core beside it.
49
128
  expect(findCoreDir(tmpDir)).toBe(path.resolve(CLI_ROOT, '..', 'core'));
50
129
  linkThisCliAsProjectCli(tmpDir);
51
- expect(findCoreDir(tmpDir)).toBeNull();
130
+ // The project's own Core: findCoreDir falls back to the carried one.
131
+ expect(findProjectCoreDir(tmpDir)).toBeNull();
132
+ });
133
+
134
+ // A route folder such as src/app/(shop)/orders/[id]/edit sits six folders
135
+ // below the project root, where Core is installed.
136
+ it('finds Core from any depth inside the project that installs it', () => {
137
+ writeProject(tmpDir, {dependencies: {'@astryxdesign/core': '^0.7.0'}});
138
+ const nmCore = install(tmpDir, '@astryxdesign/core', '0.7.0');
139
+ const route = path.join(tmpDir, 'src', 'app', '(shop)', 'orders', '[id]', 'edit');
140
+ fs.mkdirSync(route, {recursive: true});
141
+
142
+ expect(findCoreDir(route)).toBe(nmCore);
143
+ });
144
+
145
+ it('searches a workspace up to its root from any depth in a member', () => {
146
+ writeProject(tmpDir, {private: true, workspaces: ['apps/*']});
147
+ const nmCore = install(tmpDir, '@astryxdesign/core', '0.7.0');
148
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'), {dependencies: {'@astryxdesign/core': '^0.7.0'}});
149
+ const route = path.join(member, 'src', 'app', '(shop)', 'orders', '[id]');
150
+ fs.mkdirSync(route, {recursive: true});
151
+
152
+ expect(findCoreDir(route)).toBe(nmCore);
153
+ });
154
+
155
+ it('searches only the folder and the four above it outside any Astryx project', () => {
156
+ install(tmpDir, '@astryxdesign/core', '0.7.0');
157
+ const project = writeProject(path.join(tmpDir, 'a', 'b', 'c', 'd', 'e', 'app'));
158
+
159
+ // The project's own Core: findCoreDir falls back to the carried one.
160
+ expect(findProjectCoreDir(project)).toBeNull();
161
+ });
162
+
163
+ // A package.json that only sets the module type is not the project, so a
164
+ // folder deep below it still reaches the Core the project installs.
165
+ it('looks past a nested package.json that declares no Astryx package', () => {
166
+ writeProject(tmpDir, DECLARES_CORE);
167
+ const nmCore = install(tmpDir, '@astryxdesign/core', '0.7.0');
168
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
169
+ fs.writeFileSync(path.join(tmpDir, 'src', 'package.json'), JSON.stringify({type: 'module'}));
170
+ const deep = path.join(tmpDir, 'src', 'l1', 'l2', 'l3', 'l4', 'l5', 'l6');
171
+ fs.mkdirSync(deep, {recursive: true});
172
+
173
+ expect(findCoreDir(deep)).toBe(nmCore);
174
+ });
175
+
176
+ // Past the reach the search starts at the project root: a tool folder
177
+ // between them that installs its own Core but declares nothing never
178
+ // answers for the project, as `upgrade` targets the project's Core.
179
+ it('answers the project\'s Core, not a tool folder\'s, from deep inside the tool', () => {
180
+ const app = path.join(tmpDir, 'app');
181
+ writeProject(app, DECLARES_CORE);
182
+ const projectCore = install(app, '@astryxdesign/core', '0.7.0');
183
+ const tool = path.join(app, 'tools', 'codegen');
184
+ writePackage(tool, 'codegen');
185
+ install(tool, '@astryxdesign/core', '0.9.0');
186
+ const deep = path.join(tool, 'src', 'a', 'b', 'c', 'd', 'e');
187
+ fs.mkdirSync(deep, {recursive: true});
188
+
189
+ expect(findProjectCoreDir(deep)).toBe(projectCore);
190
+ expect(findInstalledPackage(path.join(deep, 'f'), '@astryxdesign/core')).toBe(projectCore);
191
+ });
192
+
193
+ // Node loads the hoisted copy whatever the member declares: an override or
194
+ // resolution can force it, and a peer range can be stale. Reads answer from
195
+ // the copy that loads.
196
+ it.each([
197
+ ['a range the copy is outside of', {devDependencies: {'@astryxdesign/core': '~0.6.8'}}],
198
+ ['only a stale peer range', {peerDependencies: {'@astryxdesign/core': '^0.6.8'}}],
199
+ ])('takes a workspace Core when the member declares %s', (_label, fields) => {
200
+ writeProject(tmpDir, {private: true, workspaces: ['apps/*'], overrides: {'@astryxdesign/core': '0.7.1'}});
201
+ const nmCore = install(tmpDir, '@astryxdesign/core', '0.7.1');
202
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'), fields);
203
+
204
+ expect(findCoreDir(member)).toBe(nmCore);
205
+ });
206
+
207
+ it('skips a packages/core folder that holds another package', () => {
208
+ writePackage(path.join(tmpDir, 'packages', 'core'), '@acme/core', '1.0.0');
209
+ const app = path.join(tmpDir, 'apps', 'web');
210
+ fs.mkdirSync(app, {recursive: true});
211
+
212
+ // The project's own Core: findCoreDir falls back to the carried one.
213
+ expect(findProjectCoreDir(app)).toBeNull();
214
+
215
+ const nmCore = install(tmpDir, '@astryxdesign/core', '0.7.0');
216
+ expect(findCoreDir(app)).toBe(nmCore);
217
+ });
218
+
219
+ it('skips a node_modules/@astryxdesign/core folder with no package.json', () => {
220
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', '@astryxdesign', 'core'), {recursive: true});
221
+
222
+ // The project's own Core: findCoreDir falls back to the carried one.
223
+ expect(findProjectCoreDir(tmpDir)).toBeNull();
224
+ });
225
+
226
+ it("asks Yarn Plug'n'Play when no folder holds Core", () => {
227
+ const store = writePackage(path.join(tmpDir, 'store', '@astryxdesign-core'), '@astryxdesign/core');
228
+ const project = withPnp({'@astryxdesign/core': store});
229
+
230
+ expect(findCoreDir(project)).toBe(store);
52
231
  });
53
232
  });
54
233
 
55
234
  describe('findInstalledPackage', () => {
56
- /** @param {string} root @param {string} name @param {string} version */
57
- function install(root, name, version) {
58
- const dir = path.join(root, 'node_modules', ...name.split('/'));
59
- fs.mkdirSync(dir, {recursive: true});
60
- fs.writeFileSync(
61
- path.join(dir, 'package.json'),
62
- JSON.stringify({name, version}),
63
- );
64
- return dir;
65
- }
66
-
67
235
  it('finds a package in the node_modules chain above startDir', () => {
68
236
  const installed = install(tmpDir, '@astryxdesign/core', '0.6.0');
69
237
  const nested = path.join(tmpDir, 'src', 'deep');
@@ -72,6 +240,22 @@ describe('findInstalledPackage', () => {
72
240
  expect(findInstalledPackage(nested, '@astryxdesign/core')).toBe(installed);
73
241
  });
74
242
 
243
+ it('finds a package from any depth inside the project', () => {
244
+ writeProject(tmpDir, DECLARES_CORE);
245
+ const installed = install(tmpDir, '@astryxdesign/core', '0.7.0');
246
+ const deep = path.join(tmpDir, 'apps', 'web', 'src', 'app', '(shop)', 'orders', '[id]');
247
+ fs.mkdirSync(deep, {recursive: true});
248
+
249
+ expect(findInstalledPackage(deep, '@astryxdesign/core')).toBe(installed);
250
+ });
251
+
252
+ it('does not search past six folders outside the project', () => {
253
+ install(tmpDir, '@astryxdesign/core', '0.7.0');
254
+ const project = writeProject(path.join(tmpDir, 'a', 'b', 'c', 'd', 'e', 'f', 'app'));
255
+
256
+ expect(findInstalledPackage(project, '@astryxdesign/core')).toBeNull();
257
+ });
258
+
75
259
  it('reports a package the project never installed as missing', () => {
76
260
  expect(findInstalledPackage(tmpDir, '@astryxdesign/core')).toBeNull();
77
261
  });
@@ -96,43 +280,6 @@ describe('findInstalledPackage', () => {
96
280
  });
97
281
 
98
282
  describe('under Yarn Plug\'n\'Play, which has no node_modules', () => {
99
- /**
100
- * Stand in for the runtime a PnP process exposes: `process.versions.pnp`
101
- * plus a requirable `pnpapi`. The package itself lives outside any
102
- * node_modules, the way Yarn stores it.
103
- * @param {Record<string, string>} resolved name -> package directory
104
- */
105
- function withPnp(resolved) {
106
- const runtime = path.join(tmpDir, 'runtime');
107
- const api = path.join(runtime, 'node_modules', 'pnpapi');
108
- fs.mkdirSync(api, {recursive: true});
109
- fs.writeFileSync(
110
- path.join(api, 'package.json'),
111
- JSON.stringify({name: 'pnpapi', version: '1.0.0', main: 'index.js'}),
112
- );
113
- fs.writeFileSync(
114
- path.join(api, 'index.js'),
115
- `const resolved = ${JSON.stringify(resolved)};\n` +
116
- 'module.exports = {\n' +
117
- ' resolveToUnqualified(name) {\n' +
118
- ' if (!(name in resolved)) {\n' +
119
- " const e = new Error(name); e.code = 'MODULE_NOT_FOUND'; throw e;\n" +
120
- ' }\n' +
121
- ' return resolved[name];\n' +
122
- ' },\n' +
123
- '};\n',
124
- );
125
- Object.defineProperty(process.versions, 'pnp', {
126
- value: '3',
127
- configurable: true,
128
- });
129
- return runtime;
130
- }
131
-
132
- afterEach(() => {
133
- delete process.versions.pnp;
134
- });
135
-
136
283
  it('falls back to the PnP runtime when there is no chain to walk', () => {
137
284
  const store = path.join(tmpDir, 'store', '@astryxdesign-core');
138
285
  fs.mkdirSync(store, {recursive: true});
@@ -153,6 +300,235 @@ describe('findInstalledPackage', () => {
153
300
  });
154
301
  });
155
302
 
303
+ describe('findProjectPackageOf', () => {
304
+ const NAMES = ['@astryxdesign/core', '@xds/core'];
305
+
306
+ it('takes the earlier name when one folder installs both', () => {
307
+ writeProject(tmpDir, DECLARES_CORE);
308
+ const current = install(tmpDir, '@astryxdesign/core', '0.7.0');
309
+ install(tmpDir, '@xds/core', '0.3.0');
310
+
311
+ expect(findProjectPackageOf(tmpDir, NAMES)).toEqual({name: '@astryxdesign/core', packageDir: current});
312
+ });
313
+
314
+ it('takes the nearest folder over an earlier name further up', () => {
315
+ writeProject(tmpDir, {private: true, workspaces: ['apps/*']});
316
+ install(tmpDir, '@astryxdesign/core', '0.7.0');
317
+ const member = writeProject(path.join(tmpDir, 'apps', 'legacy'), {dependencies: {'@xds/core': '0.3.0'}});
318
+ const legacy = install(member, '@xds/core', '0.3.0');
319
+
320
+ expect(findProjectPackageOf(member, NAMES)).toEqual({name: '@xds/core', packageDir: legacy});
321
+ });
322
+
323
+ it('returns null when none is installed', () => {
324
+ expect(findProjectPackageOf(tmpDir, NAMES)).toBeNull();
325
+ });
326
+
327
+ it('finds the package from a subfolder of the project', () => {
328
+ writeProject(tmpDir, DECLARES_CORE);
329
+ const current = install(tmpDir, '@astryxdesign/core', '0.7.0');
330
+ const deep = path.join(tmpDir, 'src', 'l1', 'l2');
331
+ fs.mkdirSync(deep, {recursive: true});
332
+
333
+ expect(findProjectPackageOf(deep, NAMES)).toEqual({name: '@astryxdesign/core', packageDir: current});
334
+ });
335
+
336
+ // A member uses what its workspace installs, whatever its own package.json
337
+ // says: an override or resolution can force the copy, and a range or peer
338
+ // range can be stale. Membership decides, not the declared version.
339
+ it.each([
340
+ ['~0.6.8', 'dependencies'],
341
+ ['0.7.0 - 0.8.0', 'dependencies'],
342
+ ['>= 0.7.0', 'dependencies'],
343
+ ['^0.6.8', 'peerDependencies'],
344
+ ])('takes the hoisted package for a member that declares %s in %s', (range, field) => {
345
+ writeProject(tmpDir, {private: true, workspaces: ['apps/*']});
346
+ const hoisted = install(tmpDir, '@astryxdesign/core', '0.7.1');
347
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'), {[field]: {'@astryxdesign/core': range}});
348
+
349
+ expect(findProjectPackageOf(member, NAMES)).toEqual({name: '@astryxdesign/core', packageDir: hoisted});
350
+ });
351
+
352
+ it.each([
353
+ ['Yarn object-form workspaces', {workspaces: {packages: ['apps/*']}}, null],
354
+ ['a ** pattern', {workspaces: ['apps/**']}, null],
355
+ ['a brace pattern', {workspaces: ['{apps,tools}/*']}, null],
356
+ ['a brace list of globs', {workspaces: ['{apps/*,tools/*}']}, null],
357
+ ['a nested brace list', {workspaces: ['{apps/{web,admin},tools/*}']}, null],
358
+ ['a pnpm-workspace.yaml block list', {}, "packages:\n - 'apps/*' # apps\n - \"tools/*\"\nonlyBuiltDependencies: []\n"],
359
+ ['a pnpm-workspace.yaml flow list', {}, "packages: ['tools/*', apps/*]\n"],
360
+ ])('takes the hoisted package for a member that %s lists', (_label, rootFields, yaml) => {
361
+ writeProject(tmpDir, {private: true, ...rootFields});
362
+ if (yaml) fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), yaml);
363
+ const hoisted = install(tmpDir, '@astryxdesign/core', '0.7.1');
364
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'), DECLARES_CORE);
365
+
366
+ expect(findProjectPackageOf(member, NAMES)).toEqual({name: '@astryxdesign/core', packageDir: hoisted});
367
+ });
368
+
369
+ it.each([
370
+ ['lists other folders', {workspaces: ['packages/*']}],
371
+ ['excludes it with a ! pattern', {workspaces: ['apps/*', '!apps/web']}],
372
+ ['lists other folders in a brace list of globs', {workspaces: ['{packages/*,tools/*}']}],
373
+ ])('stops at the project root when the workspace %s', (_label, rootFields) => {
374
+ writeProject(tmpDir, {private: true, ...rootFields});
375
+ install(tmpDir, '@astryxdesign/core', '0.7.1');
376
+ const project = writeProject(path.join(tmpDir, 'apps', 'web'), DECLARES_CORE);
377
+
378
+ expect(findProjectPackageOf(project, NAMES)).toBeNull();
379
+ });
380
+
381
+ // AST-040 FR11: a Plug'n'Play project that declares Core keeps no
382
+ // node_modules copy; the runtime answers for it.
383
+ it("asks Yarn Plug'n'Play for a project that declares Core and has no node_modules copy", () => {
384
+ const store = writePackage(path.join(tmpDir, 'store', '@astryxdesign-core'), '@astryxdesign/core', '0.7.1');
385
+ const project = withPnp({'@astryxdesign/core': store});
386
+ writeProject(project, DECLARES_CORE);
387
+
388
+ expect(fs.existsSync(path.join(project, 'node_modules', '@astryxdesign'))).toBe(false);
389
+ expect(findProjectPackageOf(project, NAMES)).toEqual({name: '@astryxdesign/core', packageDir: store});
390
+ });
391
+
392
+ // A project nested in another, outside any workspace, has its own install.
393
+ it('stops at the root of a nested project outside any workspace', () => {
394
+ writeProject(tmpDir, {dependencies: {'@astryxdesign/core': '0.8.0'}});
395
+ install(tmpDir, '@astryxdesign/core', '0.8.0');
396
+ const nested = writeProject(path.join(tmpDir, 'apps', 'web'), {dependencies: {'@astryxdesign/core': '0.9.0'}});
397
+
398
+ expect(findProjectPackageOf(nested, NAMES)).toBeNull();
399
+ });
400
+
401
+ // With no Astryx project, released versions read only this folder's own
402
+ // node_modules.
403
+ it('reads only the folder itself when no Astryx project contains it', () => {
404
+ writeProject(tmpDir);
405
+ install(tmpDir, '@astryxdesign/core', '0.7.1');
406
+ const plain = writeProject(path.join(tmpDir, 'plain'));
407
+
408
+ expect(findProjectPackageOf(plain, NAMES)).toBeNull();
409
+ const own = install(plain, '@astryxdesign/core', '0.7.1');
410
+ expect(findProjectPackageOf(plain, NAMES)).toEqual({name: '@astryxdesign/core', packageDir: own});
411
+ });
412
+ });
413
+
414
+ describe('findAstryxProject', () => {
415
+ it('is the nearest folder whose package.json declares an Astryx package', () => {
416
+ writeProject(tmpDir, DECLARES_CORE);
417
+ const tool = writeProject(path.join(tmpDir, 'tools', 'lint'));
418
+ const deep = path.join(tool, 'src');
419
+ fs.mkdirSync(deep, {recursive: true});
420
+
421
+ expect(findAstryxProject(deep)).toBe(tmpDir);
422
+ const app = writeProject(path.join(tmpDir, 'apps', 'web'), {devDependencies: {'@astryxdesign/cli': '0.7.1'}});
423
+ expect(findAstryxProject(app)).toBe(app);
424
+ });
425
+
426
+ it('counts a peer declaration and the legacy scope', () => {
427
+ const integration = writeProject(path.join(tmpDir, 'integration'), {peerDependencies: {'@astryxdesign/core': '^0.7.0'}});
428
+ const legacy = writeProject(path.join(tmpDir, 'legacy'), {dependencies: {'@xds/core': '0.3.0'}});
429
+
430
+ expect(findAstryxProject(integration)).toBe(integration);
431
+ expect(findAstryxProject(legacy)).toBe(legacy);
432
+ });
433
+
434
+ it('stops at a repository root', () => {
435
+ writeProject(tmpDir, DECLARES_CORE);
436
+ const repo = writeProject(path.join(tmpDir, 'repo'));
437
+ fs.mkdirSync(path.join(repo, '.git'));
438
+ const tools = path.join(repo, 'tools');
439
+ fs.mkdirSync(tools, {recursive: true});
440
+
441
+ expect(findAstryxProject(tools)).toBeNull();
442
+ });
443
+
444
+ it('never counts the home folder', () => {
445
+ writeProject(tmpDir, DECLARES_CORE);
446
+ const desktop = path.join(tmpDir, 'Desktop');
447
+ fs.mkdirSync(desktop, {recursive: true});
448
+
449
+ withHome(tmpDir, () => {
450
+ expect(findAstryxProject(desktop)).toBeNull();
451
+ expect(findAstryxProject(tmpDir)).toBeNull();
452
+ });
453
+ });
454
+
455
+ it('returns null when no folder declares Astryx', () => {
456
+ writeProject(tmpDir);
457
+
458
+ expect(findAstryxProject(tmpDir)).toBeNull();
459
+ });
460
+
461
+ it('counts a member its workspace lists when the workspace root declares Astryx', () => {
462
+ writeProject(tmpDir, {private: true, workspaces: ['apps/*'], ...DECLARES_CORE});
463
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'));
464
+ const tests = writeProject(path.join(member, 'e2e'));
465
+
466
+ expect(findAstryxProject(path.join(member, 'src'))).toBe(member);
467
+ expect(findAstryxProject(tests)).toBe(member);
468
+ const other = writeProject(path.join(tmpDir, 'tools', 'lint'));
469
+ expect(findAstryxProject(other)).toBe(tmpDir);
470
+ });
471
+ });
472
+
473
+ describe('findPackageRoot', () => {
474
+ it('finds the nearest package.json at or above the folder', () => {
475
+ writeProject(tmpDir);
476
+ const app = writeProject(path.join(tmpDir, 'apps', 'web'));
477
+ const deep = path.join(app, 'src', 'app');
478
+ fs.mkdirSync(deep, {recursive: true});
479
+
480
+ expect(findPackageRoot(deep)).toBe(app);
481
+ expect(findPackageRoot(tmpDir)).toBe(tmpDir);
482
+ });
483
+
484
+ it('returns null with no package.json', () => {
485
+ expect(findPackageRoot(tmpDir)).toBeNull();
486
+ });
487
+ });
488
+
489
+ describe('findWorkspaceRoot', () => {
490
+ it('finds the nearest folder that declares workspaces in package.json', () => {
491
+ writeProject(tmpDir, {private: true, workspaces: ['apps/*']});
492
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'));
493
+
494
+ expect(findWorkspaceRoot(path.join(member, 'src'))).toBe(tmpDir);
495
+ });
496
+
497
+ it('finds the nearest folder with pnpm-workspace.yaml', () => {
498
+ writeProject(tmpDir);
499
+ fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
500
+ const member = writeProject(path.join(tmpDir, 'apps', 'web'));
501
+
502
+ expect(findWorkspaceRoot(member)).toBe(tmpDir);
503
+ });
504
+
505
+ it('is the nearest package.json outside a workspace, and the folder itself with none', () => {
506
+ const app = writeProject(path.join(tmpDir, 'app'));
507
+ const src = path.join(app, 'src');
508
+ fs.mkdirSync(src, {recursive: true});
509
+
510
+ expect(findWorkspaceRoot(src)).toBe(app);
511
+ expect(findWorkspaceRoot(tmpDir)).toBe(tmpDir);
512
+ });
513
+ });
514
+
515
+ describe('findNodeModulesPackage', () => {
516
+ it('finds a package hoisted to a workspace root and names the folder that holds it', () => {
517
+ const hoisted = install(tmpDir, '@acme/widgets', '1.0.0');
518
+ const member = path.join(tmpDir, 'apps', 'web');
519
+ fs.mkdirSync(member, {recursive: true});
520
+
521
+ expect(findNodeModulesPackage(member, '@acme/widgets')).toEqual({packageDir: hoisted, hostDir: tmpDir});
522
+ });
523
+
524
+ it('does not ask Plug\'n\'Play', () => {
525
+ const store = writePackage(path.join(tmpDir, 'store', 'acme-widgets'), '@acme/widgets');
526
+ const project = withPnp({'@acme/widgets': store});
527
+
528
+ expect(findNodeModulesPackage(project, '@acme/widgets')).toBeNull();
529
+ });
530
+ });
531
+
156
532
  describe('findProjectRoot', () => {
157
533
  it('finds root with workspaces in package.json', () => {
158
534
  fs.writeFileSync(
@@ -21,7 +21,8 @@ export function readDeclaredDependencies(projectDir: string): Array<{
21
21
  *
22
22
  * Walking up matters because hoisting is normal — yarn and npm workspaces lift
23
23
  * a workspace member's dependency to the repo root, so the app's own
24
- * node_modules can be empty of a package it genuinely declares.
24
+ * node_modules can be empty of a package it genuinely declares. Integrations
25
+ * named in astryx.config resolve through the same walk.
25
26
  *
26
27
  * Presence is decided by the package's own package.json, not the directory, so
27
28
  * a stale empty directory is not mistaken for an install.
@@ -39,6 +39,7 @@
39
39
 
40
40
  import * as fs from 'node:fs';
41
41
  import * as path from 'node:path';
42
+ import {findNodeModulesPackage} from '../fs/paths.mjs';
42
43
  import {findManifestPaths, loadIntegrations} from './integrations.mjs';
43
44
  import {resolveProviders} from './provider-resolution.mjs';
44
45
 
@@ -123,7 +124,8 @@ export function readDeclaredDependencies(projectDir) {
123
124
  *
124
125
  * Walking up matters because hoisting is normal — yarn and npm workspaces lift
125
126
  * a workspace member's dependency to the repo root, so the app's own
126
- * node_modules can be empty of a package it genuinely declares.
127
+ * node_modules can be empty of a package it genuinely declares. Integrations
128
+ * named in astryx.config resolve through the same walk.
127
129
  *
128
130
  * Presence is decided by the package's own package.json, not the directory, so
129
131
  * a stale empty directory is not mistaken for an install.
@@ -135,18 +137,7 @@ export function readDeclaredDependencies(projectDir) {
135
137
  */
136
138
  export function resolveInstalledPackageDir(name, fromDir) {
137
139
  if (!isBarePackageName(name)) return null;
138
- const segments = name.split('/');
139
- let dir = path.resolve(fromDir);
140
- for (let i = 0; i < 50; i++) {
141
- const packageDir = path.join(dir, 'node_modules', ...segments);
142
- if (fs.existsSync(path.join(packageDir, 'package.json'))) {
143
- return {packageDir, hostDir: dir};
144
- }
145
- const parent = path.dirname(dir);
146
- if (parent === dir) break;
147
- dir = parent;
148
- }
149
- return null;
140
+ return findNodeModulesPackage(fromDir, name);
150
141
  }
151
142
 
152
143
  /**
@@ -34,15 +34,18 @@ export function computerInstallRoots(cliRoot: string, options?: {
34
34
  export function peerProblem(pkg: Record<string, any>, release: string): string | null;
35
35
  /**
36
36
  * The computer-level integrations installed beside the CLI at `cliRoot`, in
37
- * name order (declaration order when the root declares them). Reads
37
+ * name order (declaration order when the root declares them), then each
38
+ * integration a host approves (`approved`, architecture:cli-surface INV30),
39
+ * resolved by name from the package set that holds that CLI alone. Reads
38
40
  * package.json files only.
39
- * @param {{cliRoot: string, release: string, cacheDir?: string}} options
41
+ * @param {{cliRoot: string, release: string, cacheDir?: string, approved?: readonly string[]}} options
40
42
  * @returns {ComputerIntegrationCandidate[]}
41
43
  */
42
- export function scanComputerIntegrations({ cliRoot, release, cacheDir }: {
44
+ export function scanComputerIntegrations({ cliRoot, release, cacheDir, approved, }: {
43
45
  cliRoot: string;
44
46
  release: string;
45
47
  cacheDir?: string;
48
+ approved?: readonly string[];
46
49
  }): ComputerIntegrationCandidate[];
47
50
  /**
48
51
  * Load the compatible computer-level integrations as provider candidates,