@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
@@ -3,23 +3,30 @@
3
3
  /**
4
4
  * @file Path resolution utilities for Astryx CLI
5
5
  *
6
- * Finds packages/core, project root, and CLI package root.
6
+ * Finds the Astryx project a folder belongs to and its workspace root, the
7
+ * project's @astryxdesign/core and the other packages it installed, and the
8
+ * CLI package root.
7
9
  */
8
10
 
9
11
  import * as fs from 'node:fs';
10
12
  import {createRequire} from 'node:module';
13
+ import * as os from 'node:os';
11
14
  import * as path from 'node:path';
12
15
  import {fileURLToPath} from 'node:url';
13
16
  import {listDir, pathExists} from './listing-cache.mjs';
14
17
  import {ERROR_CODES} from '../response/error-codes.mjs';
15
18
  import {carriedCore, runChoice} from '../release/run-context.mjs';
16
19
  import {findDeclaringPackage} from '../release/project.mjs';
20
+ import {hostedRun, recordResolved} from '../runtime/package-root.mjs';
17
21
 
18
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
23
 
20
24
  /** Root of the @astryxdesign/cli package */
21
25
  export const CLI_ROOT = path.resolve(__dirname, '..', '..');
22
26
 
27
+ /** The package every Core lookup answers for. */
28
+ const CORE_PACKAGE = '@astryxdesign/core';
29
+
23
30
  /**
24
31
  * The Core a command answers from: the project's (see
25
32
  * {@link findProjectCoreDir}), else, when a copy other than the project's own
@@ -35,7 +42,33 @@ export const CLI_ROOT = path.resolve(__dirname, '..', '..');
35
42
  * @returns {string | null}
36
43
  */
37
44
  export function findCoreDir(startDir = process.cwd()) {
38
- return findProjectCoreDir(startDir) ?? carriedCoreFor(startDir).dir;
45
+ const own = findProjectCoreDir(startDir);
46
+ return own
47
+ ? noteCore(own, 'project')
48
+ : noteCore(carriedCoreFor(startDir).dir);
49
+ }
50
+
51
+ /**
52
+ * Note the Core a hosted run reads, for the record its host entry returns
53
+ * (architecture:cli-surface INV30). Does nothing outside a hosted run.
54
+ * @template {string | null} T
55
+ * @param {T} dir
56
+ * @param {'project'} [source] where it came from, when the caller knows
57
+ * @returns {T}
58
+ */
59
+ function noteCore(dir, source) {
60
+ if (dir && hostedRun()) {
61
+ let version;
62
+ try {
63
+ version = JSON.parse(
64
+ fs.readFileSync(path.join(dir, 'package.json'), 'utf8'),
65
+ ).version;
66
+ } catch {
67
+ version = null;
68
+ }
69
+ recordResolved('@astryxdesign/core', version, dir, source);
70
+ }
71
+ return dir;
39
72
  }
40
73
 
41
74
  /**
@@ -67,14 +100,14 @@ export function unavailableCoreMessage(found, inProject) {
67
100
  */
68
101
  export function requireCoreDir(startDir = process.cwd()) {
69
102
  const found = findProjectCoreDir(startDir) ?? carriedCoreFor(startDir, true);
70
- if (typeof found === 'string') return found;
103
+ if (typeof found === 'string') return noteCore(found, 'project');
71
104
  if (found.unavailable) {
72
105
  throw codedError(
73
106
  unavailableCoreMessage(found, runChoice(startDir).inProject),
74
107
  ERROR_CODES.ERR_VERSIONS_UNAVAILABLE,
75
108
  );
76
109
  }
77
- if (found.dir) return found.dir;
110
+ if (found.dir) return noteCore(found.dir);
78
111
  throw codedError(
79
112
  'Could not find @astryxdesign/core package',
80
113
  ERROR_CODES.ERR_CORE_NOT_FOUND,
@@ -121,12 +154,10 @@ function carriedCoreFor(startDir, fetch = false) {
121
154
  }
122
155
 
123
156
  /**
124
- * Find packages/core directory by walking up from startDir.
125
- * Also checks node_modules/@astryxdesign/core for installed usage.
126
- *
127
- * The project's Core only: in the folder or four above it, the reach every
128
- * released version used, else from the folder of the package.json that makes
129
- * the folder part of a project, however deep the folder is.
157
+ * The project's own @astryxdesign/core, never a carried one: found from the
158
+ * folder (see {@link findNearbyCoreDir}), else from the folder of the
159
+ * package.json that makes the folder part of a project, however deep the
160
+ * folder is.
130
161
  *
131
162
  * @param {string} [startDir]
132
163
  * @returns {string | null}
@@ -144,38 +175,464 @@ export function findProjectCoreDir(startDir = process.cwd()) {
144
175
  }
145
176
 
146
177
  /**
147
- * The Core installed in `startDir` or one of the four folders above it.
178
+ * How many folders, counting the start, the read lookups always search: the
179
+ * reach they had in released versions, so nothing they found before goes
180
+ * missing. Past this reach they continue only up to the workspace root of the
181
+ * Astryx project the start folder belongs to.
182
+ */
183
+ const CORE_REACH = 5;
184
+ const INSTALLED_REACH = 6;
185
+
186
+ /** Package scopes whose declaration makes a package.json an Astryx project. */
187
+ const ASTRYX_SCOPES = ['@astryxdesign/', '@xds/'];
188
+
189
+ /** package.json fields in which a package declares what it depends on. */
190
+ const DEPENDENCY_FIELDS = [
191
+ 'dependencies',
192
+ 'devDependencies',
193
+ 'optionalDependencies',
194
+ 'peerDependencies',
195
+ ];
196
+
197
+ /** Folders that mark a repository root; the project search stops there. */
198
+ const REPOSITORY_MARKERS = ['.git', '.hg', '.sl'];
199
+
200
+ /**
201
+ * `startDir` and every folder above it, nearest first, up to the filesystem
202
+ * root: the folders Node's resolver searches for `node_modules`.
203
+ *
148
204
  * @param {string} startDir
149
- * @returns {string | null}
205
+ * @returns {Generator<string>}
150
206
  */
151
- function findNearbyCoreDir(startDir) {
152
- let dir = startDir;
207
+ function* foldersUpFrom(startDir) {
208
+ let dir = path.resolve(startDir);
209
+ for (;;) {
210
+ yield dir;
211
+ const parent = path.dirname(dir);
212
+ if (parent === dir) return;
213
+ dir = parent;
214
+ }
215
+ }
153
216
 
154
- for (let i = 0; i < 5; i++) {
155
- const candidate = path.join(dir, 'packages', 'core');
156
- if (fs.existsSync(candidate)) {
157
- return candidate;
217
+ /**
218
+ * `dir/node_modules/<name>` when the package is installed there. Its
219
+ * package.json decides, so a stale empty folder is not an install.
220
+ *
221
+ * @param {string} dir
222
+ * @param {string} name
223
+ * @returns {string|null}
224
+ */
225
+ function installedIn(dir, name) {
226
+ const candidate = path.join(dir, 'node_modules', ...name.split('/'));
227
+ return fs.existsSync(path.join(candidate, 'package.json')) ? candidate : null;
228
+ }
229
+
230
+ /**
231
+ * The package.json in `dir`, or null when it is missing or not a JSON object.
232
+ *
233
+ * @param {string} dir
234
+ * @returns {Record<string, any>|null}
235
+ */
236
+ function readPackageJson(dir) {
237
+ try {
238
+ const pkg = JSON.parse(
239
+ fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'),
240
+ );
241
+ return pkg && typeof pkg === 'object' ? pkg : null;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Whether `dir` is @astryxdesign/core's own source, by the name in its
249
+ * package.json. The folder name says nothing: many monorepos keep an unrelated
250
+ * package in `packages/core`.
251
+ *
252
+ * @param {string} dir
253
+ * @returns {boolean}
254
+ */
255
+ function isCoreSource(dir) {
256
+ return readPackageJson(dir)?.name === CORE_PACKAGE;
257
+ }
258
+
259
+ /**
260
+ * `p` resolved through symlinks when it exists, so folders compare equal
261
+ * however they were reached.
262
+ *
263
+ * @param {string} p
264
+ * @returns {string}
265
+ */
266
+ function realDir(p) {
267
+ try {
268
+ return fs.realpathSync(path.resolve(p));
269
+ } catch {
270
+ return path.resolve(p);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The home folder, resolved through symlinks, or null when there is none.
276
+ *
277
+ * @returns {string|null}
278
+ */
279
+ function homeFolder() {
280
+ try {
281
+ const home = os.homedir();
282
+ return home ? realDir(home) : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Whether `child` lies strictly inside `parent`.
290
+ *
291
+ * @param {string} child
292
+ * @param {string} parent
293
+ * @returns {boolean}
294
+ */
295
+ function isInside(child, parent) {
296
+ const rel = path.relative(parent, child);
297
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
298
+ }
299
+
300
+ /**
301
+ * Whether the package.json in `dir` declares an Astryx package, in any
302
+ * dependency field.
303
+ *
304
+ * @param {string} dir
305
+ * @returns {boolean}
306
+ */
307
+ function declaresAstryx(dir) {
308
+ const pkg = readPackageJson(dir);
309
+ if (pkg == null) return false;
310
+ return DEPENDENCY_FIELDS.some(field => {
311
+ const deps = pkg[field];
312
+ return (
313
+ deps != null &&
314
+ typeof deps === 'object' &&
315
+ Object.keys(deps).some(name =>
316
+ ASTRYX_SCOPES.some(scope => name.startsWith(scope)),
317
+ )
318
+ );
319
+ });
320
+ }
321
+
322
+ /**
323
+ * The project root: the directory of the nearest package.json at or above
324
+ * `startDir`, or null when there is none.
325
+ *
326
+ * @param {string} startDir
327
+ * @returns {string|null}
328
+ */
329
+ export function findPackageRoot(startDir) {
330
+ for (const dir of foldersUpFrom(startDir)) {
331
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * The Astryx project `startDir` belongs to: the nearest folder at or above it
338
+ * whose package.json declares an Astryx package (`@astryxdesign/*`, or the
339
+ * legacy `@xds/*`), or that a workspace whose root declares one lists as a
340
+ * member. The search stops at a repository root, and it ends at the home
341
+ * folder without counting it. Returns null when no folder qualifies.
342
+ *
343
+ * @param {string} startDir
344
+ * @returns {string|null}
345
+ */
346
+ export function findAstryxProject(startDir) {
347
+ const start = path.resolve(startDir);
348
+ const home = homeFolder();
349
+ for (const dir of foldersUpFrom(start)) {
350
+ if (home != null && realDir(dir) === home) return null;
351
+ if (declaresAstryx(dir) || isAstryxWorkspaceMember(dir)) return dir;
352
+ if (
353
+ REPOSITORY_MARKERS.some(marker => fs.existsSync(path.join(dir, marker)))
354
+ ) {
355
+ return null;
158
356
  }
357
+ }
358
+ return null;
359
+ }
159
360
 
160
- const nodeModules = path.join(dir, 'node_modules', '@astryxdesign', 'core');
161
- if (fs.existsSync(nodeModules)) {
162
- return nodeModules;
361
+ /**
362
+ * The workspace root (`spec:AST-048` FR15): the nearest directory at or above
363
+ * `startDir` that declares a workspace, by a `pnpm-workspace.yaml` or a
364
+ * package.json `workspaces` field; outside a workspace, the directory of the
365
+ * nearest package.json; with no package.json, `startDir` itself.
366
+ *
367
+ * @param {string} startDir
368
+ * @returns {string}
369
+ */
370
+ export function findWorkspaceRoot(startDir) {
371
+ /** @type {string|null} */
372
+ let nearestPackage = null;
373
+ for (const dir of foldersUpFrom(startDir)) {
374
+ if (
375
+ fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||
376
+ readPackageJson(dir)?.workspaces != null
377
+ ) {
378
+ return dir;
379
+ }
380
+ if (
381
+ nearestPackage == null &&
382
+ fs.existsSync(path.join(dir, 'package.json'))
383
+ ) {
384
+ nearestPackage = dir;
163
385
  }
386
+ }
387
+ return nearestPackage ?? path.resolve(startDir);
388
+ }
164
389
 
165
- const parent = path.dirname(dir);
166
- if (parent === dir) break;
167
- dir = parent;
390
+ /**
391
+ * The highest folder a lookup for `project`'s packages searches: its
392
+ * workspace root, where a workspace keeps the packages its members share. For
393
+ * a project inside the home folder, never the home folder or above.
394
+ *
395
+ * @param {string} project
396
+ * @returns {string}
397
+ */
398
+ function workspaceBound(project) {
399
+ const workspace = findWorkspaceRoot(project);
400
+ const home = homeFolder();
401
+ if (
402
+ home != null &&
403
+ isInside(realDir(project), home) &&
404
+ !isInside(realDir(workspace), home)
405
+ ) {
406
+ return project;
168
407
  }
408
+ return workspace;
409
+ }
410
+
411
+ /**
412
+ * Escape `text` for use inside a regular expression.
413
+ * @param {string} text
414
+ * @returns {string}
415
+ */
416
+ function escapeRegExp(text) {
417
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
418
+ }
169
419
 
420
+ /**
421
+ * The alternatives of the brace list that opens at `glob[start]`, split at
422
+ * its own commas, and the index of its closing brace; null when it is never
423
+ * closed.
424
+ *
425
+ * @param {string} glob
426
+ * @param {number} start
427
+ * @returns {{alternatives: string[], end: number} | null}
428
+ */
429
+ function braceList(glob, start) {
430
+ const alternatives = [];
431
+ let depth = 0;
432
+ let from = start + 1;
433
+ for (let i = start; i < glob.length; i++) {
434
+ if (glob[i] === '{') {
435
+ depth++;
436
+ } else if (glob[i] === '}' && --depth === 0) {
437
+ alternatives.push(glob.slice(from, i));
438
+ return {alternatives, end: i};
439
+ } else if (glob[i] === ',' && depth === 1) {
440
+ alternatives.push(glob.slice(from, i));
441
+ from = i + 1;
442
+ }
443
+ }
170
444
  return null;
171
445
  }
172
446
 
447
+ /**
448
+ * The regular-expression source for a workspace glob: `*` and `?` stay
449
+ * within one folder name, `**` spans any number of folders, and `{a,b}`
450
+ * matches either alternative, each a glob of its own.
451
+ *
452
+ * @param {string} glob
453
+ * @returns {string}
454
+ */
455
+ function globSource(glob) {
456
+ let source = '';
457
+ for (let i = 0; i < glob.length; i++) {
458
+ const char = glob[i];
459
+ const list = char === '{' ? braceList(glob, i) : null;
460
+ if (char === '*' && glob[i + 1] === '*') {
461
+ // `**/` also matches no folder at all.
462
+ source += glob[i + 2] === '/' ? '(?:.*/)?' : '.*';
463
+ i += glob[i + 2] === '/' ? 2 : 1;
464
+ } else if (char === '*') {
465
+ source += '[^/]*';
466
+ } else if (char === '?') {
467
+ source += '[^/]';
468
+ } else if (list) {
469
+ source += `(?:${list.alternatives.map(globSource).join('|')})`;
470
+ i = list.end;
471
+ } else {
472
+ source += escapeRegExp(char);
473
+ }
474
+ }
475
+ return source;
476
+ }
477
+
478
+ /**
479
+ * A regular expression for a workspace pattern, tested against a member's
480
+ * path relative to the workspace root (see `globSource`).
481
+ *
482
+ * @param {string} pattern
483
+ * @returns {RegExp}
484
+ */
485
+ function workspacePatternRegExp(pattern) {
486
+ const glob = pattern
487
+ .trim()
488
+ .replace(/^(?:\.\/)+/, '')
489
+ .replace(/\/+$/, '');
490
+ return new RegExp(`^${globSource(glob)}$`);
491
+ }
492
+
493
+ /**
494
+ * The `packages` list in the pnpm-workspace.yaml in `dir`, as block or flow
495
+ * YAML; empty when there is none.
496
+ *
497
+ * @param {string} dir
498
+ * @returns {string[]}
499
+ */
500
+ function pnpmWorkspacePatterns(dir) {
501
+ let text;
502
+ try {
503
+ text = fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8');
504
+ } catch {
505
+ return [];
506
+ }
507
+ const lines = text.split(/\r?\n/);
508
+ const start = lines.findIndex(line => /^packages\s*:/.test(line));
509
+ if (start === -1) return [];
510
+ /** @param {string} item */
511
+ const unquote = item => item.trim().replace(/^(['"])(.*)\1$/, '$2');
512
+ const inline = lines[start]
513
+ .replace(/^packages\s*:/, '')
514
+ .replace(/\s#.*$/, '')
515
+ .trim();
516
+ if (inline.startsWith('[')) {
517
+ return inline
518
+ .replace(/^\[|\]$/g, '')
519
+ .split(',')
520
+ .map(unquote)
521
+ .filter(Boolean);
522
+ }
523
+ /** @type {string[]} */
524
+ const patterns = [];
525
+ for (const line of lines.slice(start + 1)) {
526
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
527
+ const item = /^\s*-\s*(.+?)\s*(?:\s#.*)?$/.exec(line);
528
+ if (!item) break;
529
+ patterns.push(unquote(item[1]));
530
+ }
531
+ return patterns;
532
+ }
533
+
534
+ /**
535
+ * Whether the workspace rooted at `root` lists `dir` as a member: a pattern
536
+ * in its package.json `workspaces` (an array, or `{packages: [...]}`) or its
537
+ * pnpm-workspace.yaml `packages` matches `dir`, and no `!` pattern excludes
538
+ * it.
539
+ *
540
+ * @param {string} dir
541
+ * @param {string} root
542
+ * @returns {boolean}
543
+ */
544
+ function isWorkspaceMember(dir, root) {
545
+ const rel = path.relative(root, dir).split(path.sep).join('/');
546
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false;
547
+ const workspaces = readPackageJson(root)?.workspaces;
548
+ const patterns = [
549
+ ...(Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? [])),
550
+ ...pnpmWorkspacePatterns(root),
551
+ ].filter(pattern => typeof pattern === 'string');
552
+ const matches = (/** @type {string} */ pattern) =>
553
+ workspacePatternRegExp(pattern).test(rel);
554
+ return (
555
+ patterns.some(pattern => !pattern.startsWith('!') && matches(pattern)) &&
556
+ !patterns.some(
557
+ pattern => pattern.startsWith('!') && matches(pattern.slice(1)),
558
+ )
559
+ );
560
+ }
561
+
562
+ /**
563
+ * Whether `dir` is a member its workspace lists, where the workspace root
564
+ * declares an Astryx package. Such a member uses the Astryx its workspace
565
+ * installs, so it is an Astryx project of its own.
566
+ *
567
+ * @param {string} dir
568
+ * @returns {boolean}
569
+ */
570
+ function isAstryxWorkspaceMember(dir) {
571
+ if (!fs.existsSync(path.join(dir, 'package.json'))) return false;
572
+ const root = workspaceBound(dir);
573
+ return root !== dir && declaresAstryx(root) && isWorkspaceMember(dir, root);
574
+ }
575
+
576
+ /**
577
+ * The folders a read lookup searches, nearest first: `startDir` and the
578
+ * `reach - 1` folders above it, as released versions did; then the root of
579
+ * the Astryx project `startDir` belongs to, if any, and the folders from it up
580
+ * to its workspace root. Past the reach the search starts at the project root,
581
+ * so a folder between the two that installs packages but declares no Astryx
582
+ * package (a tool inside the project) never answers for the project.
583
+ *
584
+ * @param {string} startDir
585
+ * @param {number} reach
586
+ * @returns {Generator<string>}
587
+ */
588
+ function* readFolders(startDir, reach) {
589
+ /** @type {Set<string>} */
590
+ const searched = new Set();
591
+ for (const dir of foldersUpFrom(startDir)) {
592
+ if (searched.size >= reach) break;
593
+ searched.add(dir);
594
+ yield dir;
595
+ }
596
+ const project = findAstryxProject(startDir);
597
+ if (project == null) return;
598
+ const bound = workspaceBound(project);
599
+ for (const dir of foldersUpFrom(project)) {
600
+ if (!searched.has(dir)) yield dir;
601
+ if (dir === bound) return;
602
+ }
603
+ }
604
+
605
+ /**
606
+ * The @astryxdesign/core installed for `startDir`.
607
+ *
608
+ * Searches `startDir` and the four folders above it, as released versions
609
+ * did, then on up to the workspace root of the Astryx project it belongs to.
610
+ * In each folder a `packages/core` whose package.json names
611
+ * @astryxdesign/core (the Astryx repository's own source) comes first, then
612
+ * an installed `node_modules/@astryxdesign/core`. It takes the first it finds,
613
+ * whatever version the project declares: that is the copy Node loads. When no
614
+ * folder holds one, it asks Yarn Plug'n'Play, which keeps no node_modules.
615
+ *
616
+ * @param {string} startDir
617
+ * @returns {string|null} the Core package directory, or null when there is none
618
+ */
619
+ function findNearbyCoreDir(startDir) {
620
+ for (const dir of readFolders(startDir, CORE_REACH)) {
621
+ const source = path.join(dir, 'packages', 'core');
622
+ if (isCoreSource(source)) return source;
623
+ const installed = installedIn(dir, CORE_PACKAGE);
624
+ if (installed) return installed;
625
+ }
626
+ return findPnpPackage(startDir, CORE_PACKAGE);
627
+ }
628
+
173
629
  /**
174
630
  * Where Yarn's Plug'n'Play runtime says a package lives.
175
631
  *
176
- * A PnP project has no node_modules for the walk below to find. PnP answers
177
- * from the project's own dependency graph and ignores NODE_PATH, so asking it
178
- * keeps the lookup project-local. Outside a PnP process there is nothing to ask.
632
+ * A PnP project has no node_modules for the walks in this file to find. PnP
633
+ * answers from the project's own dependency graph and ignores NODE_PATH, so
634
+ * asking it keeps the lookup project-local. Outside a PnP process there is
635
+ * nothing to ask.
179
636
  *
180
637
  * @param {string} startDir
181
638
  * @param {string} name
@@ -184,8 +641,9 @@ function findNearbyCoreDir(startDir) {
184
641
  function findPnpPackage(startDir, name) {
185
642
  if (!process.versions.pnp) return null;
186
643
  try {
187
- const api = createRequire(path.join(startDir, 'noop.js'))('pnpapi');
188
- const dir = api.resolveToUnqualified(name, path.join(startDir, '/'));
644
+ const from = path.resolve(startDir);
645
+ const api = createRequire(path.join(from, 'noop.js'))('pnpapi');
646
+ const dir = api.resolveToUnqualified(name, path.join(from, '/'));
189
647
  return dir && fs.existsSync(path.join(dir, 'package.json')) ? dir : null;
190
648
  } catch {
191
649
  // The project does not declare it, or this is not a PnP process after all.
@@ -193,6 +651,24 @@ function findPnpPackage(startDir, name) {
193
651
  }
194
652
  }
195
653
 
654
+ /**
655
+ * The nearest `node_modules/<name>` at or above `startDir`, found the way
656
+ * Node's resolver finds a package, and the folder whose node_modules holds it.
657
+ * It searches every folder up to the filesystem root and does not ask
658
+ * Plug'n'Play.
659
+ *
660
+ * @param {string} startDir
661
+ * @param {string} name bare package name; callers reject path segments first
662
+ * @returns {{packageDir: string, hostDir: string}|null}
663
+ */
664
+ export function findNodeModulesPackage(startDir, name) {
665
+ for (const dir of foldersUpFrom(startDir)) {
666
+ const packageDir = installedIn(dir, name);
667
+ if (packageDir) return {packageDir, hostDir: dir};
668
+ }
669
+ return null;
670
+ }
671
+
196
672
  /**
197
673
  * Locate a package the project at `startDir` actually installed.
198
674
  *
@@ -204,25 +680,67 @@ function findPnpPackage(startDir, name) {
204
680
  * Reading package.json off disk also sidesteps packages that don't export it,
205
681
  * so the version is always available to range-check.
206
682
  *
207
- * Walks the node_modules chain first, then asks PnP, which covers the Yarn
208
- * default layout where that chain does not exist.
683
+ * Searches the node_modules of `startDir` and the five folders above it, as
684
+ * released versions did, then on up to the workspace root of the Astryx
685
+ * project it belongs to. Then it asks PnP, which covers the Yarn default
686
+ * layout where that chain does not exist.
209
687
  *
210
688
  * @param {string} startDir
211
689
  * @param {string} name
212
690
  * @returns {string|null} the package directory, or null when not installed
213
691
  */
214
692
  export function findInstalledPackage(startDir, name) {
215
- let dir = startDir;
216
- for (let i = 0; i < 6; i++) {
217
- const candidate = path.join(dir, 'node_modules', ...name.split('/'));
218
- if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
219
- const parent = path.dirname(dir);
220
- if (parent === dir) break;
221
- dir = parent;
693
+ for (const dir of readFolders(startDir, INSTALLED_REACH)) {
694
+ const packageDir = installedIn(dir, name);
695
+ if (packageDir) return packageDir;
222
696
  }
223
697
  return findPnpPackage(startDir, name);
224
698
  }
225
699
 
700
+ /**
701
+ * The first of `names` the Astryx project of `startDir` installed, for a
702
+ * command that writes to the project (`upgrade`).
703
+ *
704
+ * It searches the node_modules of the project root, nearest name first. For
705
+ * a member its workspace lists, it then searches each folder above the
706
+ * project up to the workspace root, where the workspace installs the packages
707
+ * its members share; otherwise the search stops at the project root. Then it
708
+ * asks Plug'n'Play, in name order. With no Astryx project it looks only in the
709
+ * node_modules of `startDir` itself, as released versions did.
710
+ *
711
+ * @param {string} startDir
712
+ * @param {readonly string[]} names in order of preference
713
+ * @returns {{name: string, packageDir: string}|null}
714
+ */
715
+ export function findProjectPackageOf(startDir, names) {
716
+ const project = findAstryxProject(startDir);
717
+ if (project == null) {
718
+ const dir = path.resolve(startDir);
719
+ for (const name of names) {
720
+ const packageDir = installedIn(dir, name);
721
+ if (packageDir) return {name, packageDir};
722
+ }
723
+ return null;
724
+ }
725
+ const workspace = workspaceBound(project);
726
+ const top =
727
+ workspace !== project && isWorkspaceMember(project, workspace)
728
+ ? workspace
729
+ : project;
730
+ for (const dir of foldersUpFrom(project)) {
731
+ for (const name of names) {
732
+ const packageDir = installedIn(dir, name);
733
+ if (packageDir) return {name, packageDir};
734
+ }
735
+ if (dir === top) break;
736
+ }
737
+ for (const name of names) {
738
+ const packageDir = findPnpPackage(project, name);
739
+ if (packageDir) return {name, packageDir};
740
+ }
741
+ return null;
742
+ }
743
+
226
744
  /**
227
745
  * Find the monorepo root by looking for the root package.json
228
746
  * that has workspaces defined.