apcore-js 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +112 -9
  2. package/dist/acl-handlers.d.ts +14 -0
  3. package/dist/acl-handlers.d.ts.map +1 -1
  4. package/dist/acl-handlers.js +37 -4
  5. package/dist/acl-handlers.js.map +1 -1
  6. package/dist/acl.d.ts +22 -1
  7. package/dist/acl.d.ts.map +1 -1
  8. package/dist/acl.js +90 -34
  9. package/dist/acl.js.map +1 -1
  10. package/dist/async-task.d.ts +70 -16
  11. package/dist/async-task.d.ts.map +1 -1
  12. package/dist/async-task.js +212 -72
  13. package/dist/async-task.js.map +1 -1
  14. package/dist/bindings.d.ts.map +1 -1
  15. package/dist/bindings.js +113 -11
  16. package/dist/bindings.js.map +1 -1
  17. package/dist/builtin-steps.d.ts +33 -8
  18. package/dist/builtin-steps.d.ts.map +1 -1
  19. package/dist/builtin-steps.js +119 -47
  20. package/dist/builtin-steps.js.map +1 -1
  21. package/dist/client.d.ts +1 -0
  22. package/dist/client.d.ts.map +1 -1
  23. package/dist/client.js.map +1 -1
  24. package/dist/config.d.ts +38 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +163 -33
  27. package/dist/config.js.map +1 -1
  28. package/dist/context.d.ts +34 -7
  29. package/dist/context.d.ts.map +1 -1
  30. package/dist/context.js +108 -40
  31. package/dist/context.js.map +1 -1
  32. package/dist/decorator.d.ts +3 -0
  33. package/dist/decorator.d.ts.map +1 -1
  34. package/dist/decorator.js +3 -0
  35. package/dist/decorator.js.map +1 -1
  36. package/dist/errors.d.ts +88 -2
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +231 -56
  39. package/dist/errors.js.map +1 -1
  40. package/dist/events/circuit-breaker.d.ts +45 -0
  41. package/dist/events/circuit-breaker.d.ts.map +1 -0
  42. package/dist/events/circuit-breaker.js +115 -0
  43. package/dist/events/circuit-breaker.js.map +1 -0
  44. package/dist/events/emitter.d.ts +24 -1
  45. package/dist/events/emitter.d.ts.map +1 -1
  46. package/dist/events/emitter.js +86 -12
  47. package/dist/events/emitter.js.map +1 -1
  48. package/dist/events/index.d.ts +4 -2
  49. package/dist/events/index.d.ts.map +1 -1
  50. package/dist/events/index.js +3 -2
  51. package/dist/events/index.js.map +1 -1
  52. package/dist/events/subscribers.d.ts +33 -1
  53. package/dist/events/subscribers.d.ts.map +1 -1
  54. package/dist/events/subscribers.js +124 -1
  55. package/dist/events/subscribers.js.map +1 -1
  56. package/dist/executor.d.ts +14 -3
  57. package/dist/executor.d.ts.map +1 -1
  58. package/dist/executor.js +155 -48
  59. package/dist/executor.js.map +1 -1
  60. package/dist/generated/version.d.ts +1 -1
  61. package/dist/generated/version.js +1 -1
  62. package/dist/index.d.ts +47 -25
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +35 -18
  65. package/dist/index.js.map +1 -1
  66. package/dist/middleware/base.d.ts +25 -3
  67. package/dist/middleware/base.d.ts.map +1 -1
  68. package/dist/middleware/base.js +24 -0
  69. package/dist/middleware/base.js.map +1 -1
  70. package/dist/middleware/circuit-breaker.d.ts +54 -0
  71. package/dist/middleware/circuit-breaker.d.ts.map +1 -0
  72. package/dist/middleware/circuit-breaker.js +168 -0
  73. package/dist/middleware/circuit-breaker.js.map +1 -0
  74. package/dist/middleware/context-namespace.d.ts +30 -0
  75. package/dist/middleware/context-namespace.d.ts.map +1 -0
  76. package/dist/middleware/context-namespace.js +38 -0
  77. package/dist/middleware/context-namespace.js.map +1 -0
  78. package/dist/middleware/index.d.ts +8 -2
  79. package/dist/middleware/index.d.ts.map +1 -1
  80. package/dist/middleware/index.js +5 -2
  81. package/dist/middleware/index.js.map +1 -1
  82. package/dist/middleware/logging.d.ts +6 -0
  83. package/dist/middleware/logging.d.ts.map +1 -1
  84. package/dist/middleware/logging.js +13 -3
  85. package/dist/middleware/logging.js.map +1 -1
  86. package/dist/middleware/manager.d.ts +11 -4
  87. package/dist/middleware/manager.d.ts.map +1 -1
  88. package/dist/middleware/manager.js +26 -9
  89. package/dist/middleware/manager.js.map +1 -1
  90. package/dist/middleware/platform-notify.d.ts +8 -4
  91. package/dist/middleware/platform-notify.d.ts.map +1 -1
  92. package/dist/middleware/platform-notify.js +15 -7
  93. package/dist/middleware/platform-notify.js.map +1 -1
  94. package/dist/middleware/retry.d.ts +16 -7
  95. package/dist/middleware/retry.d.ts.map +1 -1
  96. package/dist/middleware/retry.js +21 -15
  97. package/dist/middleware/retry.js.map +1 -1
  98. package/dist/middleware/tracing.d.ts +50 -0
  99. package/dist/middleware/tracing.d.ts.map +1 -0
  100. package/dist/middleware/tracing.js +89 -0
  101. package/dist/middleware/tracing.js.map +1 -0
  102. package/dist/observability/batch-span-processor.d.ts +48 -0
  103. package/dist/observability/batch-span-processor.d.ts.map +1 -0
  104. package/dist/observability/batch-span-processor.js +89 -0
  105. package/dist/observability/batch-span-processor.js.map +1 -0
  106. package/dist/observability/context-logger.d.ts +54 -1
  107. package/dist/observability/context-logger.d.ts.map +1 -1
  108. package/dist/observability/context-logger.js +287 -10
  109. package/dist/observability/context-logger.js.map +1 -1
  110. package/dist/observability/error-history.d.ts +36 -7
  111. package/dist/observability/error-history.d.ts.map +1 -1
  112. package/dist/observability/error-history.js +169 -50
  113. package/dist/observability/error-history.js.map +1 -1
  114. package/dist/observability/index.d.ts +16 -5
  115. package/dist/observability/index.d.ts.map +1 -1
  116. package/dist/observability/index.js +8 -3
  117. package/dist/observability/index.js.map +1 -1
  118. package/dist/observability/metrics-utils.d.ts.map +1 -1
  119. package/dist/observability/metrics-utils.js +3 -5
  120. package/dist/observability/metrics-utils.js.map +1 -1
  121. package/dist/observability/metrics.d.ts +15 -1
  122. package/dist/observability/metrics.d.ts.map +1 -1
  123. package/dist/observability/metrics.js +37 -3
  124. package/dist/observability/metrics.js.map +1 -1
  125. package/dist/observability/prometheus-exporter.d.ts +37 -0
  126. package/dist/observability/prometheus-exporter.d.ts.map +1 -0
  127. package/dist/observability/prometheus-exporter.js +135 -0
  128. package/dist/observability/prometheus-exporter.js.map +1 -0
  129. package/dist/observability/storage.d.ts +43 -0
  130. package/dist/observability/storage.d.ts.map +1 -0
  131. package/dist/observability/storage.js +58 -0
  132. package/dist/observability/storage.js.map +1 -0
  133. package/dist/observability/store.d.ts +29 -0
  134. package/dist/observability/store.d.ts.map +1 -0
  135. package/dist/observability/store.js +36 -0
  136. package/dist/observability/store.js.map +1 -0
  137. package/dist/observability/tracing.d.ts +2 -0
  138. package/dist/observability/tracing.d.ts.map +1 -1
  139. package/dist/observability/tracing.js +12 -2
  140. package/dist/observability/tracing.js.map +1 -1
  141. package/dist/observability/usage-exporter.d.ts +58 -0
  142. package/dist/observability/usage-exporter.d.ts.map +1 -0
  143. package/dist/observability/usage-exporter.js +86 -0
  144. package/dist/observability/usage-exporter.js.map +1 -0
  145. package/dist/observability/usage.d.ts +18 -1
  146. package/dist/observability/usage.d.ts.map +1 -1
  147. package/dist/observability/usage.js +35 -4
  148. package/dist/observability/usage.js.map +1 -1
  149. package/dist/pipeline-config.d.ts +24 -7
  150. package/dist/pipeline-config.d.ts.map +1 -1
  151. package/dist/pipeline-config.js +113 -19
  152. package/dist/pipeline-config.js.map +1 -1
  153. package/dist/pipeline.d.ts +123 -2
  154. package/dist/pipeline.d.ts.map +1 -1
  155. package/dist/pipeline.js +249 -50
  156. package/dist/pipeline.js.map +1 -1
  157. package/dist/registry/conflicts.d.ts +2 -2
  158. package/dist/registry/conflicts.d.ts.map +1 -1
  159. package/dist/registry/conflicts.js +10 -11
  160. package/dist/registry/conflicts.js.map +1 -1
  161. package/dist/registry/dependencies.d.ts +1 -1
  162. package/dist/registry/dependencies.d.ts.map +1 -1
  163. package/dist/registry/dependencies.js +69 -20
  164. package/dist/registry/dependencies.js.map +1 -1
  165. package/dist/registry/index.d.ts +2 -0
  166. package/dist/registry/index.d.ts.map +1 -1
  167. package/dist/registry/index.js +1 -0
  168. package/dist/registry/index.js.map +1 -1
  169. package/dist/registry/multi-class.d.ts +57 -0
  170. package/dist/registry/multi-class.d.ts.map +1 -0
  171. package/dist/registry/multi-class.js +120 -0
  172. package/dist/registry/multi-class.js.map +1 -0
  173. package/dist/registry/registry.d.ts +99 -4
  174. package/dist/registry/registry.d.ts.map +1 -1
  175. package/dist/registry/registry.js +291 -33
  176. package/dist/registry/registry.js.map +1 -1
  177. package/dist/registry/scanner.d.ts.map +1 -1
  178. package/dist/registry/scanner.js +6 -0
  179. package/dist/registry/scanner.js.map +1 -1
  180. package/dist/registry/version.d.ts +1 -0
  181. package/dist/registry/version.d.ts.map +1 -1
  182. package/dist/registry/version.js +33 -4
  183. package/dist/registry/version.js.map +1 -1
  184. package/dist/schema/constants.d.ts +9 -0
  185. package/dist/schema/constants.d.ts.map +1 -0
  186. package/dist/schema/constants.js +9 -0
  187. package/dist/schema/constants.js.map +1 -0
  188. package/dist/schema/extractor.d.ts +69 -0
  189. package/dist/schema/extractor.d.ts.map +1 -0
  190. package/dist/schema/extractor.js +142 -0
  191. package/dist/schema/extractor.js.map +1 -0
  192. package/dist/schema/index.d.ts +3 -1
  193. package/dist/schema/index.d.ts.map +1 -1
  194. package/dist/schema/index.js +2 -1
  195. package/dist/schema/index.js.map +1 -1
  196. package/dist/schema/loader.d.ts +27 -3
  197. package/dist/schema/loader.d.ts.map +1 -1
  198. package/dist/schema/loader.js +137 -32
  199. package/dist/schema/loader.js.map +1 -1
  200. package/dist/schema/ref-resolver.d.ts.map +1 -1
  201. package/dist/schema/ref-resolver.js +10 -1
  202. package/dist/schema/ref-resolver.js.map +1 -1
  203. package/dist/schema/types.d.ts +4 -0
  204. package/dist/schema/types.d.ts.map +1 -1
  205. package/dist/schema/types.js.map +1 -1
  206. package/dist/schema/validator.d.ts +9 -0
  207. package/dist/schema/validator.d.ts.map +1 -1
  208. package/dist/schema/validator.js +153 -4
  209. package/dist/schema/validator.js.map +1 -1
  210. package/dist/sys-modules/audit.d.ts +50 -0
  211. package/dist/sys-modules/audit.d.ts.map +1 -0
  212. package/dist/sys-modules/audit.js +89 -0
  213. package/dist/sys-modules/audit.js.map +1 -0
  214. package/dist/sys-modules/control.d.ts +32 -4
  215. package/dist/sys-modules/control.d.ts.map +1 -1
  216. package/dist/sys-modules/control.js +197 -23
  217. package/dist/sys-modules/control.js.map +1 -1
  218. package/dist/sys-modules/index.d.ts +7 -2
  219. package/dist/sys-modules/index.d.ts.map +1 -1
  220. package/dist/sys-modules/index.js +3 -1
  221. package/dist/sys-modules/index.js.map +1 -1
  222. package/dist/sys-modules/overrides.d.ts +58 -0
  223. package/dist/sys-modules/overrides.d.ts.map +1 -0
  224. package/dist/sys-modules/overrides.js +106 -0
  225. package/dist/sys-modules/overrides.js.map +1 -0
  226. package/dist/sys-modules/registration.d.ts +18 -1
  227. package/dist/sys-modules/registration.d.ts.map +1 -1
  228. package/dist/sys-modules/registration.js +115 -11
  229. package/dist/sys-modules/registration.js.map +1 -1
  230. package/dist/sys-modules/toggle.d.ts +7 -2
  231. package/dist/sys-modules/toggle.d.ts.map +1 -1
  232. package/dist/sys-modules/toggle.js +61 -5
  233. package/dist/sys-modules/toggle.js.map +1 -1
  234. package/dist/trace-context.d.ts +47 -9
  235. package/dist/trace-context.d.ts.map +1 -1
  236. package/dist/trace-context.js +139 -16
  237. package/dist/trace-context.js.map +1 -1
  238. package/dist/utils/index.d.ts.map +1 -1
  239. package/dist/utils/index.js +2 -1
  240. package/dist/utils/index.js.map +1 -1
  241. package/package.json +1 -1
@@ -3,9 +3,11 @@
3
3
  */
4
4
  import { getDefault } from '../config.js';
5
5
  import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
6
+ import { detectIdConflicts } from './conflicts.js';
6
7
  import { resolveDependencies } from './dependencies.js';
7
8
  import { resolveEntryPoint } from './entry-point.js';
8
9
  import { mergeModuleMetadata, parseDependencies } from './metadata.js';
10
+ import { _discoverMultiClass } from './multi-class.js';
9
11
  import { getSchema } from './schema-export.js';
10
12
  import { toStrictSchema } from '../schema/strict.js';
11
13
  import { deepCopy } from '../utils/index.js';
@@ -64,17 +66,17 @@ export const MODULE_ID_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
64
66
  */
65
67
  export const MAX_MODULE_ID_LENGTH = 192;
66
68
  /**
67
- * Reserved words that cannot appear as any segment of a module ID.
69
+ * Reserved words that cannot appear as the first segment of a module ID.
68
70
  */
69
71
  export const RESERVED_WORDS = new Set(['system', 'internal', 'core', 'apcore', 'plugin', 'schema', 'acl']);
70
72
  /**
71
73
  * Validate a module ID against PROTOCOL_SPEC §2.7 in canonical order.
72
74
  *
73
- * Order: empty → pattern → length → reserved (per-segment).
75
+ * Order: empty → pattern → length → reserved (first-segment).
74
76
  * Duplicate detection is the caller's responsibility (it requires registry
75
77
  * state).
76
78
  *
77
- * When `allowReserved` is true the per-segment reserved word check is
79
+ * When `allowReserved` is true the first-segment reserved word check is
78
80
  * skipped — used by `Registry.registerInternal` so sys modules can use the
79
81
  * `system.*` prefix. All other validations (empty, pattern, length) still
80
82
  * apply.
@@ -98,12 +100,11 @@ function validateModuleId(moduleId, allowReserved) {
98
100
  if (moduleId.length > MAX_MODULE_ID_LENGTH) {
99
101
  throw new InvalidInputError(`Module ID exceeds maximum length of ${MAX_MODULE_ID_LENGTH}: ${moduleId.length}`);
100
102
  }
101
- // 4. reserved word per-segment check (skipped for registerInternal)
103
+ // 4. reserved word first-segment check (skipped for registerInternal)
102
104
  if (!allowReserved) {
103
- for (const segment of moduleId.split('.')) {
104
- if (RESERVED_WORDS.has(segment)) {
105
- throw new InvalidInputError(`Module ID contains reserved word: '${segment}'`);
106
- }
105
+ const firstSegment = moduleId.split('.')[0];
106
+ if (RESERVED_WORDS.has(firstSegment)) {
107
+ throw new InvalidInputError(`Module ID contains reserved word: '${firstSegment}'`);
107
108
  }
108
109
  }
109
110
  }
@@ -116,6 +117,7 @@ export class Registry {
116
117
  [REGISTRY_EVENTS.UNREGISTER, []],
117
118
  ]);
118
119
  _idMap = {};
120
+ _lowercaseMap = new Map();
119
121
  _schemaCache = new Map();
120
122
  _config;
121
123
  _watchers;
@@ -173,9 +175,21 @@ export class Registry {
173
175
  async _discoverCustom() {
174
176
  const rootPaths = this._extensionRoots.map((r) => r['root']);
175
177
  const customModules = await this._customDiscoverer.discover(rootPaths);
178
+ if (!Array.isArray(customModules)) {
179
+ console.warn(`[apcore:registry] Custom discoverer returned non-array (${typeof customModules}); expected Array<{moduleId, module}>. Ignoring.`);
180
+ return 0;
181
+ }
176
182
  let count = 0;
177
183
  for (const entry of customModules) {
184
+ if (entry === null || typeof entry !== 'object') {
185
+ console.warn(`[apcore:registry] Malformed entry from custom discoverer (expected object, got ${entry === null ? 'null' : typeof entry}); skipping.`);
186
+ continue;
187
+ }
178
188
  const { moduleId, module: mod } = entry;
189
+ if (typeof moduleId !== 'string' || mod === undefined) {
190
+ console.warn(`[apcore:registry] Malformed entry from custom discoverer (missing 'moduleId' string or 'module'); skipping.`);
191
+ continue;
192
+ }
179
193
  // Apply custom validator if set
180
194
  if (this._customValidator !== null) {
181
195
  const errors = await this._customValidator.validate(mod);
@@ -184,8 +198,19 @@ export class Registry {
184
198
  continue;
185
199
  }
186
200
  }
201
+ // PROTOCOL_SPEC §2.7 ID validation — sync finding A-D-102.
202
+ // Mirrors apcore-python `Registry._discover_custom` which calls
203
+ // `_validate_module_id` before registration. Invalid IDs are skipped
204
+ // with a warning rather than aborting the whole discover run.
187
205
  try {
188
- this.register(moduleId, mod);
206
+ validateModuleId(moduleId, false);
207
+ }
208
+ catch (e) {
209
+ console.warn(`[apcore:registry] Skipping custom-discovered module with invalid ID '${moduleId}': ${e.message}`);
210
+ continue;
211
+ }
212
+ try {
213
+ this._registerImpl(moduleId, mod);
189
214
  count++;
190
215
  }
191
216
  catch (e) {
@@ -194,6 +219,20 @@ export class Registry {
194
219
  }
195
220
  return count;
196
221
  }
222
+ /**
223
+ * Default discovery pipeline (D-32 — 8 canonical stages, mirroring
224
+ * apcore-rust `default_discoverer.rs`):
225
+ *
226
+ * 1. _ensureIdMap — lazy-load the optional id_map.json
227
+ * 2. _scanRoots — walk extension roots
228
+ * 3. _applyIdMapOverrides — rewrite canonical IDs from the map
229
+ * 4. _loadAllMetadata — load each module's `module.yaml`
230
+ * 5. _resolveAllEntryPoints — import the JS/TS entry point
231
+ * 6. _validateAll — run module/custom validators
232
+ * 7. _filterIdConflicts — batch-drop conflicting / invalid IDs
233
+ * 8. _resolveLoadOrder + _registerInOrder
234
+ * — topological sort then register.
235
+ */
197
236
  async _discoverDefault() {
198
237
  await this._ensureIdMap();
199
238
  const discovered = await this._scanRoots();
@@ -201,8 +240,9 @@ export class Registry {
201
240
  const rawMetadata = await this._loadAllMetadata(discovered);
202
241
  const resolvedModules = await this._resolveAllEntryPoints(discovered, rawMetadata);
203
242
  const validModules = await this._validateAll(resolvedModules);
204
- const loadOrder = this._resolveLoadOrder(validModules, rawMetadata);
205
- return this._registerInOrder(loadOrder, validModules, rawMetadata);
243
+ const filteredModules = this._filterIdConflicts(validModules, rawMetadata);
244
+ const loadOrder = this._resolveLoadOrder(filteredModules, rawMetadata);
245
+ return this._registerInOrder(loadOrder, filteredModules, rawMetadata);
206
246
  }
207
247
  async _scanRoots() {
208
248
  let maxDepth = 8;
@@ -229,7 +269,13 @@ export class Registry {
229
269
  ? dm.filePath.slice(root.length + 1)
230
270
  : null;
231
271
  if (relPath && relPath in this._idMap) {
232
- dm.canonicalId = this._idMap[relPath]['id'];
272
+ const rawId = this._idMap[relPath]['id'];
273
+ if (typeof rawId === 'string' && rawId.length > 0) {
274
+ dm.canonicalId = rawId;
275
+ }
276
+ else {
277
+ console.warn(`[apcore:registry] ID map entry for '${relPath}' has invalid 'id' field (got ${typeof rawId}), skipping override`);
278
+ }
233
279
  break;
234
280
  }
235
281
  }
@@ -276,24 +322,99 @@ export class Registry {
276
322
  }
277
323
  return validModules;
278
324
  }
325
+ /**
326
+ * Stage 7 (D-32) — batch-drop modules with invalid or conflicting IDs.
327
+ *
328
+ * Mirrors apcore-python `_filter_id_conflicts` and apcore-rust
329
+ * `default_discoverer::filter_id_conflicts`. Two failure modes drop a
330
+ * module here (warn + skip) rather than aborting the whole batch:
331
+ * - PROTOCOL_SPEC §2.7 ID validation (empty / pattern / length /
332
+ * reserved-word first segment), and
333
+ * - Algorithm A03 conflict detection (duplicate against an existing
334
+ * registration, lowercase collision, reserved-word collision).
335
+ *
336
+ * Soft-severity conflicts (e.g. case-insensitive match against an
337
+ * already-registered ID at `warn` level) are NOT dropped here — the
338
+ * warning is logged and the module flows through to registration so
339
+ * existing behaviour is preserved. Only `error`-severity conflicts and
340
+ * invalid IDs are filtered out.
341
+ *
342
+ * `rawMetadata` is accepted for cross-language signature parity with
343
+ * the Rust/Python helpers (which may inspect metadata for additional
344
+ * checks); the TS implementation reads only the module ID.
345
+ */
346
+ _filterIdConflicts(validModules, _rawMetadata) {
347
+ const filtered = new Map();
348
+ // Track within-batch IDs (case-insensitive) so two newly-discovered
349
+ // modules whose IDs collide on lowercase don't both slip through.
350
+ const batchLowercase = new Map(this._lowercaseMap);
351
+ const batchIds = new Set(this._modules.keys());
352
+ for (const [modId, mod] of validModules.entries()) {
353
+ try {
354
+ validateModuleId(modId, false);
355
+ }
356
+ catch (e) {
357
+ console.warn(`[apcore:registry] Skipping discovered module with invalid ID '${modId}': ${e.message}`);
358
+ continue;
359
+ }
360
+ const conflict = detectIdConflicts(modId, batchIds, RESERVED_WORDS, batchLowercase);
361
+ if (conflict !== null) {
362
+ if (conflict.severity === 'error') {
363
+ console.warn(`[apcore:registry] Skipping discovered module '${modId}' due to ID conflict: ${conflict.message}`);
364
+ continue;
365
+ }
366
+ // Soft severity — log but keep the module.
367
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
368
+ }
369
+ filtered.set(modId, mod);
370
+ batchIds.add(modId);
371
+ batchLowercase.set(modId.toLowerCase(), modId);
372
+ }
373
+ return filtered;
374
+ }
279
375
  _resolveLoadOrder(validModules, rawMetadata) {
280
376
  const modulesWithDeps = [];
281
- for (const modId of validModules.keys()) {
377
+ const moduleVersions = new Map();
378
+ for (const [modId, cls] of validModules.entries()) {
282
379
  const meta = rawMetadata.get(modId) ?? {};
283
380
  const depsRaw = meta['dependencies'] ?? [];
284
381
  modulesWithDeps.push([modId, depsRaw.length > 0 ? parseDependencies(depsRaw) : []]);
382
+ const yamlVersion = meta['version'];
383
+ const codeVersion = cls?.version;
384
+ const resolvedVersion = (typeof yamlVersion === 'string' && yamlVersion) ||
385
+ (typeof codeVersion === 'string' && codeVersion) ||
386
+ '1.0.0';
387
+ moduleVersions.set(modId, resolvedVersion);
388
+ }
389
+ // Include already-registered modules so inter-batch version constraints
390
+ // resolve against the live registry too.
391
+ for (const [existingId, existingMod] of this._modules.entries()) {
392
+ if (!moduleVersions.has(existingId)) {
393
+ const existingVersion = existingMod?.version;
394
+ if (typeof existingVersion === 'string') {
395
+ moduleVersions.set(existingId, existingVersion);
396
+ }
397
+ }
285
398
  }
286
- const knownIds = new Set(modulesWithDeps.map(([id]) => id));
287
- return resolveDependencies(modulesWithDeps, knownIds);
399
+ const knownIds = new Set([
400
+ ...modulesWithDeps.map(([id]) => id),
401
+ ...this._modules.keys(),
402
+ ]);
403
+ return resolveDependencies(modulesWithDeps, knownIds, moduleVersions);
288
404
  }
289
405
  _registerInOrder(loadOrder, validModules, rawMetadata) {
406
+ // Stage 8 (D-32). Conflict detection / ID validation already happened in
407
+ // `_filterIdConflicts` (stage 7), so this loop is purely a register pass.
290
408
  let count = 0;
291
409
  for (const modId of loadOrder) {
292
410
  const mod = validModules.get(modId);
411
+ if (mod === undefined)
412
+ continue;
293
413
  const modObj = mod;
294
414
  const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
295
415
  this._modules.set(modId, mod);
296
416
  this._moduleMeta.set(modId, mergedMeta);
417
+ this._lowercaseMap.set(modId.toLowerCase(), modId);
297
418
  if (typeof modObj['onLoad'] === 'function') {
298
419
  try {
299
420
  modObj['onLoad']();
@@ -302,6 +423,7 @@ export class Registry {
302
423
  console.warn(`[apcore:registry] onLoad failed for ${modId}, skipping:`, e);
303
424
  this._modules.delete(modId);
304
425
  this._moduleMeta.delete(modId);
426
+ this._lowercaseMap.delete(modId.toLowerCase());
305
427
  continue;
306
428
  }
307
429
  }
@@ -316,16 +438,49 @@ export class Registry {
316
438
  * Validation order (PROTOCOL_SPEC §2.7, aligned with apcore-python and
317
439
  * apcore-rust): empty → pattern → length → reserved (per-segment) →
318
440
  * duplicate.
441
+ *
442
+ * The optional `version` and `metadata` parameters mirror apcore-python's
443
+ * `Registry.register(module_id, module, version=None, metadata=None)` for
444
+ * cross-language signature parity (sync finding A-001). Multi-version
445
+ * coexistence is not yet implemented in this SDK — when supplied, both
446
+ * fields are merged into the module's metadata so callers can read them
447
+ * back via `getDefinition()` and `list({tags})`. See PROTOCOL_SPEC §5.4.
319
448
  */
320
- register(moduleId, module) {
449
+ register(moduleId, module, version, metadata) {
321
450
  validateModuleId(moduleId, false);
322
- if (this._modules.has(moduleId)) {
323
- throw new InvalidInputError(`Module ID '${moduleId}' is already registered`);
451
+ if (this._customValidator !== null) {
452
+ const result = this._customValidator.validate(module);
453
+ if (result instanceof Promise) {
454
+ throw new InvalidInputError(`Custom validator for '${moduleId}' is async — use discover() which awaits the validator, or register after awaiting validation manually.`);
455
+ }
456
+ if (result.length > 0) {
457
+ throw new InvalidInputError(`Custom validator rejected module '${moduleId}': ${result.join('; ')}`);
458
+ }
459
+ }
460
+ const overrides = { ...(metadata ?? {}) };
461
+ if (version !== undefined && version !== null) {
462
+ overrides['version'] = version;
463
+ }
464
+ this._registerImpl(moduleId, module, overrides);
465
+ }
466
+ /** Inner registration — no validator, no ID validation. Used by discover() paths that run their own checks. */
467
+ _registerImpl(moduleId, module, metadataOverrides = {}) {
468
+ // Algorithm A03: detect ID conflicts (exact duplicate, reserved word, case collision)
469
+ const conflict = detectIdConflicts(moduleId, new Set(this._modules.keys()), RESERVED_WORDS, this._lowercaseMap);
470
+ if (conflict !== null) {
471
+ if (conflict.severity === 'error') {
472
+ throw new InvalidInputError(conflict.message);
473
+ }
474
+ else {
475
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
476
+ }
324
477
  }
325
478
  this._modules.set(moduleId, module);
326
- // Populate metadata from the module object
479
+ this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
480
+ // Populate metadata from the module object, layering any explicit overrides
481
+ // (e.g. the `version` / `metadata` args passed to `register()`) on top.
327
482
  const modObj = module;
328
- this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
483
+ this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, metadataOverrides));
329
484
  // Call onLoad if available
330
485
  if (typeof modObj['onLoad'] === 'function') {
331
486
  try {
@@ -334,6 +489,7 @@ export class Registry {
334
489
  catch (e) {
335
490
  this._modules.delete(moduleId);
336
491
  this._moduleMeta.delete(moduleId);
492
+ this._lowercaseMap.delete(moduleId.toLowerCase());
337
493
  throw e;
338
494
  }
339
495
  }
@@ -346,6 +502,7 @@ export class Registry {
346
502
  this._modules.delete(moduleId);
347
503
  this._moduleMeta.delete(moduleId);
348
504
  this._schemaCache.delete(moduleId);
505
+ this._lowercaseMap.delete(moduleId.toLowerCase());
349
506
  // Call onUnload if available
350
507
  const modObj = module;
351
508
  if (typeof modObj['onUnload'] === 'function') {
@@ -359,7 +516,19 @@ export class Registry {
359
516
  this._triggerEvent(REGISTRY_EVENTS.UNREGISTER, moduleId, module);
360
517
  return true;
361
518
  }
362
- get(moduleId) {
519
+ /**
520
+ * Look up a registered module by ID.
521
+ *
522
+ * @param moduleId - Module identifier (must be non-empty).
523
+ * @param _versionHint - Optional semver range for multi-version coexistence
524
+ * (PROTOCOL_SPEC §5.4). This SDK currently exposes a single-version
525
+ * registry, so the hint is accepted for cross-language API parity with
526
+ * apcore-python (sync finding A-002) but does NOT participate in
527
+ * resolution: the latest registered module for `moduleId` is returned
528
+ * regardless of the hint. When multi-version registration lands, this
529
+ * parameter will gate semver-range matching.
530
+ */
531
+ get(moduleId, _versionHint) {
363
532
  if (moduleId === '') {
364
533
  throw new ModuleNotFoundError('');
365
534
  }
@@ -401,7 +570,18 @@ export class Registry {
401
570
  get moduleIds() {
402
571
  return [...this._modules.keys()].sort();
403
572
  }
404
- getDefinition(moduleId) {
573
+ getDefinition(moduleId, _versionHint) {
574
+ // `_versionHint` accepted for cross-language API parity with apcore-python
575
+ // (sync finding A-002 / §5.4). Ignored under the single-version registry;
576
+ // see `get()` for the rationale.
577
+ //
578
+ // D10-011: spec registry-system.md:382 says any error that `get(module_id)`
579
+ // raises is propagated. The empty-string guard mirrors `get()` (line 669)
580
+ // so callers using getDefinition see the same ModuleNotFoundError as
581
+ // get(), matching apcore-python where getDefinition routes through get().
582
+ if (moduleId === '') {
583
+ throw new ModuleNotFoundError('');
584
+ }
405
585
  const module = this._modules.get(moduleId);
406
586
  if (module == null)
407
587
  return null;
@@ -500,6 +680,29 @@ export class Registry {
500
680
  }
501
681
  }
502
682
  }
683
+ /**
684
+ * Watch the configured extension roots for filesystem changes and
685
+ * unregister any module whose source file is modified or deleted.
686
+ *
687
+ * **Cross-language divergence (sync finding A-D-104):** unlike apcore-python
688
+ * (which re-imports the file via `importlib.reload`) and apcore-rust (which
689
+ * triggers full rediscovery), the TypeScript SDK is **event-only**. On a
690
+ * file change the registry:
691
+ * 1. unregisters the previously-loaded module (calling its `onUnload`),
692
+ * 2. emits a `'file_changed'` event with `{ filePath }` payload.
693
+ *
694
+ * Consumers are expected to subscribe and re-register the module
695
+ * themselves (e.g. by calling `registry.discover()` or registering a fresh
696
+ * import). ES module specifiers are immutable in Node — there is no
697
+ * portable "reload from disk" primitive — so a transparent dynamic
698
+ * `import()` would silently return the cached old module on every
699
+ * invocation. A workaround using a cache-busting query (`?v=Date.now()`)
700
+ * leaks the old module each reload and breaks browser bundlers, so it is
701
+ * intentionally **not** offered here.
702
+ *
703
+ * If your application needs Python-style hot-reload semantics, listen for
704
+ * `'file_changed'` and re-discover or re-import explicitly.
705
+ */
503
706
  async watch() {
504
707
  if (this._watchers && this._watchers.length > 0) {
505
708
  return; // Already watching
@@ -524,24 +727,32 @@ export class Registry {
524
727
  if (now - last < 300)
525
728
  return;
526
729
  this._debounceTimers?.set(fullPath, now);
730
+ const handle = (p) => {
731
+ p.catch((e) => {
732
+ console.warn(`[apcore:registry] Watch handler failed for ${fullPath}:`, e);
733
+ });
734
+ };
527
735
  if (eventType === "rename") {
528
736
  // Could be create or delete
529
737
  try {
530
738
  fs.accessSync(fullPath);
531
- this._handleFileChange(fullPath);
739
+ handle(this._handleFileChange(fullPath));
532
740
  }
533
741
  catch {
534
- this._handleFileDeletion(fullPath);
742
+ handle(this._handleFileDeletion(fullPath));
535
743
  }
536
744
  }
537
745
  else {
538
- this._handleFileChange(fullPath);
746
+ handle(this._handleFileChange(fullPath));
539
747
  }
540
748
  });
541
749
  this._watchers.push(watcher);
542
750
  }
543
- catch {
544
- // Skip directories that don't exist
751
+ catch (e) {
752
+ // Surface real failures (EMFILE, EACCES, Linux kernels < 4.7 without
753
+ // recursive support, etc.). A silently non-functional watch misleads
754
+ // users who expect hot-reload to be active.
755
+ console.warn(`[apcore:registry] fs.watch failed for '${rootPath}' — hot-reload disabled for this root:`, e);
545
756
  }
546
757
  }
547
758
  }
@@ -564,12 +775,17 @@ export class Registry {
564
775
  try {
565
776
  oldModule.onUnload();
566
777
  }
567
- catch { /* ignore */ }
778
+ catch (e) {
779
+ console.warn(`[apcore:registry] onUnload failed for '${moduleId}':`, e);
780
+ }
568
781
  }
569
782
  this.unregister(moduleId);
570
783
  }
571
- // Re-import is complex in ES modules - emit event for user to handle
572
- this._triggerEvent("register", moduleId ?? basename(filePath, extname(filePath)), null);
784
+ // Re-import is complex in ES modules tell consumers that a watched file
785
+ // changed so they can re-import and re-register. The earlier design
786
+ // emitted a 'register' event with a null module, which crashed any
787
+ // consumer that accessed fields on the module argument.
788
+ this._triggerEvent("file_changed", moduleId ?? basename(filePath, extname(filePath)), { filePath });
573
789
  }
574
790
  async _handleFileDeletion(path) {
575
791
  const moduleId = this._pathToModuleId(path);
@@ -579,7 +795,9 @@ export class Registry {
579
795
  try {
580
796
  module.onUnload();
581
797
  }
582
- catch { /* ignore */ }
798
+ catch (e) {
799
+ console.warn(`[apcore:registry] onUnload failed for '${moduleId}':`, e);
800
+ }
583
801
  }
584
802
  this.unregister(moduleId);
585
803
  }
@@ -606,12 +824,30 @@ export class Registry {
606
824
  */
607
825
  registerInternal(moduleId, module) {
608
826
  validateModuleId(moduleId, true);
609
- if (this._modules.has(moduleId)) {
610
- throw new InvalidInputError(`Module ID '${moduleId}' is already registered`);
827
+ // D11-007: route duplicate detection through detectIdConflicts (with an
828
+ // empty reserved-words set so the bypass for system.* prefixes is
829
+ // preserved). This restores the case-collision branch present in
830
+ // apcore-python (registry.py:1674) and apcore-rust (registry.rs:727).
831
+ // The lowercase-only EBNF in validateModuleId makes case collisions
832
+ // unreachable today, but the contract surface stays aligned across SDKs.
833
+ const conflict = detectIdConflicts(moduleId, new Set(this._modules.keys()), new Set(), this._lowercaseMap);
834
+ if (conflict !== null) {
835
+ if (conflict.severity === 'error') {
836
+ throw new InvalidInputError(conflict.message);
837
+ }
838
+ else {
839
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
840
+ }
611
841
  }
612
842
  this._modules.set(moduleId, module);
613
843
  const modObj = module;
614
844
  this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
845
+ // Mirror apcore-python register_internal and apcore-rust register_core:
846
+ // every registration site (including sys/internal) populates the lowercase
847
+ // index. The lowercase-only EBNF pattern enforced by validateModuleId makes
848
+ // case collisions unreachable today, but keeping _lowercaseMap consistent
849
+ // with _modules preserves the invariant for downstream conflict detection.
850
+ this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
615
851
  if (typeof modObj['onLoad'] === 'function') {
616
852
  try {
617
853
  modObj['onLoad']();
@@ -619,6 +855,7 @@ export class Registry {
619
855
  catch (e) {
620
856
  this._modules.delete(moduleId);
621
857
  this._moduleMeta.delete(moduleId);
858
+ this._lowercaseMap.delete(moduleId.toLowerCase());
622
859
  throw e;
623
860
  }
624
861
  }
@@ -649,6 +886,18 @@ export class Registry {
649
886
  this._schemaCache.clear();
650
887
  }
651
888
  // ── Safe Hot-Reload (F09 / Algorithm A21) ───────────────────────
889
+ /**
890
+ * Discover module IDs for the classes in a single file under multi-class
891
+ * mode (PROTOCOL_SPEC §2.1.1).
892
+ *
893
+ * D-15: cross-language alignment with Python `Registry.discover_multi_class`
894
+ * and the Rust trait method. Internally delegates to the free function
895
+ * {@link discoverMultiClass} (re-exported as `_discoverMultiClass` for
896
+ * scanner internals), so behaviour is identical.
897
+ */
898
+ discoverMultiClass(filePath, classes, extensionsRoot = 'extensions', multiClassEnabled = false) {
899
+ return _discoverMultiClass(filePath, classes, extensionsRoot, multiClassEnabled);
900
+ }
652
901
  /**
653
902
  * Number of in-flight executions per module.
654
903
  */
@@ -710,9 +959,18 @@ export class Registry {
710
959
  }
711
960
  /**
712
961
  * Remove the draining mark and clean up drain state.
962
+ *
963
+ * If any waitDrained waiters are still pending (e.g., refCount briefly
964
+ * hit zero and then a new acquire bumped it back up), resolve them first
965
+ * so they do not wait for their individual timeouts before returning.
713
966
  */
714
967
  endDrain(moduleId) {
715
968
  this._draining.delete(moduleId);
969
+ const resolvers = this._drainResolvers.get(moduleId);
970
+ if (resolvers) {
971
+ for (const resolve of resolvers)
972
+ resolve();
973
+ }
716
974
  this._drainResolvers.delete(moduleId);
717
975
  this._refCounts.delete(moduleId);
718
976
  }