d365fo-mcp 1.13.0 → 1.14.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 (70) hide show
  1. package/bridge/D365MetadataBridge/Program.cs +128 -20
  2. package/bridge/D365MetadataBridge/Protocol/RequestDispatcher.cs +37 -0
  3. package/bridge/D365MetadataBridge/Services/MetadataReadService.cs +56 -59
  4. package/dist/bridge/bridgeAdapter.js +7 -1
  5. package/dist/cli/session/analyze.js +6 -1
  6. package/dist/knowledge/kernelEnums.d.ts +64 -0
  7. package/dist/knowledge/kernelEnums.js +104 -0
  8. package/dist/server/toolSchemas/buildD365foProject.d.ts +1 -1
  9. package/dist/server/toolSchemas/buildD365foProject.js +2 -2
  10. package/dist/server/toolSchemas/d365foFile.js +7 -6
  11. package/dist/server/toolSchemas/extensionInfo.js +2 -2
  12. package/dist/server/toolSchemas/findReferences.js +1 -1
  13. package/dist/server/toolSchemas/getKnowledge.d.ts +8 -0
  14. package/dist/server/toolSchemas/getKnowledge.js +6 -0
  15. package/dist/server/toolSchemas/getObjectInfo.js +3 -3
  16. package/dist/server/toolSchemas/index.d.ts +10 -60
  17. package/dist/server/toolSchemas/labels.d.ts +1 -19
  18. package/dist/server/toolSchemas/labels.js +7 -15
  19. package/dist/server/toolSchemas/objectPatterns.js +3 -3
  20. package/dist/server/toolSchemas/runBpCheck.js +3 -3
  21. package/dist/server/toolSchemas/runSystestClass.js +2 -2
  22. package/dist/server/toolSchemas/search.d.ts +0 -36
  23. package/dist/server/toolSchemas/search.js +3 -42
  24. package/dist/server/toolSchemas/triggerDbSync.d.ts +0 -4
  25. package/dist/server/toolSchemas/triggerDbSync.js +4 -5
  26. package/dist/server/toolSchemas/undoLastModification.js +1 -1
  27. package/dist/server/toolSchemas/validateCode.js +3 -3
  28. package/dist/server/toolSchemas/verifyD365foProject.js +1 -1
  29. package/dist/tools/analysis/search.js +4 -2
  30. package/dist/tools/analysis/validateCode.d.ts +1 -1
  31. package/dist/tools/analysis/validateCode.js +237 -42
  32. package/dist/tools/analysis/validateObjectNaming.d.ts +4 -3
  33. package/dist/tools/analysis/validateObjectNaming.js +12 -355
  34. package/dist/tools/d365foFile.js +91 -2
  35. package/dist/tools/knowledge/getKnowledge.d.ts +2 -0
  36. package/dist/tools/knowledge/getKnowledge.js +71 -0
  37. package/dist/tools/prepare/prepare.d.ts +3 -1
  38. package/dist/tools/prepare/prepare.js +53 -1
  39. package/dist/tools/prepare/prepareChange.js +45 -7
  40. package/dist/tools/prepare/prepareCreate.js +45 -5
  41. package/dist/tools/readers/enumInfo.d.ts +1 -6
  42. package/dist/tools/readers/enumInfo.js +9 -0
  43. package/dist/tools/sdlc/buildProject.d.ts +13 -0
  44. package/dist/tools/sdlc/buildProject.js +89 -7
  45. package/dist/tools/sdlc/runBpCheck.d.ts +13 -0
  46. package/dist/tools/sdlc/runBpCheck.js +82 -7
  47. package/dist/tools/smart/codeGen.js +3 -2
  48. package/dist/tools/specs/d365foFileOpSpecs.js +45 -12
  49. package/dist/tools/specs/generateObjectOpSpecs.js +3 -4
  50. package/dist/tools/toolHandler.js +54 -14
  51. package/dist/tools/write/createD365File.d.ts +15 -1
  52. package/dist/tools/write/createD365File.js +31 -25
  53. package/dist/tools/write/deleteD365File.js +8 -6
  54. package/dist/tools/write/inlineWriteVerification.d.ts +15 -0
  55. package/dist/tools/write/inlineWriteVerification.js +20 -0
  56. package/dist/tools/write/modifyD365File.d.ts +205 -0
  57. package/dist/tools/write/modifyD365File.js +199 -25
  58. package/dist/tools/write/resolveReferences.js +5 -6
  59. package/dist/tools/xml/securityPrivilegeXml.d.ts +56 -0
  60. package/dist/tools/xml/securityPrivilegeXml.js +121 -7
  61. package/dist/utils/callDedup.d.ts +31 -2
  62. package/dist/utils/callDedup.js +73 -4
  63. package/dist/utils/formExtensionControlModifications.d.ts +92 -0
  64. package/dist/utils/formExtensionControlModifications.js +289 -0
  65. package/dist/utils/objectNamingRules.d.ts +60 -0
  66. package/dist/utils/objectNamingRules.js +398 -0
  67. package/dist/utils/toolMetrics.d.ts +18 -1
  68. package/dist/utils/toolMetrics.js +23 -2
  69. package/dist/validation/formPatternValidator.js +15 -0
  70. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
1
  using System;
2
+ using System.Collections.Generic;
2
3
  using System.Diagnostics;
3
4
  using System.IO;
4
5
  using System.Reflection;
5
6
  using System.Runtime.CompilerServices;
6
7
  using System.Text.Json;
8
+ using System.Threading;
7
9
  using System.Threading.Tasks;
8
10
  using D365MetadataBridge.Protocol;
9
11
 
@@ -219,9 +221,33 @@ namespace D365MetadataBridge
219
221
  }
220
222
  }
221
223
 
224
+ /// <summary>
225
+ /// How many reads may be inside the provider at once. Bounded on purpose: the
226
+ /// win is in overlapping a handful of fan-out reads, not in handing an unbounded
227
+ /// number of threads to a metadata provider whose thread-safety is undocumented.
228
+ /// </summary>
229
+ private const int ReadSlots = 6;
230
+
231
+ /// <summary>
232
+ /// A read takes one slot; anything else takes ALL of them, which is what makes a
233
+ /// write or a provider refresh exclusive against every in-flight read. Simple
234
+ /// enough to reason about without an async reader/writer lock.
235
+ ///
236
+ /// A writer waits for the slots, so a continuous stream of reads could delay it.
237
+ /// In practice requests come from one MCP client that awaits its own calls, so
238
+ /// there is no such stream.
239
+ /// </summary>
240
+ private static readonly SemaphoreSlim _slots = new SemaphoreSlim(ReadSlots, ReadSlots);
241
+
242
+ /// <summary>stdout is one stream: concurrent handlers must not interleave lines on it.</summary>
243
+ private static readonly SemaphoreSlim _stdoutLock = new SemaphoreSlim(1, 1);
244
+ private static readonly StreamWriter _stdout =
245
+ new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true };
246
+
222
247
  private static async Task<int> RunStdioLoop(RequestDispatcher dispatcher)
223
248
  {
224
249
  var reader = new StreamReader(Console.OpenStandardInput());
250
+ var inFlight = new List<Task>();
225
251
 
226
252
  string? line;
227
253
  while ((line = await reader.ReadLineAsync()) != null)
@@ -229,45 +255,127 @@ namespace D365MetadataBridge
229
255
  if (string.IsNullOrWhiteSpace(line))
230
256
  continue;
231
257
 
232
- BridgeResponse response;
258
+ BridgeRequest? request = null;
233
259
  try
234
260
  {
235
- var request = JsonSerializer.Deserialize<BridgeRequest>(line, JsonOptions.Default);
236
- if (request == null || string.IsNullOrEmpty(request.Method))
237
- {
238
- response = BridgeResponse.CreateError("?", -32600, "Invalid request");
239
- }
240
- else
241
- {
242
- Log.WriteLine($"[DEBUG] → {request.Method} (id={request.Id})");
243
- response = await dispatcher.Dispatch(request);
244
- Log.WriteLine($"[DEBUG] ← {request.Method} OK (id={request.Id})");
245
- }
261
+ request = JsonSerializer.Deserialize<BridgeRequest>(line, JsonOptions.Default);
246
262
  }
247
263
  catch (JsonException ex)
248
264
  {
249
265
  Log.WriteLine($"[ERROR] JSON parse error: {ex.Message}");
250
- response = BridgeResponse.CreateError("?", -32700, $"Parse error: {ex.Message}");
266
+ await WriteResponse(BridgeResponse.CreateError("?", -32700, $"Parse error: {ex.Message}"));
267
+ continue;
251
268
  }
252
- catch (Exception ex)
269
+
270
+ if (request == null || string.IsNullOrEmpty(request.Method))
253
271
  {
254
- Log.WriteLine($"[ERROR] Unhandled: {ex.Message}\n{ex.StackTrace}");
255
- response = BridgeResponse.CreateError("?", -32603, $"Internal error: {ex.Message}");
272
+ await WriteResponse(BridgeResponse.CreateError("?", -32600, "Invalid request"));
273
+ continue;
256
274
  }
257
275
 
258
- await WriteResponse(response);
276
+ // Reads go to the thread pool so the loop can read the next line at once;
277
+ // everything else keeps the original behaviour of running to completion
278
+ // before the next request is even parsed.
279
+ if (RequestDispatcher.IsConcurrentSafeRead(request.Method))
280
+ {
281
+ // Touch Exception on a faulted task before dropping it, so the fault
282
+ // is observed rather than left for the finalizer — a task pruned here
283
+ // is never awaited anywhere else.
284
+ inFlight.RemoveAll(t =>
285
+ {
286
+ if (t.IsFaulted) { var _ = t.Exception; }
287
+ return t.IsCompleted;
288
+ });
289
+ inFlight.Add(HandleConcurrently(dispatcher, request));
290
+ }
291
+ else
292
+ {
293
+ // Drain the reads already running, then take every slot: a write or a
294
+ // provider rebuild must not overlap a read of the provider it replaces.
295
+ if (inFlight.Count > 0)
296
+ {
297
+ // Guarded. DispatchGuarded turns any handler escape into a
298
+ // response, so the only realistic thrower left is WriteResponse on
299
+ // a broken stdout — and an unguarded WhenAll rethrows that HERE,
300
+ // out of RunStdioLoop and out of Main, killing the bridge because
301
+ // an unrelated read could not print. The final drain below was
302
+ // already written this way; this one was not.
303
+ try { await Task.WhenAll(inFlight); }
304
+ catch { /* each read already answered, or failed on its own */ }
305
+ inFlight.Clear();
306
+ }
307
+ for (var i = 0; i < ReadSlots; i++) await _slots.WaitAsync();
308
+ try
309
+ {
310
+ await WriteResponse(await DispatchGuarded(dispatcher, request));
311
+ }
312
+ finally
313
+ {
314
+ _slots.Release(ReadSlots);
315
+ }
316
+ }
259
317
  }
260
318
 
319
+ if (inFlight.Count > 0)
320
+ {
321
+ try { await Task.WhenAll(inFlight); } catch { /* each response was already sent */ }
322
+ }
261
323
  Log.WriteLine("[INFO] stdin closed, bridge exiting");
262
324
  return 0;
263
325
  }
264
326
 
327
+ /// <summary>One read: take a slot, dispatch, answer. Never throws back into the loop.</summary>
328
+ private static async Task HandleConcurrently(RequestDispatcher dispatcher, BridgeRequest request)
329
+ {
330
+ await _slots.WaitAsync();
331
+ try
332
+ {
333
+ // Task.Run, not a bare await: every read handler returns
334
+ // Task.FromResult(...), so Dispatch runs to completion SYNCHRONOUSLY on
335
+ // whichever thread calls it. Awaiting it here left the work on the stdio
336
+ // loop thread and measured 1.06x against 1.04x for the serial build —
337
+ // the change did nothing until the work actually left this thread.
338
+ var response = await Task.Run(() => DispatchGuarded(dispatcher, request));
339
+ await WriteResponse(response);
340
+ }
341
+ finally
342
+ {
343
+ _slots.Release();
344
+ }
345
+ }
346
+
347
+ /// <summary>Dispatch, turning any escape into a response rather than a lost request.</summary>
348
+ private static async Task<BridgeResponse> DispatchGuarded(RequestDispatcher dispatcher, BridgeRequest request)
349
+ {
350
+ try
351
+ {
352
+ Log.WriteLine($"[DEBUG] → {request.Method} (id={request.Id})");
353
+ var response = await dispatcher.Dispatch(request);
354
+ Log.WriteLine($"[DEBUG] ← {request.Method} OK (id={request.Id})");
355
+ return response;
356
+ }
357
+ catch (Exception ex)
358
+ {
359
+ Log.WriteLine($"[ERROR] Unhandled: {ex.Message}\n{ex.StackTrace}");
360
+ return BridgeResponse.CreateError(request.Id, -32603, $"Internal error: {ex.Message}");
361
+ }
362
+ }
363
+
265
364
  private static async Task WriteResponse(BridgeResponse response)
266
365
  {
267
366
  var json = JsonSerializer.Serialize(response, JsonOptions.Default);
268
- var stdout = new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true };
269
- await stdout.WriteLineAsync(json);
270
- await stdout.FlushAsync();
367
+ // Serialized, and on ONE cached writer: concurrent handlers each opening their
368
+ // own StreamWriter over the same handle is how a JSON-RPC stream gets interleaved.
369
+ await _stdoutLock.WaitAsync();
370
+ try
371
+ {
372
+ await _stdout.WriteLineAsync(json);
373
+ await _stdout.FlushAsync();
374
+ }
375
+ finally
376
+ {
377
+ _stdoutLock.Release();
378
+ }
271
379
  }
272
380
 
273
381
  /// <summary>
@@ -813,6 +813,43 @@ namespace D365MetadataBridge.Protocol
813
813
  }
814
814
  }
815
815
 
816
+ /// <summary>
817
+ /// Methods that only READ metadata, and may therefore run concurrently with
818
+ /// each other.
819
+ ///
820
+ /// Measured on this VM: six reads issued together took 674 ms against 689 ms
821
+ /// issued one at a time — a 1.02x "speedup", because the stdio loop awaited each
822
+ /// Dispatch before reading the next line. Every fan-out the TS side performs
823
+ /// (get_object_info objects[], search queries[], prepare's five probes) was
824
+ /// therefore pipelining into a queue of one.
825
+ ///
826
+ /// The list is explicit and the DEFAULT IS FALSE: anything not named here —
827
+ /// including a method added later — keeps the old exclusive behaviour. A write
828
+ /// misfiled as a read would corrupt metadata; a read misfiled as a write is
829
+ /// merely as slow as it is today.
830
+ /// </summary>
831
+ private static readonly HashSet<string> ConcurrentSafeReads =
832
+ new HashSet<string>(StringComparer.OrdinalIgnoreCase)
833
+ {
834
+ "ping", "getcapabilities", "getinfo", "getxrefschema",
835
+ // Typed readers
836
+ "readtable", "readclass", "readenum", "readedt", "readform", "readquery",
837
+ "readview", "readreport", "readdataentity", "readmenuitem",
838
+ "readsecurityprivilege", "readsecurityduty", "readsecurityrole",
839
+ "readtableextensions",
840
+ // Short aliases of the same readers
841
+ "table", "class", "enum", "edt", "form", "query", "view", "menu",
842
+ // Lookup / search
843
+ "searchobjects", "listobjects", "resolveobjectinfo", "getcompletionmembers",
844
+ "getmethodsource", "validateobject", "discoverformpatterns",
845
+ // Cross-reference queries
846
+ "findreferences", "findeventsubscribers", "findextensionclasses",
847
+ "findapiusagecallers", "samplexrefrows",
848
+ };
849
+
850
+ /// <summary>True when <paramref name="method"/> may run alongside other reads.</summary>
851
+ public static bool IsConcurrentSafeRead(string? method)
852
+ => !string.IsNullOrEmpty(method) && ConcurrentSafeReads.Contains(method!);
816
853
  private Task<BridgeResponse> HandleMetadata(BridgeRequest request, Func<object?> handler)
817
854
  {
818
855
  if (_metadataService == null)
@@ -1150,68 +1150,65 @@ namespace D365MetadataBridge.Services
1150
1150
  // seen set prevents duplicate names when the same object exists in both providers
1151
1151
  var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
1152
1152
 
1153
- void Search(string objType, IList<string> keys)
1153
+ // Takes the getter, not a materialized list: as an ARGUMENT, Keys(...) ran to
1154
+ // completion before this method could return, so every collection was fully
1155
+ // enumerated even when the result budget was already full. For the default
1156
+ // "all" filter that is 14 primary-key lists per search, on both providers.
1157
+ void Search(string objType, Func<IEnumerable<string>> getter)
1154
1158
  {
1155
- if (keys == null) return;
1156
- foreach (var n in keys)
1159
+ // Budget spent — do not touch this collection at all.
1160
+ if (result.Results.Count >= maxResults) return;
1161
+ try
1157
1162
  {
1158
- if (result.Results.Count >= maxResults) return;
1159
- if (n.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 && seen.Add(n))
1160
- result.Results.Add(new SearchItemModel { Name = n, Type = objType });
1163
+ var raw = getter();
1164
+ if (raw == null) return;
1165
+ foreach (var n in raw)
1166
+ {
1167
+ // Stops ENUMERATING, not merely adding.
1168
+ if (result.Results.Count >= maxResults) return;
1169
+ if (n.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 && seen.Add(n))
1170
+ result.Results.Add(new SearchItemModel { Name = n, Type = objType });
1171
+ }
1172
+ }
1173
+ catch (Exception ex)
1174
+ {
1175
+ Console.Error.WriteLine($"[WARN] Search: could not list '{objType}': {ex.GetType().Name}: {ex.Message}");
1161
1176
  }
1162
1177
  }
1163
1178
 
1164
- // Materialize primary keys from a provider collection.
1165
- // IMPORTANT: these are accessed STRONGLY-TYPED through the public
1179
+ // Collections are accessed STRONGLY-TYPED through the public
1166
1180
  // Microsoft.Dynamics.AX.Metadata.Providers.IMetadataProvider interface — NOT via
1167
1181
  // `dynamic`. The concrete provider (DiskMetadataProvider) is an internal type, and
1168
1182
  // the DLR cannot bind to public members of an internal type from this assembly:
1169
1183
  // `dynamic dyn = prov; dyn.MenuItemDisplays` throws RuntimeBinderException
1170
1184
  // ("'object' does not contain a definition for 'MenuItemDisplays'") and silently
1171
1185
  // yields nothing — which is exactly how menu items/security stayed invisible.
1172
- // Interface access binds at compile time and works. Keys are enumerated (not cast
1173
- // to IList<string>) so a lazy IEnumerable<string> return is handled safely too.
1174
- List<string> Keys(string label, Func<IEnumerable<string>> getter)
1175
- {
1176
- var list = new List<string>();
1177
- try
1178
- {
1179
- var raw = getter();
1180
- if (raw != null)
1181
- foreach (var k in raw) list.Add(k);
1182
- }
1183
- catch (Exception ex)
1184
- {
1185
- Console.Error.WriteLine($"[WARN] Search: could not list '{label}': {ex.GetType().Name}: {ex.Message}");
1186
- }
1187
- return list;
1188
- }
1189
-
1186
+ // Interface access binds at compile time and works.
1190
1187
  void SearchProvider(IMetadataProvider prov)
1191
1188
  {
1192
1189
  try
1193
1190
  {
1194
1191
  switch (type.ToLowerInvariant())
1195
1192
  {
1196
- case "table": Search("table", Keys("table", () => prov.Tables.GetPrimaryKeys())); break;
1197
- case "class": Search("class", Keys("class", () => prov.Classes.GetPrimaryKeys())); break;
1198
- case "enum": Search("enum", Keys("enum", () => prov.Enums.GetPrimaryKeys())); break;
1199
- case "edt": Search("edt", Keys("edt", () => prov.Edts.GetPrimaryKeys())); break;
1200
- case "form": Search("form", Keys("form", () => prov.Forms.GetPrimaryKeys())); break;
1201
- case "query": Search("query", Keys("query", () => prov.Queries.GetPrimaryKeys())); break;
1202
- case "view": Search("view", Keys("view", () => prov.Views.GetPrimaryKeys())); break;
1193
+ case "table": Search("table", () => prov.Tables.GetPrimaryKeys()); break;
1194
+ case "class": Search("class", () => prov.Classes.GetPrimaryKeys()); break;
1195
+ case "enum": Search("enum", () => prov.Enums.GetPrimaryKeys()); break;
1196
+ case "edt": Search("edt", () => prov.Edts.GetPrimaryKeys()); break;
1197
+ case "form": Search("form", () => prov.Forms.GetPrimaryKeys()); break;
1198
+ case "query": Search("query", () => prov.Queries.GetPrimaryKeys()); break;
1199
+ case "view": Search("view", () => prov.Views.GetPrimaryKeys()); break;
1203
1200
  case "data-entity":
1204
- case "dataentity": Search("data-entity", Keys("data-entity", () => prov.DataEntityViews.GetPrimaryKeys())); break;
1201
+ case "dataentity": Search("data-entity", () => prov.DataEntityViews.GetPrimaryKeys()); break;
1205
1202
 
1206
- case "menu-item-display": Search("menu-item-display", Keys("menu-item-display", () => prov.MenuItemDisplays.GetPrimaryKeys())); break;
1207
- case "menu-item-action": Search("menu-item-action", Keys("menu-item-action", () => prov.MenuItemActions.GetPrimaryKeys())); break;
1208
- case "menu-item-output": Search("menu-item-output", Keys("menu-item-output", () => prov.MenuItemOutputs.GetPrimaryKeys())); break;
1203
+ case "menu-item-display": Search("menu-item-display", () => prov.MenuItemDisplays.GetPrimaryKeys()); break;
1204
+ case "menu-item-action": Search("menu-item-action", () => prov.MenuItemActions.GetPrimaryKeys()); break;
1205
+ case "menu-item-output": Search("menu-item-output", () => prov.MenuItemOutputs.GetPrimaryKeys()); break;
1209
1206
 
1210
- case "security-privilege": Search("security-privilege", Keys("security-privilege", () => prov.SecurityPrivileges.GetPrimaryKeys())); break;
1211
- case "security-duty": Search("security-duty", Keys("security-duty", () => prov.SecurityDuties.GetPrimaryKeys())); break;
1212
- case "security-role": Search("security-role", Keys("security-role", () => prov.SecurityRoles.GetPrimaryKeys())); break;
1207
+ case "security-privilege": Search("security-privilege", () => prov.SecurityPrivileges.GetPrimaryKeys()); break;
1208
+ case "security-duty": Search("security-duty", () => prov.SecurityDuties.GetPrimaryKeys()); break;
1209
+ case "security-role": Search("security-role", () => prov.SecurityRoles.GetPrimaryKeys()); break;
1213
1210
 
1214
- case "table-extension": Search("table-extension", Keys("table-extension", () => prov.TableExtensions.GetPrimaryKeys())); break;
1211
+ case "table-extension": Search("table-extension", () => prov.TableExtensions.GetPrimaryKeys()); break;
1215
1212
  // IMetadataProvider has no ClassExtensions collection — CoC/augmentation
1216
1213
  // classes live in the regular Classes collection (named *_Extension). The
1217
1214
  // bridge can't filter them out here, so return nothing and let the caller
@@ -1219,28 +1216,28 @@ namespace D365MetadataBridge.Services
1219
1216
  case "class-extension":
1220
1217
  Console.Error.WriteLine("[DEBUG] Search: class-extension requested — no IMetadataProvider collection; falling back to SQLite index");
1221
1218
  break;
1222
- case "form-extension": Search("form-extension", Keys("form-extension", () => prov.FormExtensions.GetPrimaryKeys())); break;
1223
- case "enum-extension": Search("enum-extension", Keys("enum-extension", () => prov.EnumExtensions.GetPrimaryKeys())); break;
1224
- case "edt-extension": Search("edt-extension", Keys("edt-extension", () => prov.EdtExtensions.GetPrimaryKeys())); break;
1225
- case "data-entity-extension": Search("data-entity-extension", Keys("data-entity-extension", () => prov.DataEntityViewExtensions.GetPrimaryKeys())); break;
1219
+ case "form-extension": Search("form-extension", () => prov.FormExtensions.GetPrimaryKeys()); break;
1220
+ case "enum-extension": Search("enum-extension", () => prov.EnumExtensions.GetPrimaryKeys()); break;
1221
+ case "edt-extension": Search("edt-extension", () => prov.EdtExtensions.GetPrimaryKeys()); break;
1222
+ case "data-entity-extension": Search("data-entity-extension", () => prov.DataEntityViewExtensions.GetPrimaryKeys()); break;
1226
1223
 
1227
1224
  default:
1228
1225
  // "all" (or any unrecognized filter): enumerate every object kind so
1229
1226
  // nothing — menu items included — is silently invisible to search.
1230
- Search("table", Keys("table", () => prov.Tables.GetPrimaryKeys()));
1231
- Search("class", Keys("class", () => prov.Classes.GetPrimaryKeys()));
1232
- Search("enum", Keys("enum", () => prov.Enums.GetPrimaryKeys()));
1233
- Search("edt", Keys("edt", () => prov.Edts.GetPrimaryKeys()));
1234
- Search("form", Keys("form", () => prov.Forms.GetPrimaryKeys()));
1235
- Search("query", Keys("query", () => prov.Queries.GetPrimaryKeys()));
1236
- Search("view", Keys("view", () => prov.Views.GetPrimaryKeys()));
1237
- Search("data-entity", Keys("data-entity", () => prov.DataEntityViews.GetPrimaryKeys()));
1238
- Search("menu-item-display", Keys("menu-item-display", () => prov.MenuItemDisplays.GetPrimaryKeys()));
1239
- Search("menu-item-action", Keys("menu-item-action", () => prov.MenuItemActions.GetPrimaryKeys()));
1240
- Search("menu-item-output", Keys("menu-item-output", () => prov.MenuItemOutputs.GetPrimaryKeys()));
1241
- Search("security-privilege", Keys("security-privilege", () => prov.SecurityPrivileges.GetPrimaryKeys()));
1242
- Search("security-duty", Keys("security-duty", () => prov.SecurityDuties.GetPrimaryKeys()));
1243
- Search("security-role", Keys("security-role", () => prov.SecurityRoles.GetPrimaryKeys()));
1227
+ Search("table", () => prov.Tables.GetPrimaryKeys());
1228
+ Search("class", () => prov.Classes.GetPrimaryKeys());
1229
+ Search("enum", () => prov.Enums.GetPrimaryKeys());
1230
+ Search("edt", () => prov.Edts.GetPrimaryKeys());
1231
+ Search("form", () => prov.Forms.GetPrimaryKeys());
1232
+ Search("query", () => prov.Queries.GetPrimaryKeys());
1233
+ Search("view", () => prov.Views.GetPrimaryKeys());
1234
+ Search("data-entity", () => prov.DataEntityViews.GetPrimaryKeys());
1235
+ Search("menu-item-display", () => prov.MenuItemDisplays.GetPrimaryKeys());
1236
+ Search("menu-item-action", () => prov.MenuItemActions.GetPrimaryKeys());
1237
+ Search("menu-item-output", () => prov.MenuItemOutputs.GetPrimaryKeys());
1238
+ Search("security-privilege", () => prov.SecurityPrivileges.GetPrimaryKeys());
1239
+ Search("security-duty", () => prov.SecurityDuties.GetPrimaryKeys());
1240
+ Search("security-role", () => prov.SecurityRoles.GetPrimaryKeys());
1244
1241
  break;
1245
1242
  }
1246
1243
  }
@@ -1144,7 +1144,12 @@ const BRIDGE_MODIFY_OPS = new Set([
1144
1144
  // (MetadataWriteService exposes no RemoveControl, and security objects have no
1145
1145
  // bridge write path at all) — both are served by a direct-XML writer and still
1146
1146
  // pass through this gate.
1147
- 'remove-control', 'remove-entry-point',
1147
+ // add-entry-point shipped its schema entry, op-spec, dispatcher case and writer
1148
+ // without ever being listed here, so canBridgeModify() short-circuited and the
1149
+ // whole feature was unreachable from the tool surface — dead code behind a ✅
1150
+ // schema. Both this set AND XML_ONLY_MODIFY_PAIRS have to name it; either alone
1151
+ // still returns false. (eval case L2-object-delete-and-entry-point-cleanup)
1152
+ 'remove-control', 'add-entry-point', 'remove-entry-point',
1148
1153
  // AxIgnoreDiagnosticList is not an AOT object at all — MetadataWriteService has
1149
1154
  // no concept of it — so this is XML-only for the same structural reason as the
1150
1155
  // two above.
@@ -1183,6 +1188,7 @@ const BRIDGE_MODIFY_TYPES = new Set([
1183
1188
  * a bridge resolution failure instead of "not supported for this object type".
1184
1189
  */
1185
1190
  const XML_ONLY_MODIFY_PAIRS = {
1191
+ 'add-entry-point': new Set(['security-privilege']),
1186
1192
  'remove-entry-point': new Set(['security-privilege']),
1187
1193
  'remove-diagnostic-suppression': new Set(['ignore-diagnostic-list']),
1188
1194
  'add-diagnostic-suppression': new Set(['ignore-diagnostic-list']),
@@ -26,7 +26,12 @@ const MAX_RELATIVE_RMSE = 0.02;
26
26
  * covers the tool's primary use — `labels` has a bulk create but its searches
27
27
  * (the common case) have no batch form, and flagging those would be noise.
28
28
  */
29
- const PLURAL_FORM_TOOLS = ['get_object_info', 'run_bp_check', 'verify_d365fo_project'];
29
+ const PLURAL_FORM_TOOLS = [
30
+ 'get_object_info', 'run_bp_check', 'verify_d365fo_project',
31
+ 'd365fo_file', // operations[] applies many edits to one object in a call
32
+ 'get_knowledge', // topics[] answers several lookups in a call
33
+ 'search', // queries[] runs several searches in one call, in parallel
34
+ ];
30
35
  /**
31
36
  * Least squares with no intercept, three unknowns, via the normal equations.
32
37
  *
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Kernel enums — enums the X++ runtime defines, with NO AOT metadata element.
3
+ *
4
+ * These are referenced by ordinary metadata (`<EnumType>NoYes</EnumType>` appears
5
+ * 48 times in CustTable.xml alone, and EDT NoYesId reports `Enum Type: NoYes`)
6
+ * but they have no `AxEnum/*.xml` anywhere, so no metadata-backed lookup can
7
+ * ever prove them: not the C# bridge (`IMetadataProvider.Enums` does not carry
8
+ * them), not the SQLite symbol index (it indexes AOT XML), not a disk probe.
9
+ *
10
+ * Treating "absent from the AOT" as "does not exist" produced two failures, both
11
+ * confirmed live on 2026-08-24:
12
+ *
13
+ * 1. `validate_code(mode="references", codeType="xml-table")` reported
14
+ * `<EnumType>NoYes</EnumType>` as a hard ERROR — "Enum NoYes not found in
15
+ * the symbol index" — under "Fix errors before writing — these will cause
16
+ * compiler failures". An agent obeying that edits correct metadata, and
17
+ * `search` offers it NoYesBlank / DefaultNoYes / NoYesCombo to "correct" to.
18
+ * Those enums are real, so the result compiles clean and means the wrong
19
+ * thing.
20
+ * 2. `get_object_info(objectType="enum", name="NoYes")` answered "not found via
21
+ * bridge, symbol index, or on disk" and advised searching other spellings,
22
+ * running update_symbol_index on `NoYes.xml`, and checking
23
+ * D365FO_CUSTOM_PACKAGES_PATH. None of that can ever succeed: there is no
24
+ * file to index. Meanwhile the EDT reader hands the agent the name `NoYes`
25
+ * in the first place — a loop with no exit.
26
+ *
27
+ * The X++ reference resolver already knew this (resolveReferences.ts allow-listed
28
+ * 'noyes' and 'exception' for exactly this reason), which is why X++ using
29
+ * `NoYes::Yes` validated cleanly while the XML path hard-errored on the same
30
+ * enum. This module is that knowledge in one place, so the next path to need it
31
+ * does not have to rediscover it.
32
+ *
33
+ * MEMBERSHIP IS EVIDENCE-BASED, not remembered: every name below was checked
34
+ * against the complete set of 8,220 `AxEnum/*.xml` basenames in
35
+ * K:\AosService\PackagesLocalDirectory (metamodel 7.0.7996.33) and is absent
36
+ * from all of them.
37
+ */
38
+ /** A kernel enum, and the value names it is used with. */
39
+ export interface KernelEnum {
40
+ /** Canonical casing, for rendering. */
41
+ name: string;
42
+ /**
43
+ * Value names as OBSERVED in shipped X++/metadata under
44
+ * PackagesLocalDirectory. Deliberately not claimed to be exhaustive — it is
45
+ * what the product itself uses. Omitted where a scan found no usage, rather
46
+ * than filled in from memory.
47
+ */
48
+ values?: string[];
49
+ }
50
+ /** Lowercase names, for the allow-lists that already work in lowercase. */
51
+ export declare const KERNEL_ENUM_NAMES: ReadonlySet<string>;
52
+ /** Is `name` an enum the runtime defines rather than the AOT? Case-insensitive. */
53
+ export declare function isKernelEnum(name: string | null | undefined): boolean;
54
+ /** The entry for `name`, or undefined when it is not a kernel enum. */
55
+ export declare function getKernelEnum(name: string | null | undefined): KernelEnum | undefined;
56
+ /**
57
+ * The answer a reader should give instead of "not found".
58
+ *
59
+ * States the one thing the caller has to know — there is nothing to index, and
60
+ * references to it are valid — so the reply cannot be read as "this name is
61
+ * wrong, go find the right one".
62
+ */
63
+ export declare function describeKernelEnum(name: string): string | null;
64
+ //# sourceMappingURL=kernelEnums.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Kernel enums — enums the X++ runtime defines, with NO AOT metadata element.
3
+ *
4
+ * These are referenced by ordinary metadata (`<EnumType>NoYes</EnumType>` appears
5
+ * 48 times in CustTable.xml alone, and EDT NoYesId reports `Enum Type: NoYes`)
6
+ * but they have no `AxEnum/*.xml` anywhere, so no metadata-backed lookup can
7
+ * ever prove them: not the C# bridge (`IMetadataProvider.Enums` does not carry
8
+ * them), not the SQLite symbol index (it indexes AOT XML), not a disk probe.
9
+ *
10
+ * Treating "absent from the AOT" as "does not exist" produced two failures, both
11
+ * confirmed live on 2026-08-24:
12
+ *
13
+ * 1. `validate_code(mode="references", codeType="xml-table")` reported
14
+ * `<EnumType>NoYes</EnumType>` as a hard ERROR — "Enum NoYes not found in
15
+ * the symbol index" — under "Fix errors before writing — these will cause
16
+ * compiler failures". An agent obeying that edits correct metadata, and
17
+ * `search` offers it NoYesBlank / DefaultNoYes / NoYesCombo to "correct" to.
18
+ * Those enums are real, so the result compiles clean and means the wrong
19
+ * thing.
20
+ * 2. `get_object_info(objectType="enum", name="NoYes")` answered "not found via
21
+ * bridge, symbol index, or on disk" and advised searching other spellings,
22
+ * running update_symbol_index on `NoYes.xml`, and checking
23
+ * D365FO_CUSTOM_PACKAGES_PATH. None of that can ever succeed: there is no
24
+ * file to index. Meanwhile the EDT reader hands the agent the name `NoYes`
25
+ * in the first place — a loop with no exit.
26
+ *
27
+ * The X++ reference resolver already knew this (resolveReferences.ts allow-listed
28
+ * 'noyes' and 'exception' for exactly this reason), which is why X++ using
29
+ * `NoYes::Yes` validated cleanly while the XML path hard-errored on the same
30
+ * enum. This module is that knowledge in one place, so the next path to need it
31
+ * does not have to rediscover it.
32
+ *
33
+ * MEMBERSHIP IS EVIDENCE-BASED, not remembered: every name below was checked
34
+ * against the complete set of 8,220 `AxEnum/*.xml` basenames in
35
+ * K:\AosService\PackagesLocalDirectory (metamodel 7.0.7996.33) and is absent
36
+ * from all of them.
37
+ */
38
+ const ENTRIES = [
39
+ // By far the most common: boolean-ish fields reach it through EDT NoYesId.
40
+ { name: 'NoYes', values: ['No', 'Yes'] },
41
+ { name: 'Exception', values: [
42
+ 'Break', 'CLRError', 'CodeAccessSecurity', 'DDEerror', 'Deadlock',
43
+ 'DuplicateKeyException', 'DuplicateKeyExceptionNotRecovered', 'Error',
44
+ 'FunctionArgument', 'Info', 'Internal', 'Sequence', 'Timeout',
45
+ 'TransientSqlConnectionError', 'UpdateConflict',
46
+ 'UpdateConflictNotRecovered', 'ViewDataSourceValidation', 'Warning',
47
+ ] },
48
+ { name: 'Types', values: [
49
+ 'AnyType', 'Blob', 'Class', 'Container', 'Date', 'Enum', 'Guid', 'Int64',
50
+ 'Integer', 'List', 'RString', 'Real', 'Record', 'String', 'Time',
51
+ 'UserType', 'UtcDateTime', 'VarArg', 'VarString', 'Void',
52
+ ] },
53
+ { name: 'TableScope', values: ['CurrentTableOnly', 'IncludeBaseTables', 'IncludeDerivedTables'] },
54
+ { name: 'ConcurrencyModel', values: ['Auto', 'Optimistic', 'Pessimistic'] },
55
+ { name: 'StatementType', values: ['Delete', 'Insert', 'Select', 'Update'] },
56
+ // Real kernel enums with no usage in the packages scanned — listed without
57
+ // values rather than with guessed ones.
58
+ { name: 'IsolationLevel' },
59
+ { name: 'UtcDateTimeOrder' },
60
+ { name: 'DateOrder' },
61
+ { name: 'DateDay' },
62
+ { name: 'DateMonth' },
63
+ { name: 'DateYear' },
64
+ ];
65
+ const BY_LOWER = new Map(ENTRIES.map(e => [e.name.toLowerCase(), e]));
66
+ /** Lowercase names, for the allow-lists that already work in lowercase. */
67
+ export const KERNEL_ENUM_NAMES = new Set(BY_LOWER.keys());
68
+ /** Is `name` an enum the runtime defines rather than the AOT? Case-insensitive. */
69
+ export function isKernelEnum(name) {
70
+ return !!name && BY_LOWER.has(name.trim().toLowerCase());
71
+ }
72
+ /** The entry for `name`, or undefined when it is not a kernel enum. */
73
+ export function getKernelEnum(name) {
74
+ return name ? BY_LOWER.get(name.trim().toLowerCase()) : undefined;
75
+ }
76
+ /**
77
+ * The answer a reader should give instead of "not found".
78
+ *
79
+ * States the one thing the caller has to know — there is nothing to index, and
80
+ * references to it are valid — so the reply cannot be read as "this name is
81
+ * wrong, go find the right one".
82
+ */
83
+ export function describeKernelEnum(name) {
84
+ const entry = getKernelEnum(name);
85
+ if (!entry)
86
+ return null;
87
+ const lines = [
88
+ `# Enum: ${entry.name} (kernel enum)`,
89
+ '',
90
+ `\`${entry.name}\` is defined by the X++ runtime, not by an AOT element. There is no ` +
91
+ `\`AxEnum/${entry.name}.xml\` in any package, so the bridge, the symbol index and a disk ` +
92
+ `probe all correctly fail to find one — and there is nothing to index.`,
93
+ '',
94
+ `**It is valid to reference.** \`<EnumType>${entry.name}</EnumType>\` in metadata and ` +
95
+ `\`${entry.name}::Value\` in X++ both compile. Do NOT substitute a similarly named AOT ` +
96
+ `enum (for ${entry.name === 'NoYes' ? '`NoYes`, search offers `NoYesBlank`, `NoYesCombo`, `DefaultNoYes`' : 'example, a prefixed variant'}) — those are different types.`,
97
+ ];
98
+ if (entry.values?.length) {
99
+ lines.push('', '## Values', '', entry.values.map(v => `- \`${entry.name}::${v}\``).join('\n'), '', '_Value names as used by shipped X++ under PackagesLocalDirectory; the kernel is the ' +
100
+ 'authority, so treat the list as observed rather than exhaustive._');
101
+ }
102
+ return lines.join('\n');
103
+ }
104
+ //# sourceMappingURL=kernelEnums.js.map
@@ -25,7 +25,7 @@ export declare const buildD365foProjectTool: {
25
25
  type: string;
26
26
  description: string;
27
27
  };
28
- buildReferencedModels: {
28
+ bpCheck: {
29
29
  type: string;
30
30
  description: string;
31
31
  };
@@ -27,9 +27,9 @@ export const buildD365foProjectTool = {
27
27
  type: 'boolean',
28
28
  description: 'Full recompile of the TARGET model only (deps stay incremental). Use when xppc reports stale symbol errors.',
29
29
  },
30
- buildReferencedModels: {
30
+ bpCheck: {
31
31
  type: 'boolean',
32
- description: 'DISABLED — always ignored. Rebuilding dependency models on every build slows the run down and referenced models are expected to already be compiled.',
32
+ description: 'On a SUCCESSFUL build, also run the best-practice checker and append its findings — saves the usual follow-up run_bp_check call.',
33
33
  },
34
34
  wait: {
35
35
  type: 'boolean',