stonyx 0.2.3-alpha.6 → 0.2.3-alpha.61

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 (69) hide show
  1. package/README.md +6 -3
  2. package/config/{environment.js → environment copy.ts } +16 -12
  3. package/dist/cli/help.d.ts +8 -0
  4. package/dist/cli/help.js +43 -0
  5. package/dist/cli/load-commands.d.ts +10 -0
  6. package/dist/cli/load-commands.js +49 -0
  7. package/dist/cli/new.d.ts +72 -0
  8. package/dist/cli/new.js +426 -0
  9. package/dist/cli/serve.d.ts +6 -0
  10. package/dist/cli/serve.js +49 -0
  11. package/dist/cli/test-setup.d.ts +1 -0
  12. package/dist/cli/test-setup.js +30 -0
  13. package/dist/cli/test.d.ts +4 -0
  14. package/dist/cli/test.js +23 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +53 -0
  17. package/dist/exports/config.d.ts +2 -0
  18. package/dist/exports/config.js +2 -0
  19. package/dist/exports/log.d.ts +2 -0
  20. package/{src → dist}/exports/log.js +1 -2
  21. package/dist/exports/test-helpers.d.ts +5 -0
  22. package/dist/exports/test-helpers.js +7 -0
  23. package/dist/lifecycle.d.ts +7 -0
  24. package/dist/lifecycle.js +18 -0
  25. package/dist/main.d.ts +22 -0
  26. package/dist/main.js +102 -0
  27. package/dist/modules.d.ts +30 -0
  28. package/dist/modules.js +393 -0
  29. package/dist/util/duplicate-core.d.ts +186 -0
  30. package/dist/util/duplicate-core.js +414 -0
  31. package/dist/util/import-config.d.ts +52 -0
  32. package/dist/util/import-config.js +256 -0
  33. package/dist/util/resolve-entry-point.d.ts +1 -0
  34. package/dist/util/resolve-entry-point.js +14 -0
  35. package/dist/util/resolve-module-name.d.ts +14 -0
  36. package/dist/util/resolve-module-name.js +33 -0
  37. package/package.json +41 -12
  38. package/scripts/postinstall.js +24 -2
  39. package/.claude/index.md +0 -96
  40. package/.github/workflows/ci.yml +0 -16
  41. package/.github/workflows/publish.yml +0 -51
  42. package/docs/api.md +0 -89
  43. package/docs/cli.md +0 -88
  44. package/docs/configuration.md +0 -72
  45. package/docs/conventions/discord-conventions.md +0 -62
  46. package/docs/conventions/framework-modules.md +0 -127
  47. package/docs/conventions/index.md +0 -24
  48. package/docs/conventions/orm-conventions.md +0 -158
  49. package/docs/conventions/project-structure.md +0 -105
  50. package/docs/conventions/rest-conventions.md +0 -96
  51. package/docs/conventions/testing-conventions.md +0 -56
  52. package/docs/developing-modules.md +0 -108
  53. package/docs/index.md +0 -15
  54. package/docs/lifecycle.md +0 -47
  55. package/docs/logging.md +0 -52
  56. package/docs/modules.md +0 -82
  57. package/docs/testing.md +0 -64
  58. package/src/cli/help.js +0 -48
  59. package/src/cli/load-commands.js +0 -56
  60. package/src/cli/new.js +0 -237
  61. package/src/cli/serve.js +0 -38
  62. package/src/cli/test-setup.js +0 -8
  63. package/src/cli/test.js +0 -27
  64. package/src/cli.js +0 -63
  65. package/src/exports/config.js +0 -3
  66. package/src/exports/test-helpers.js +0 -8
  67. package/src/lifecycle.js +0 -17
  68. package/src/main.js +0 -101
  69. package/src/modules.js +0 -112
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Validates and initializes stonyx modules
3
+ * TODO: Refactor into a ModuleLoader class (good coding exercise)
4
+ */
5
+ import { readFile } from '@stonyx/utils/file';
6
+ import { kebabCaseToCamelCase } from '@stonyx/utils/string';
7
+ import { mergeObject } from '@stonyx/utils/object';
8
+ import { importConfig, CONFIG_NOT_FOUND_PREFIX, CONFIG_NOT_LOADABLE_PREFIX } from './util/import-config.js';
9
+ import { findForeignCores, duplicateCoreMessage } from './util/duplicate-core.js';
10
+ const modulePromises = {};
11
+ // The `@stonyx/*` names this app DECLARED — which since PR #120 fix round 1 is
12
+ // a strictly WIDER set than the keys of `modulePromises`. Registration is now
13
+ // scoped to what discovery admitted, so a declared name whose manifest is
14
+ // missing, or whose manifest carries no `stonyx-module` keyword, is in this set
15
+ // and not in that map. `waitForModule`'s failure branch is the only consumer,
16
+ // and this is the only question it needs answered: "is this name in the
17
+ // manifest?" is exactly what `modulePromises` stopped being able to answer.
18
+ //
19
+ // Reassigned per `loadModules` call rather than accumulated, because only the
20
+ // current manifest can answer for the current boot — a name declared by an
21
+ // earlier load must not answer for this one.
22
+ //
23
+ // It is NOT symmetric with `modulePromises`, and the asymmetry is worth
24
+ // stating because the obvious reading of the line above is that it is.
25
+ // `modulePromises` has no reset: no `delete`, no reassignment, no clear, at
26
+ // any commit on `dev`. It accumulates the union of every `loadModules` call in
27
+ // the process while this set is replaced per call. Measured over three loads
28
+ // in one process at c87826b: load A declares and installs
29
+ // `@stonyx/probe-life`; load B declares it with the package absent, so
30
+ // discovery warns and `continue`s and registers nothing — and
31
+ // `waitForModule('probe-life')` still RESOLVES, off load A's stale entry.
32
+ // That is the "reports success for a module never loaded, silently" outcome
33
+ // the registration loop below calls worse than the hang; T23 forecloses that
34
+ // remedy WITHIN one call and no test sees across calls.
35
+ //
36
+ // Inherited, not introduced here — `modulePromises` was never reset at base
37
+ // 5693744 either — and deliberately not closed here, because giving it a
38
+ // per-call lifetime has a blast radius of its own and is filed separately.
39
+ // Production reachability is nil: one `loadModules` call site, guarded by the
40
+ // `Stonyx.instance` early return. The suite is held safe by the
41
+ // unique-module-name-per-test rule in this file's test header, not by this.
42
+ let declaredModuleNames = new Set();
43
+ // Configure module-specific logging
44
+ function configureLog(chronicle, module, config) {
45
+ const { logColor, logMethod, logTimestamp } = config;
46
+ if (!logColor)
47
+ return;
48
+ chronicle.defineType(logMethod || module, logColor, { logTimestamp: !!logTimestamp });
49
+ }
50
+ function initializeModule(moduleName, moduleClass, modules, initPromises) {
51
+ const moduleInstance = new moduleClass();
52
+ modules.push(moduleInstance);
53
+ // A module class with no `init()` has nothing to initialise, so it is ready
54
+ // the moment it is instantiated. Without this its registered promise dangles
55
+ // — the only resolve on this path lives inside the `init()` wrapper below,
56
+ // which is never created. Third of the three never-resolved paths named in
57
+ // #120, and the one that scoping registration to `discovered` does NOT close,
58
+ // because such a module IS discovered — measured, not reasoned: with only
59
+ // the registration fix applied the suite read 180 pass / 1 fail at f575a3c
60
+ // with T24 the sole failure. The SHA is the point; see the aggregate
61
+ // convention noted at the `declaredDependencies` union below. Resolving here is readiness BY DEFINITION — there is no `init()`,
62
+ // so there is nothing to wait for — and that is what separates it from
63
+ // resolving on a discovery `continue`, where there is no module to be ready
64
+ // at all.
65
+ //
66
+ // It is deliberately NOT the stronger claim that the module initialised.
67
+ // `waitForModule` is documented as blocking until a module "finishes
68
+ // initializing", and a `static async init()` typo — which TypeScript cannot
69
+ // catch, since `init?()` is optional in lifecycle.ts — presents to the loader
70
+ // as a class with no `init()`. This resolves it, and the waiter proceeds
71
+ // against a module whose own initialisation never ran; measured, the waiter
72
+ // observes the module's ready flag still false. The loader cannot tell that
73
+ // typo from a legitimate no-`init()` module, and a no-`init()` module is a
74
+ // supported shape, so resolving is the only non-hanging answer available and
75
+ // availability over a silent hang is the right trade. The claim is what is
76
+ // narrowed here, not the behaviour.
77
+ //
78
+ // `?.` for the reason the resolve below carries it: `initializeModule` is
79
+ // also reached from the standalone path as `initializeModule(projectName, …)`,
80
+ // and `projectName` is never a key in `modulePromises`.
81
+ if (!moduleInstance.init) {
82
+ modulePromises[moduleName]?.resolve();
83
+ return;
84
+ }
85
+ initPromises.push((async () => {
86
+ await moduleInstance.init();
87
+ modulePromises[moduleName]?.resolve();
88
+ })());
89
+ }
90
+ function messageOf(error) {
91
+ return error instanceof Error ? error.message : String(error);
92
+ }
93
+ /**
94
+ * Why the old single catch had to go (abofs/stonyx#108, invariant I2).
95
+ *
96
+ * It spanned four distinct failure modes — config absent, config present and
97
+ * declined, config throwing, entry point failing — and collapsed all four into
98
+ * one fixed claim: `Stonyx modules with async loading must have a
99
+ * config/environment.js file`. In the reproduction #108 was filed over, BOTH
100
+ * facts in that sentence were false: the named file existed and imported
101
+ * cleanly with nine keys, and the module named was not the module that threw.
102
+ * The real error reached stderr only through a bare `console.error`, unlinked
103
+ * from the thrown one, so a programmatic supervisor — or any log aggregator
104
+ * that keeps the thrown message — lost the diagnosis entirely.
105
+ *
106
+ * `CONFIG_NOT_LOADABLE_PREFIX` was exported by abofs/stonyx#105 for exactly
107
+ * this branch and had zero non-test importers in `src/` until now; #116
108
+ * corrected the doc sentence and deliberately left the code here.
109
+ *
110
+ * NOT DONE, deliberately: #108's AC1 asks that a surviving config-file message
111
+ * be "guarded by an `existsSync` on the module's own config/environment.js".
112
+ * That guard could not fail. `importConfig` throws `CONFIG_NOT_FOUND_PREFIX`
113
+ * only after `existsSync` has already returned false for every extension it
114
+ * loads AND every extension it merely detects, so an `existsSync` re-check in
115
+ * this branch is true by construction — a check that cannot red, which is the
116
+ * defect family this cluster exists to remove. The branch is keyed on the
117
+ * loader's own outcome instead, and the literal string is gone from `src/`
118
+ * altogether, which is what the grep in that AC actually measures.
119
+ *
120
+ * SCOPE: this names which STEP failed and preserves the original verbatim. It
121
+ * does not diagnose WHY a module's own config threw, and it cannot tell a
122
+ * module that ships no config from one whose install was truncated.
123
+ */
124
+ function describeConfigFailure(moduleName, configBasePath, error) {
125
+ const message = messageOf(error);
126
+ if (message.startsWith(CONFIG_NOT_FOUND_PREFIX)) {
127
+ return new Error(`Stonyx module "${moduleName}" carries the "stonyx-async" keyword, which requires it to ship ` +
128
+ `default configuration, and none is installed. Looked for ${configBasePath}.ts and ` +
129
+ `${configBasePath}.js. This file ships inside "${moduleName}" — a missing one is a truncated ` +
130
+ 'or corrupted install of that module, not a mistake in this app\'s own config.', { cause: error });
131
+ }
132
+ if (message.startsWith(CONFIG_NOT_LOADABLE_PREFIX)) {
133
+ return new Error(`Stonyx module "${moduleName}" ships default configuration this Node runtime declined to ` +
134
+ `load. ${message}`, { cause: error });
135
+ }
136
+ return new Error(`Stonyx module "${moduleName}" failed while loading its default configuration from ` +
137
+ `${configBasePath}: ${message}`, { cause: error });
138
+ }
139
+ export default async function loadModules(config, rootPath, chronicle) {
140
+ const modules = [];
141
+ const initPromises = [];
142
+ const rootPackage = await readFile(`${rootPath}/package.json`, { json: true });
143
+ // DISCOVERY SOURCE (abofs/stonyx#106 rule 3).
144
+ //
145
+ // This local used to be named `dependencies` while holding `devDependencies`
146
+ // and nothing else. That name is the ruled root cause of #106: every reader
147
+ // of this function — including its own docs — believed discovery scanned
148
+ // both maps, because the identifier said so. It did not. An installed stonyx
149
+ // module declared in `dependencies` was invisible, which is precisely how the
150
+ // published fleet pins the core, so the loader could not see its own
151
+ // siblings.
152
+ //
153
+ // Rule 3 widens the source to the DE-DUPLICATED union of both maps. Object
154
+ // spread IS the de-duplication and is load-bearing: a module declared in both
155
+ // maps collapses to one key, so it is registered once and — for an async
156
+ // module — instantiated once. (A SYNC module is discovered once and
157
+ // instantiated zero times; the loader never imports its entry point. Measured
158
+ // on a dual-declared sync fixture: `modules.length === 0`.)
159
+ //
160
+ // A bare `[ ...Object.keys(deps), ...Object.keys(devDeps) ]` concat instantiates
161
+ // a dual-declared module twice and runs its `init()` twice. That WOULD be
162
+ // invisible to the suite aggregate were T22 not armed; T22's instance-count
163
+ // assertion is what makes it visible. With the concat seeded, T22 is the SOLE
164
+ // failure and both of its counts read actual 2 against expected 1 — measured
165
+ // 180 pass / 1 fail at f575a3c, out of a 181-test suite.
166
+ //
167
+ // The SHA is not decoration. This sentence replaced one that round 1 found
168
+ // false, and its replacement carried a figure measured at the PREVIOUS head
169
+ // under the words "at this head": 178/1, which cannot reconcile against a
170
+ // 181-test suite at all. Four reviewers caught it independently. A bare
171
+ // aggregate has no author and no date, so it rots silently on the next commit
172
+ // that adds a test; an aggregate with the SHA it was taken at stays true
173
+ // forever and tells the next reader exactly what to re-run.
174
+ const declaredDependencies = {
175
+ ...(rootPackage.dependencies || {}),
176
+ ...(rootPackage.devDependencies || {}),
177
+ };
178
+ const projectName = typeof rootPackage.name === 'string' ? rootPackage.name : '';
179
+ // Expose rootPath to public configuration
180
+ config.rootPath = rootPath;
181
+ // The `@stonyx/` prefix filter STAYS (#106 keeps it explicitly). Widening it
182
+ // to `@` — or dropping it — makes the loader warn on every non-Stonyx
183
+ // dependency and leaves their `modulePromises` entries permanently
184
+ // unresolved. T3 owns that kill. What is a module is decided below, by the
185
+ // `stonyx-module` keyword; this is only a name test that bounds the scan.
186
+ const moduleDependencies = Object.keys(declaredDependencies).filter((moduleName) => moduleName.startsWith('@stonyx/'));
187
+ declaredModuleNames = new Set(moduleDependencies);
188
+ // DISCOVERY, hoisted above the pre-flight.
189
+ //
190
+ // The pre-flight must check exactly what the loader loads, and no more.
191
+ // `@stonyx/*` is a NAME test, not a membership test: a scoped dependency
192
+ // without the `stonyx-module` keyword is warned about and skipped below —
193
+ // never imported, never configured, incapable of registering anything on any
194
+ // singleton — and refusing to boot over its nested copy prescribed the MODULE
195
+ // AUTHOR's remedy to a package that is not a module and has no such
196
+ // obligation. #106 says the two predicates are different in as many words.
197
+ // So the keyword gate runs first and the pre-flight sees only real modules.
198
+ const discovered = [];
199
+ for (const moduleName of moduleDependencies) {
200
+ const modulePackage = await readFile(`${rootPath}/node_modules/${moduleName}/package.json`, { json: true, missingFileCallback: (_filePath) => {
201
+ console.warn(`Warning: Could not locate stonyx module: "${moduleName}". Module was not loaded`);
202
+ return '';
203
+ } });
204
+ if (!modulePackage)
205
+ continue;
206
+ const keywords = Array.isArray(modulePackage.keywords) ? modulePackage.keywords : [];
207
+ if (!keywords.includes('stonyx-module')) {
208
+ console.warn(`Warning: Stonyx modules must contain the "stonyx-module" keyword. Module was not loaded`);
209
+ continue;
210
+ }
211
+ discovered.push({
212
+ name: moduleName,
213
+ dir: `${rootPath}/node_modules/${moduleName}`,
214
+ package: modulePackage,
215
+ keywords,
216
+ });
217
+ }
218
+ // Setup module promises prior to initialization — over `discovered`, NOT
219
+ // over `moduleDependencies`.
220
+ //
221
+ // This loop used to run above discovery and consume the declared list. That
222
+ // was already the shape at base, but rule 3 widened what the declared list
223
+ // contains, and the widening turned a fast named throw into an unbounded
224
+ // boot hang. A name registered here but never resolved leaves
225
+ // `waitForModule` pending forever; discovery `continue`s past two kinds of
226
+ // name without reaching ANY of this file's three resolve sites — a manifest
227
+ // that is not there, and a package without the `stonyx-module` keyword.
228
+ // Three, not the two this sentence claimed when it was written: the
229
+ // no-`init()` early return in `initializeModule` is a resolve site and the
230
+ // commit that wrote this sentence is the commit that added it. The sites are
231
+ // named rather than numbered by line, because the comment above
232
+ // `initializeModule` reasons from this inventory and a stale count sends the
233
+ // next dangling-promise audit one site short — they are the no-`init()`
234
+ // early return, the `init()` wrapper, and the sync-module `continue` in the
235
+ // load loop.
236
+ // `@stonyx/logs` ships without that keyword and is an ordinary `dependencies`
237
+ // entry, so this is a shape the fleet actually has.
238
+ //
239
+ // name in `dependencies`, not installed, another module awaits it
240
+ // base 5693744 throws "…was not registered in project dependencies", 3 ms
241
+ // a57045e one console.warn, then loadModules never settles
242
+ //
243
+ // Registering only what discovery admitted restores the fail-fast for both
244
+ // of those DISCOVERY FAILURES, and for `devDependencies`-declared names too
245
+ // — that half is INHERITED, not introduced here, and it is a real behaviour
246
+ // change: hang becomes throw — and the throw names the right cause, because
247
+ // `modulePromises` is no longer the app's declared list and `waitForModule`
248
+ // below branches on that difference rather than asserting the old one. T13
249
+ // pins the hang-becomes-throw change and the named cause; the PR body names
250
+ // them. ("Halves" is avoided here because T13 uses that word for its own
251
+ // input-domain split — the `devDependencies` half it covers and the
252
+ // `dependencies` half it hands to T23.)
253
+ //
254
+ // SCOPED DELIBERATELY, because the sentence above would otherwise read as a
255
+ // closure statement for the whole never-settling-promise family and it is
256
+ // not one. What it closes is names that FAIL discovery. A name that PASSES
257
+ // discovery is registered, and where its class HAS an `init()` its promise
258
+ // is resolved only once that `init()` has settled — the qualifier matters,
259
+ // because two of the three resolve sites named above settle without any
260
+ // `init()` running. So two discovered modules whose `init()`s await
261
+ // each other through `waitForModule` are both registered, reach no resolve
262
+ // site, and leave `await Promise.all(initPromises)` at the end of this
263
+ // function pending forever. Measured on a cross-map mutual wait
264
+ // (`@stonyx/c1-a` in `dependencies`, `@stonyx/c1-b` in `devDependencies`,
265
+ // each `init()` awaiting the other), 3000 ms cap:
266
+ // base 5693744 threw in 3 ms — the `dependencies` half was never
267
+ // discovered, so it failed fast for the wrong reason
268
+ // a57045e HUNG, 3001 ms
269
+ // f575a3c HUNG, 3003 ms — scoping registration does not touch it
270
+ // c87826b HUNG, 3002 ms — re-measured at the head this ships from,
271
+ // because the rows above stop before three commits that
272
+ // touch this file and a reader at head cannot tell from a
273
+ // truncated table whether the row still applies
274
+ // Rule 3 moves that shape from fail-fast to deadlock and nothing in this
275
+ // file closes it.
276
+ //
277
+ // NO DETECTION IS ATTEMPTED, and that is a decision rather than an
278
+ // oversight. `waitForModule` is told which module is being waited FOR and
279
+ // never which module is waiting, so a cycle is not observable without
280
+ // caller attribution that does not exist here, and this framework sets no
281
+ // boot timeout to hang a fallback off. A wait cycle is an authoring error in
282
+ // the modules; docs/modules.md's `waitForModule` section records the hazard
283
+ // so it is documented rather than merely known.
284
+ //
285
+ // The alternative — resolving in each `continue` branch — also removes the
286
+ // hang and is worse: `waitForModule` would report success for a module that
287
+ // was never loaded, silently. T23 asserts the named rejection precisely so
288
+ // that remedy cannot pass.
289
+ //
290
+ // Ordering is safe: nothing between here and the old site touches
291
+ // `modulePromises`, discovery only reads manifests, and registration still
292
+ // completes before the first `init()` can run.
293
+ for (const { name: moduleName } of discovered) {
294
+ const promise = {};
295
+ modulePromises[moduleName] = promise;
296
+ promise.ready = new Promise(resolve => promise.resolve = resolve);
297
+ }
298
+ // Pre-flight: invariant I1, "one core". Before ANY module entry point is
299
+ // imported, because the point of the check is the module that would NOT
300
+ // throw — it would load, initialise, and register its hooks on a second
301
+ // singleton that nobody started. See src/util/duplicate-core.ts.
302
+ //
303
+ // It runs over every discovered module, SYNC AND ASYNC ALIKE. A sync module
304
+ // is never imported by the loader, so its second core never announces itself
305
+ // — which is exactly why the mechanism is a pre-flight and not a better
306
+ // catch. The consequence is that `stonyx-async` no longer decides whether a
307
+ // duplicate core is loud: both arms are refused here, by the same check,
308
+ // before the keyword is ever read. docs/modules.md documents that.
309
+ //
310
+ // `console.warn` for the inconclusive probes: fail-open is the policy, but
311
+ // "we could not check" must be distinguishable from "we checked and it is
312
+ // fine". This is the file's existing idiom for a non-fatal loader advisory.
313
+ const foreignCores = findForeignCores(discovered.map(({ name, dir }) => ({ name, dir })), undefined, message => console.warn(message));
314
+ if (foreignCores.length > 0)
315
+ throw new Error(duplicateCoreMessage(foreignCores));
316
+ // Standalone module configuration
317
+ if (Array.isArray(rootPackage.keywords) && rootPackage.keywords.includes('stonyx-module')) {
318
+ configureLog(chronicle, projectName, config);
319
+ const entryPoint = typeof rootPackage.main === 'string' ? rootPackage.main : '';
320
+ const { default: moduleClass } = await import(`${rootPath}/${entryPoint}`);
321
+ initializeModule(projectName, moduleClass, modules, initPromises);
322
+ }
323
+ for (const { name: moduleName, package: modulePackage, keywords } of discovered) {
324
+ if (!keywords.includes('stonyx-async')) {
325
+ modulePromises[moduleName].resolve();
326
+ continue;
327
+ }
328
+ // Load & Configure Async Modules
329
+ const configBasePath = `${rootPath}/node_modules/${moduleName}/config/environment`;
330
+ let moduleConfig;
331
+ try {
332
+ moduleConfig = await importConfig(configBasePath);
333
+ }
334
+ catch (error) {
335
+ throw describeConfigFailure(moduleName, configBasePath, error);
336
+ }
337
+ const module = kebabCaseToCamelCase(moduleName.split('/').pop() ?? moduleName);
338
+ const userConfig = config[module] || {};
339
+ const finalConfig = mergeObject(moduleConfig, userConfig);
340
+ config[module] = finalConfig;
341
+ // Configure module-specific logging
342
+ configureLog(chronicle, module, finalConfig);
343
+ const entryPath = `${rootPath}/node_modules/${moduleName}/${modulePackage.main}`;
344
+ try {
345
+ const { default: moduleClass } = await import(entryPath);
346
+ initializeModule(moduleName, moduleClass, modules, initPromises);
347
+ }
348
+ catch (error) {
349
+ throw new Error(`Stonyx module "${moduleName}" failed while importing its entry point ${entryPath}: ${messageOf(error)}`, { cause: error });
350
+ }
351
+ }
352
+ // Wait until all modules are initialized
353
+ await Promise.all(initPromises);
354
+ return modules;
355
+ }
356
+ /**
357
+ * Blocks until `@stonyx/<moduleName>` has finished initializing.
358
+ *
359
+ * TWO failure sentences, because one sentence stopped being able to carry both
360
+ * facts. Until PR #120 fix round 1 `modulePromises` held every DECLARED
361
+ * `@stonyx/*` name, so "absent from the map" and "absent from the manifest"
362
+ * were the same fact and the single inherited sentence was true. Registration
363
+ * is now scoped to the DISCOVERED set — which is what removes the boot hang,
364
+ * see the registration loop in `loadModules` — so absence from the map has two
365
+ * causes, and the inherited sentence is FALSE for one of them: it tells the
366
+ * operator the package is not in their dependencies while it is sitting in
367
+ * their manifest.
368
+ *
369
+ * Not hypothetical, and not a shape this repo has to imagine: `@stonyx/logs`
370
+ * ships without the `stonyx-module` keyword and is an ordinary `dependencies`
371
+ * entry of THIS package.json. An app in the same shape calling
372
+ * `waitForModule('logs')` would be sent to grep a manifest that already
373
+ * contains the answer. Naming the wrong cause is the defect abofs/stonyx#108
374
+ * was filed over, and it is not made acceptable by being one line long.
375
+ *
376
+ * The declared branch does not say WHICH of the two causes applies, because
377
+ * this function does not know. Discovery does, and it already `console.warn`s
378
+ * the specific reason at load time, so the message points at that warning
379
+ * rather than guessing between them.
380
+ */
381
+ export async function waitForModule(moduleName) {
382
+ const fullName = `@stonyx/${moduleName}`;
383
+ const modulePromise = modulePromises[fullName];
384
+ if (!modulePromise) {
385
+ throw new Error(declaredModuleNames.has(fullName)
386
+ ? `Could wait for module: ${fullName}. It IS declared in this project's dependencies or ` +
387
+ 'devDependencies, but the loader did not load it: either it is not installed under ' +
388
+ 'node_modules, or its package.json does not carry the "stonyx-module" keyword. ' +
389
+ 'loadModules warned which one at load time.'
390
+ : `Could wait for module: ${fullName}. Module was not registered in project dependencies`);
391
+ }
392
+ await modulePromise.ready;
393
+ }
@@ -0,0 +1,186 @@
1
+ /** A resolved `stonyx` package: where it physically is, and what version. */
2
+ export interface CorePackage {
3
+ /** Absolute, symlink-resolved package root (the directory holding package.json). */
4
+ root: string;
5
+ version: string;
6
+ }
7
+ export interface ForeignCore {
8
+ moduleName: string;
9
+ moduleCore: CorePackage;
10
+ runningCore: CorePackage;
11
+ }
12
+ /**
13
+ * Told when the probe could not reach a conclusion.
14
+ *
15
+ * FAIL-OPEN IS THE POLICY; SILENCE IS NOT. `findForeignCores` must never invent
16
+ * a boot failure out of an inconclusive probe — but "we could not check" has to
17
+ * be distinguishable from "we checked and it is fine", because the states that
18
+ * fail open are exactly the states in which a genuine second core is present
19
+ * and unreported. Measured on trees that DO carry a second core, each of these
20
+ * boots clean and silent while the control throws in the same run:
21
+ *
22
+ * - an interposed `dist/package.json`, so the running core cannot name itself
23
+ * - a nested `stonyx/package.json` that is not valid JSON
24
+ * - the same file at `chmod 000`
25
+ *
26
+ * A reporter can only write a line. It cannot change the outcome.
27
+ */
28
+ export type InconclusiveReporter = (message: string) => void;
29
+ /**
30
+ * The directories ESM consults for a bare specifier, in the order it consults
31
+ * them: `<d>/node_modules` for every ancestor-or-self `d` of `fromDir`.
32
+ *
33
+ * THE RESOLVER BEING MODELLED IS ESM. `loadModules` reaches every module
34
+ * through `await import`, so the copy of `stonyx` a module "would import" is
35
+ * whatever ESM's PACKAGE_RESOLVE finds — not whatever CJS finds. This function
36
+ * is generated rather than filtered, and that is the fix for a defect in each
37
+ * direction.
38
+ *
39
+ * TOO WIDE, the previous defect. The candidates used to come from
40
+ * `require.resolve.paths`, which is the CJS walk FOLLOWED BY the CJS global
41
+ * folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`, the node
42
+ * prefix. ESM ignores every one of those, so honouring them invents a
43
+ * "duplicate" out of an ambient environment variable that the real import never
44
+ * consults. This workspace exports `NODE_PATH` at a different `stonyx`
45
+ * (abofs/stonyx#107), so that false positive is not hypothetical: it flips a
46
+ * tree that boots into a hard refusal. Generating the walk REMOVES that failure
47
+ * mode rather than screening for it — nothing here reads the environment, so
48
+ * there is no list to get the screen wrong on.
49
+ *
50
+ * TOO NARROW, and this one was a SILENT MISS. WHICH CJS ENUMERATION IS MEANT
51
+ * IS PART OF THE CLAIM, because the two are not the same set: the walk PROPER
52
+ * is `Module._nodeModulePaths`, and `require.resolve.paths` is that walk
53
+ * FOLLOWED BY the global folders of the paragraph above. It is the walk proper
54
+ * that never descends into a directory already named `node_modules`. **ESM's
55
+ * PACKAGE_RESOLVE has no such skip.** Measured with no `NODE_PATH` at all,
56
+ * from a module at `<app>/node_modules/@stonyx/x`:
57
+ *
58
+ * import.meta.resolve('stonyx') -> <app>/node_modules/node_modules/stonyx/main.js
59
+ * await import('stonyx') -> that copy
60
+ * require.resolve('stonyx') -> MODULE_NOT_FOUND
61
+ *
62
+ * A genuine second core there was missed silently. Worse, because ESM reaches
63
+ * the ancestor `<app>/node_modules` BEFORE `<app>`, that copy SHADOWS a correct
64
+ * local core at `<app>/node_modules/stonyx` while the pre-flight reported
65
+ * success — this file's own opening paragraph ("it loads, it initialises, it
66
+ * reports success, and its hooks silently never fire") surviving the check
67
+ * written to catch it. Isolating control, moving nothing but the directory: the
68
+ * same core at `<app>/node_modules/@stonyx/x/node_modules/stonyx` was REFUSED.
69
+ *
70
+ * THE SET DELTA, both directions and named per enumeration — node v24.13.0,
71
+ * from that same module dir. Against the WALK PROPER the difference is one
72
+ * directory and it is one-sided: `esm \ Module._nodeModulePaths` = exactly
73
+ * `<app>/node_modules/node_modules` (the miss above), and
74
+ * `Module._nodeModulePaths \ esm` = `[]`. Against `require.resolve.paths` the
75
+ * second direction is NOT empty and never was: `require.resolve.paths \ esm` =
76
+ * the three global folders (measured here: `~/.node_modules`,
77
+ * `~/.node_libraries` and the node prefix, all three absent on disk) PLUS one
78
+ * entry per `NODE_PATH` component, four more as this workspace exports it. That tail IS the "too wide" defect above, measured rather than
79
+ * argued, and it is why an unqualified "the CJS candidates are the same" is
80
+ * true of one enumeration and false of the other.
81
+ *
82
+ * BOUNDED BY ANCESTRY, not by a rule. `<app>/node_modules/node_modules` is a
83
+ * candidate because `<app>/node_modules` is an ancestor of the module dir; two
84
+ * levels of nesting is not a candidate, because
85
+ * `<app>/node_modules/node_modules` is not an ancestor of it. Measured: a core
86
+ * planted two levels deep resolves to the real core, and is correctly ignored.
87
+ *
88
+ * THE REJECTED ALTERNATIVE, and the correction that goes with it. While the
89
+ * candidates still came from `require.resolve.paths`, `Module.globalPaths` was
90
+ * reviewed as the screen instead of a shape test, and rejected here on typing
91
+ * grounds plus the claim that the two "admit the identical set". MEASURED, that
92
+ * claim is FALSE, and false in the direction that matters: `globalPaths` filters
93
+ * by STRING MEMBERSHIP, so whenever `NODE_PATH` names a directory that is also
94
+ * a genuine walk entry, the provenance screen drops the walk entry too. Pointed
95
+ * at `<app>/node_modules` with a real second core inside it:
96
+ *
97
+ * NODE_PATH=<app>/node_modules provenance -> no core found
98
+ * shape -> 0.0.0-APP-LOCAL-SECOND-CORE
99
+ * NODE_PATH unset (control) provenance -> 0.0.0-APP-LOCAL-SECOND-CORE
100
+ * shape -> 0.0.0-APP-LOCAL-SECOND-CORE
101
+ *
102
+ * That is a false NEGATIVE on the commonest tree there is — a second core in
103
+ * the app's own `node_modules` going invisible the moment a developer exports
104
+ * `NODE_PATH` at it. So the rejection was right and the stated reason was not:
105
+ * it is not an equal-but-untyped alternative, it is a narrower one that would
106
+ * have introduced a miss. Generating the walk retires the question entirely —
107
+ * there is no list to screen and no environment read — which is why neither
108
+ * screen survives here.
109
+ *
110
+ * ORDER IS LOAD-BEARING — it is what makes the shadowing case detectable, since
111
+ * the first candidate carrying a core is the one returned. It is Node's own
112
+ * order, verified positionally rather than assumed: a distinct core planted in
113
+ * each of the first four candidates and removed one at a time, `await import`
114
+ * selecting exactly this sequence.
115
+ *
116
+ * `fromDir` must already be realpath'd; `coreSeenBy` does that first.
117
+ */
118
+ export declare function esmNodeModulesWalk(fromDir: string): string[];
119
+ /**
120
+ * The nearest ancestor of `startDir` that OWNS a `package.json`, reported only
121
+ * if that package is `stonyx`.
122
+ *
123
+ * Stops at the first `package.json` rather than at the first one NAMED stonyx:
124
+ * the first is by definition the package that owns the file, and continuing
125
+ * past it would walk out of the resolved package and could report an unrelated
126
+ * ancestor — inside this repo's own worktree, that ancestor is the repo root,
127
+ * which IS named `stonyx`. That would make every fixture look like a match.
128
+ *
129
+ * EXPORTED FOR ITS GUARD. That paragraph is an argument that a behaviour is
130
+ * load-bearing, and until D15 the entire suite could not tell the two
131
+ * behaviours apart — a mutant that walked past a non-stonyx manifest survived
132
+ * every test. A docblock asserting a property with no test that can red is the
133
+ * defect family this cluster exists to remove, so the function is exported for
134
+ * one reason: so that claim can fail.
135
+ */
136
+ export declare function owningCore(startDir: string, report?: InconclusiveReporter): CorePackage | null;
137
+ /** The `stonyx` copy that is executing this code. */
138
+ export declare function runningCore(report?: InconclusiveReporter): CorePackage | null;
139
+ /**
140
+ * The `stonyx` copy a package installed at `moduleDir` would import.
141
+ *
142
+ * The candidate set is `esmNodeModulesWalk` — Node's ESM walk, generated, in
143
+ * Node's order — because ESM is the resolver `loadModules` actually uses. The
144
+ * first candidate that carries a `stonyx` package root wins, which is exactly
145
+ * how PACKAGE_RESOLVE picks. Manifests are read directly rather than through
146
+ * `require.resolve`, so a module whose nested core is present but unbuilt is
147
+ * still detected rather than silently skipped by an `exports` map.
148
+ *
149
+ * ONE deliberate narrowing beyond the walk: `moduleDir` is realpath'd FIRST.
150
+ * Under pnpm, `<app>/node_modules/@stonyx/orm` is a symlink into
151
+ * `.pnpm/@stonyx+orm@v/node_modules/@stonyx/orm`, and Node resolves that
152
+ * symlink before resolving the module's own imports. Walking the symlink path
153
+ * instead reads the app's flat `node_modules` and reports the running core for
154
+ * every module — the check would pass vacuously on exactly the installer
155
+ * `stonyx new` uses.
156
+ *
157
+ * Returns `null` when no copy is reachable at all — "cannot tell", handled as
158
+ * not-a-duplicate by `findForeignCores`. Manifests that exist but will not read
159
+ * go to `report`; see `InconclusiveReporter`.
160
+ */
161
+ export declare function coreSeenBy(moduleDir: string, report?: InconclusiveReporter): CorePackage | null;
162
+ /**
163
+ * Every discovered module that would import a DIFFERENT physical core.
164
+ *
165
+ * FAIL DIRECTION — this is the property, not an implementation detail. A
166
+ * module whose core cannot be resolved, and the case where the running core
167
+ * cannot identify itself, both report NOTHING. This guard exists to convert a
168
+ * silent wrong state into a loud one; it must not invent a boot failure out of
169
+ * an inconclusive probe. Every state it does report has two package roots in
170
+ * hand and has compared them.
171
+ */
172
+ export declare function findForeignCores(modules: {
173
+ name: string;
174
+ dir: string;
175
+ }[], core?: CorePackage | null | undefined, report?: InconclusiveReporter): ForeignCore[];
176
+ /**
177
+ * The diagnostic. It replaces the message abofs/stonyx#108 was filed over —
178
+ * `Stonyx modules with async loading must have a config/environment.js file` —
179
+ * which named a file that was present and correct, and named a module that was
180
+ * not the one that failed.
181
+ *
182
+ * Per `quality.md`, the replacement states what it does NOT cover, because the
183
+ * message it replaces is exactly the kind of overstated claim that made the
184
+ * next reader stop checking.
185
+ */
186
+ export declare function duplicateCoreMessage(foreign: ForeignCore[]): string;