stonyx 0.2.3-alpha.59 → 0.2.3-alpha.60

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.d.ts CHANGED
@@ -2,4 +2,29 @@ import type { StoynxModule } from './lifecycle.js';
2
2
  import type Chronicle from '@stonyx/logs';
3
3
  export type StoynxConfig = Record<string, Record<string, unknown> | unknown>;
4
4
  export default function loadModules(config: StoynxConfig, rootPath: string, chronicle: Chronicle): Promise<StoynxModule[]>;
5
+ /**
6
+ * Blocks until `@stonyx/<moduleName>` has finished initializing.
7
+ *
8
+ * TWO failure sentences, because one sentence stopped being able to carry both
9
+ * facts. Until PR #120 fix round 1 `modulePromises` held every DECLARED
10
+ * `@stonyx/*` name, so "absent from the map" and "absent from the manifest"
11
+ * were the same fact and the single inherited sentence was true. Registration
12
+ * is now scoped to the DISCOVERED set — which is what removes the boot hang,
13
+ * see the registration loop in `loadModules` — so absence from the map has two
14
+ * causes, and the inherited sentence is FALSE for one of them: it tells the
15
+ * operator the package is not in their dependencies while it is sitting in
16
+ * their manifest.
17
+ *
18
+ * Not hypothetical, and not a shape this repo has to imagine: `@stonyx/logs`
19
+ * ships without the `stonyx-module` keyword and is an ordinary `dependencies`
20
+ * entry of THIS package.json. An app in the same shape calling
21
+ * `waitForModule('logs')` would be sent to grep a manifest that already
22
+ * contains the answer. Naming the wrong cause is the defect abofs/stonyx#108
23
+ * was filed over, and it is not made acceptable by being one line long.
24
+ *
25
+ * The declared branch does not say WHICH of the two causes applies, because
26
+ * this function does not know. Discovery does, and it already `console.warn`s
27
+ * the specific reason at load time, so the message points at that warning
28
+ * rather than guessing between them.
29
+ */
5
30
  export declare function waitForModule(moduleName: string): Promise<void>;
package/dist/modules.js CHANGED
@@ -8,6 +8,19 @@ import { mergeObject } from '@stonyx/utils/object';
8
8
  import { importConfig, CONFIG_NOT_FOUND_PREFIX, CONFIG_NOT_LOADABLE_PREFIX } from './util/import-config.js';
9
9
  import { findForeignCores, duplicateCoreMessage } from './util/duplicate-core.js';
10
10
  const modulePromises = {};
11
+ // The `@stonyx/*` names this app DECLARED — which since PR #120 fix round 1 is
12
+ // a strictly WIDER set than the keys of `modulePromises`. Registration is now
13
+ // scoped to what discovery admitted, so a declared name whose manifest is
14
+ // missing, or whose manifest carries no `stonyx-module` keyword, is in this set
15
+ // and not in that map. `waitForModule`'s failure branch is the only consumer,
16
+ // and this is the only question it needs answered: "is this name in the
17
+ // manifest?" is exactly what `modulePromises` stopped being able to answer.
18
+ //
19
+ // Reassigned per `loadModules` call rather than accumulated, so it tracks the
20
+ // manifest of the most recent load exactly as `modulePromises` tracks that
21
+ // load's discovery — one derived fact, one lifetime, rather than a second
22
+ // singleton drifting away from the first.
23
+ let declaredModuleNames = new Set();
11
24
  // Configure module-specific logging
12
25
  function configureLog(chronicle, module, config) {
13
26
  const { logColor, logMethod, logTimestamp } = config;
@@ -25,9 +38,22 @@ function initializeModule(moduleName, moduleClass, modules, initPromises) {
25
38
  // #120, and the one that scoping registration to `discovered` does NOT close,
26
39
  // because such a module IS discovered — measured, not reasoned: with only
27
40
  // the registration fix applied the suite read 180/1 with T24 the sole
28
- // failure. Resolving here is truthful, because the module really did load,
29
- // and that is what separates it from resolving on a discovery `continue`,
30
- // where there is no module to be ready.
41
+ // failure. Resolving here is readiness BY DEFINITION — there is no `init()`,
42
+ // so there is nothing to wait for — and that is what separates it from
43
+ // resolving on a discovery `continue`, where there is no module to be ready
44
+ // at all.
45
+ //
46
+ // It is deliberately NOT the stronger claim that the module initialised.
47
+ // `waitForModule` is documented as blocking until a module "finishes
48
+ // initializing", and a `static async init()` typo — which TypeScript cannot
49
+ // catch, since `init?()` is optional in lifecycle.ts — presents to the loader
50
+ // as a class with no `init()`. This resolves it, and the waiter proceeds
51
+ // against a module whose own initialisation never ran; measured, the waiter
52
+ // observes the module's ready flag still false. The loader cannot tell that
53
+ // typo from a legitimate no-`init()` module, and a no-`init()` module is a
54
+ // supported shape, so resolving is the only non-hanging answer available and
55
+ // availability over a silent hang is the right trade. The claim is what is
56
+ // narrowed here, not the behaviour.
31
57
  //
32
58
  // `?.` for the reason the resolve below carries it: `initializeModule` is
33
59
  // also reached from the standalone path as `initializeModule(projectName, …)`,
@@ -114,9 +140,17 @@ export default async function loadModules(config, rootPath, chronicle) {
114
140
  // A bare `[ ...Object.keys(deps), ...Object.keys(devDeps) ]` concat instantiates
115
141
  // a dual-declared module twice and runs its `init()` twice. That WOULD be
116
142
  // invisible to the suite aggregate were T22 not armed; T22's instance-count
117
- // assertion is what makes it visible. Measured at this head with the concat
118
- // seeded: 178/1 with T22 the sole failure, both of its counts reading actual 2
119
- // against expected 1.
143
+ // assertion is what makes it visible. With the concat seeded, T22 is the SOLE
144
+ // failure and both of its counts read actual 2 against expected 1 — measured
145
+ // 180 pass / 1 fail at f575a3c, out of a 181-test suite.
146
+ //
147
+ // The SHA is not decoration. This sentence replaced one that round 1 found
148
+ // false, and its replacement carried a figure measured at the PREVIOUS head
149
+ // under the words "at this head": 178/1, which cannot reconcile against a
150
+ // 181-test suite at all. Four reviewers caught it independently. A bare
151
+ // aggregate has no author and no date, so it rots silently on the next commit
152
+ // that adds a test; an aggregate with the SHA it was taken at stays true
153
+ // forever and tells the next reader exactly what to re-run.
120
154
  const declaredDependencies = {
121
155
  ...(rootPackage.dependencies || {}),
122
156
  ...(rootPackage.devDependencies || {}),
@@ -130,6 +164,7 @@ export default async function loadModules(config, rootPath, chronicle) {
130
164
  // unresolved. T3 owns that kill. What is a module is decided below, by the
131
165
  // `stonyx-module` keyword; this is only a name test that bounds the scan.
132
166
  const moduleDependencies = Object.keys(declaredDependencies).filter((moduleName) => moduleName.startsWith('@stonyx/'));
167
+ declaredModuleNames = new Set(moduleDependencies);
133
168
  // DISCOVERY, hoisted above the pre-flight.
134
169
  //
135
170
  // The pre-flight must check exactly what the loader loads, and no more.
@@ -168,8 +203,16 @@ export default async function loadModules(config, rootPath, chronicle) {
168
203
  // contains, and the widening turned a fast named throw into an unbounded
169
204
  // boot hang. A name registered here but never resolved leaves
170
205
  // `waitForModule` pending forever; discovery `continue`s past two kinds of
171
- // name without reaching either of this file's two resolve sites — a manifest
206
+ // name without reaching ANY of this file's three resolve sites — a manifest
172
207
  // that is not there, and a package without the `stonyx-module` keyword.
208
+ // Three, not the two this sentence claimed when it was written: the
209
+ // no-`init()` early return in `initializeModule` is a resolve site and the
210
+ // commit that wrote this sentence is the commit that added it. The sites are
211
+ // named rather than numbered by line, because the comment above
212
+ // `initializeModule` reasons from this inventory and a stale count sends the
213
+ // next dangling-promise audit one site short — they are the no-`init()`
214
+ // early return, the `init()` wrapper, and the sync-module `continue` in the
215
+ // load loop.
173
216
  // `@stonyx/logs` ships without that keyword and is an ordinary `dependencies`
174
217
  // entry, so this is a shape the fleet actually has.
175
218
  //
@@ -177,10 +220,38 @@ export default async function loadModules(config, rootPath, chronicle) {
177
220
  // base 5693744 throws "…was not registered in project dependencies", 3 ms
178
221
  // a57045e one console.warn, then loadModules never settles
179
222
  //
180
- // Registering only what discovery admitted restores the fail-fast for both,
181
- // and for `devDependencies`-declared names too — that half is INHERITED, not
182
- // introduced here, and it is a real behaviour change: hang becomes throw.
183
- // T13 pins it, and the PR body names it.
223
+ // Registering only what discovery admitted restores the fail-fast for both
224
+ // of those DISCOVERY FAILURES, and for `devDependencies`-declared names too
225
+ // — that half is INHERITED, not introduced here, and it is a real behaviour
226
+ // change: hang becomes throw — and the throw names the right cause, because
227
+ // `modulePromises` is no longer the app's declared list and `waitForModule`
228
+ // below branches on that difference rather than asserting the old one. T13
229
+ // pins both halves, and the PR body names them.
230
+ //
231
+ // SCOPED DELIBERATELY, because the sentence above would otherwise read as a
232
+ // closure statement for the whole never-settling-promise family and it is
233
+ // not one. What it closes is names that FAIL discovery. A name that PASSES
234
+ // discovery is registered, and its promise is resolved only once its own
235
+ // `init()` has settled — so two discovered modules whose `init()`s await
236
+ // each other through `waitForModule` are both registered, reach no resolve
237
+ // site, and leave `await Promise.all(initPromises)` at the end of this
238
+ // function pending forever. Measured on a cross-map mutual wait
239
+ // (`@stonyx/c1-a` in `dependencies`, `@stonyx/c1-b` in `devDependencies`,
240
+ // each `init()` awaiting the other), 3000 ms cap:
241
+ // base 5693744 threw in 3 ms — the `dependencies` half was never
242
+ // discovered, so it failed fast for the wrong reason
243
+ // a57045e HUNG, 3001 ms
244
+ // f575a3c HUNG, 3003 ms — scoping registration does not touch it
245
+ // Rule 3 moves that shape from fail-fast to deadlock and nothing in this
246
+ // file closes it.
247
+ //
248
+ // NO DETECTION IS ATTEMPTED, and that is a decision rather than an
249
+ // oversight. `waitForModule` is told which module is being waited FOR and
250
+ // never which module is waiting, so a cycle is not observable without
251
+ // caller attribution that does not exist here, and this framework sets no
252
+ // boot timeout to hang a fallback off. A wait cycle is an authoring error in
253
+ // the modules; docs/modules.md's `waitForModule` section records the hazard
254
+ // so it is documented rather than merely known.
184
255
  //
185
256
  // The alternative — resolving in each `continue` branch — also removes the
186
257
  // hang and is worse: `waitForModule` would report success for a module that
@@ -253,10 +324,40 @@ export default async function loadModules(config, rootPath, chronicle) {
253
324
  await Promise.all(initPromises);
254
325
  return modules;
255
326
  }
327
+ /**
328
+ * Blocks until `@stonyx/<moduleName>` has finished initializing.
329
+ *
330
+ * TWO failure sentences, because one sentence stopped being able to carry both
331
+ * facts. Until PR #120 fix round 1 `modulePromises` held every DECLARED
332
+ * `@stonyx/*` name, so "absent from the map" and "absent from the manifest"
333
+ * were the same fact and the single inherited sentence was true. Registration
334
+ * is now scoped to the DISCOVERED set — which is what removes the boot hang,
335
+ * see the registration loop in `loadModules` — so absence from the map has two
336
+ * causes, and the inherited sentence is FALSE for one of them: it tells the
337
+ * operator the package is not in their dependencies while it is sitting in
338
+ * their manifest.
339
+ *
340
+ * Not hypothetical, and not a shape this repo has to imagine: `@stonyx/logs`
341
+ * ships without the `stonyx-module` keyword and is an ordinary `dependencies`
342
+ * entry of THIS package.json. An app in the same shape calling
343
+ * `waitForModule('logs')` would be sent to grep a manifest that already
344
+ * contains the answer. Naming the wrong cause is the defect abofs/stonyx#108
345
+ * was filed over, and it is not made acceptable by being one line long.
346
+ *
347
+ * The declared branch does not say WHICH of the two causes applies, because
348
+ * this function does not know. Discovery does, and it already `console.warn`s
349
+ * the specific reason at load time, so the message points at that warning
350
+ * rather than guessing between them.
351
+ */
256
352
  export async function waitForModule(moduleName) {
257
353
  const fullName = `@stonyx/${moduleName}`;
258
354
  const modulePromise = modulePromises[fullName];
259
- if (!modulePromise)
260
- throw new Error(`Could wait for module: ${fullName}. Module was not registered in project dependencies`);
261
- await modulePromises[fullName].ready;
355
+ if (!modulePromise) {
356
+ throw new Error(declaredModuleNames.has(fullName)
357
+ ? `Could wait for module: ${fullName}. It IS declared in this project's dependencies, but the ` +
358
+ 'loader did not load it: either it is not installed under node_modules, or its package.json ' +
359
+ 'does not carry the "stonyx-module" keyword. loadModules warned which one at load time.'
360
+ : `Could wait for module: ${fullName}. Module was not registered in project dependencies`);
361
+ }
362
+ await modulePromise.ready;
262
363
  }
@@ -374,16 +374,40 @@ export function duplicateCoreMessage(foreign) {
374
374
  // de-duplicated union of `dependencies` and `devDependencies`, and until
375
375
  // PR #120 fix round 1 this sentence still said `devDependencies` only —
376
376
  // so a refusal could name `seen by "@stonyx/x"` for a package declared
377
- // ONLY in `dependencies` and then, seven lines later, tell the operator
377
+ // ONLY in `dependencies` and then, a few lines later, tell the operator
378
378
  // that such a declaration is not counted. Reproduced verbatim before the
379
379
  // fix. That sends them to search `devDependencies`, where there is nothing.
380
380
  // If the scan source ever changes again, this string changes with it;
381
381
  // `duplicate-core-test.ts` D4 asserts the claim, not just the header.
382
+ //
383
+ // FIX ROUND 2 corrects the closing sentence for the same reason. It used to
384
+ // exclude "a copy dragged in by ... a package without the keyword", which
385
+ // is false for the commonest shape there is: `npm install -g stonyx` plus
386
+ // `stonyx new` writes `stonyx` into the app's own `dependencies`
387
+ // (`src/cli/new.ts`), `stonyx/package.json` carries no `keywords` field at
388
+ // all, and every discovered module's ESM walk reaches
389
+ // `<app>/node_modules/stonyx` — so that copy is counted and PRINTED in the
390
+ // `seen by` row this sentence sits under. It is the exact case the
391
+ // duplicate-INSTALL remedy branch above was written for. The sentence now
392
+ // names what is ENUMERATED — the resolution walk — instead of closing a set
393
+ // of packages, because the enumeration is a property the code has and the
394
+ // set is not. D20 pins it against a fixture that counts such a copy.
395
+ //
396
+ // "FIRST copy that walk finds" is not padding, and it is the round-2
397
+ // rewrite's own near-miss: `coreSeenBy` returns on the first `asCore` hit,
398
+ // so a second copy further up the same walk is SHADOWED and never counted.
399
+ // Written as "a copy owned by any package on that walk is counted" this
400
+ // sentence would have been false in the same shape as the one it replaces —
401
+ // over-closing in the other direction. The shadowing case is stated because
402
+ // it is the reason the fail-open note below matters.
382
403
  'Scope of this check: it compares physical package ROOTS only. It does not check that the ' +
383
- 'single surviving copy is a compatible version, and it looks at @stonyx/* packages declared in ' +
384
- 'this app\'s dependencies or devDependencies that carry the "stonyx-module" keyword — nothing ' +
385
- 'else. Either placement counts: the loader scans the de-duplicated union of both maps. A copy ' +
386
- 'dragged in by anything outside that set — a transitive dependency, a package without the ' +
387
- 'keyword, or one not declared in this app\'s manifest at all — is not counted.',
404
+ 'single surviving copy is a compatible version, and the modules it starts from are the ' +
405
+ '@stonyx/* packages declared in this app\'s dependencies or devDependencies that carry the ' +
406
+ '"stonyx-module" keyword — nothing else. Either placement counts: the loader scans the ' +
407
+ 'de-duplicated union of both maps. What it then reports is not those packages\' own copies but ' +
408
+ 'the copy each of them would IMPORT: for every one it follows Node\'s ESM resolution walk up ' +
409
+ 'from that module\'s own directory and takes the FIRST copy that walk finds, whoever owns it — ' +
410
+ 'including this app\'s own node_modules/stonyx, which no module declares. A copy no module\'s ' +
411
+ 'walk reaches first is not counted, and one further up a walk is hidden by a nearer one.',
388
412
  ].join('\n');
389
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stonyx",
3
- "version": "0.2.3-alpha.59",
3
+ "version": "0.2.3-alpha.60",
4
4
  "description": "Base stonyx framework module",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",