d365fo-mcp 1.17.2 → 1.17.4

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 (43) hide show
  1. package/bridge/D365MetadataBridge/Models/Models.cs +13 -0
  2. package/bridge/D365MetadataBridge/Services/CrossReferenceService.cs +331 -69
  3. package/dist/bridge/bridgeAdapter.js +70 -11
  4. package/dist/bridge/bridgeTypes.d.ts +7 -0
  5. package/dist/cli/commands/doctor.js +10 -8
  6. package/dist/index.js +55 -1
  7. package/dist/metadata/buildIndexWorker.d.ts +1 -1
  8. package/dist/metadata/buildIndexWorker.js +1 -1
  9. package/dist/metadata/startupIndexWorker.d.ts +11 -1
  10. package/dist/metadata/startupIndexWorker.js +10 -2
  11. package/dist/metadata/startupIndexing.d.ts +3 -0
  12. package/dist/metadata/startupIndexing.js +7 -1
  13. package/dist/metadata/symbolIndex.d.ts +120 -56
  14. package/dist/metadata/symbolIndex.js +179 -84
  15. package/dist/scripts/build-database.js +123 -89
  16. package/dist/scripts/build-fts.js +123 -89
  17. package/dist/scripts/extract-metadata.js +29 -10
  18. package/dist/scripts/startupIndexWorker.js +96 -79
  19. package/dist/server/serverMode.d.ts +23 -0
  20. package/dist/server/serverMode.js +26 -0
  21. package/dist/server/toolSchemas/findReferences.js +1 -1
  22. package/dist/tools/analysis/findReferences.js +92 -2
  23. package/dist/tools/analysis/searchLabels.js +1 -1
  24. package/dist/tools/prepare/prepareCreate.js +8 -5
  25. package/dist/tools/readers/getObjectInfo.js +5 -0
  26. package/dist/tools/readers/objectXml.d.ts +17 -2
  27. package/dist/tools/readers/objectXml.js +35 -9
  28. package/dist/tools/toolHandler.js +20 -9
  29. package/dist/tools/write/resolveReferences.js +1 -1
  30. package/dist/utils/configManager.js +7 -1
  31. package/dist/utils/indexStaleness.d.ts +11 -3
  32. package/dist/utils/indexStaleness.js +41 -7
  33. package/dist/utils/objectFileLookup.d.ts +44 -0
  34. package/dist/utils/objectFileLookup.js +86 -0
  35. package/dist/utils/objectNamingRules.js +1 -1
  36. package/dist/utils/packagesRoot.d.ts +41 -1
  37. package/dist/utils/packagesRoot.js +152 -16
  38. package/dist/utils/progressReporter.d.ts +25 -0
  39. package/dist/utils/progressReporter.js +34 -0
  40. package/dist/utils/startupProgress.d.ts +57 -0
  41. package/dist/utils/startupProgress.js +102 -0
  42. package/dist/workspace/contextSnapshot.js +8 -1
  43. package/package.json +1 -1
@@ -696,6 +696,19 @@ namespace D365MetadataBridge.Models
696
696
  [JsonPropertyName("module")]
697
697
  public string? Module { get; set; }
698
698
 
699
+ /// <summary>
700
+ /// The AOT path this class actually extends, read from its [ExtensionOf] declaration —
701
+ /// e.g. "/Tables/SalesTable", "/Forms/SalesTable/DataSources/SalesLine",
702
+ /// "/Forms/VendOpenTrans/DataSources/TaxWithholdTrans/DataFields/TaxReimbursement_IT".
703
+ ///
704
+ /// Without this the caller cannot tell a table CoC from a form CoC when both objects
705
+ /// share a name: asking about "SalesTable" legitimately matches 14 extensions of the
706
+ /// TABLE and 34 of the FORM and the elements below it, which must never be pooled into
707
+ /// one count.
708
+ /// </summary>
709
+ [JsonPropertyName("extendedElement")]
710
+ public string ExtendedElement { get; set; } = "";
711
+
699
712
  /// <summary>Methods that the extension class wraps via CoC (next calls)</summary>
700
713
  [JsonPropertyName("wrappedMethods")]
701
714
  public List<string> WrappedMethods { get; set; } = new List<string>();
@@ -73,26 +73,79 @@ namespace D365MetadataBridge.Services
73
73
  return (objectType, objectName, segment, segmentName);
74
74
  }
75
75
 
76
+ // DYNAMICSXREFDB [References].Kind values.
77
+ //
78
+ // These were DECODED EMPIRICALLY (2026-09-09) by sampling source/target path pairs per
79
+ // value across three live xref databases spanning two platform versions (10.0.2645.99,
80
+ // 10.0.2645.111, 10.0.2428.205). The mapping was identical in all three.
81
+ //
82
+ // The previous comment here claimed "1=Read/Reference, 2=DerivedFrom/Extends". Both
83
+ // halves were wrong, and the second one was costly: Kind 2 is the GENERIC type
84
+ // reference — 18.3M of the 28M rows — so every `EcoResDescription desc;` declaration and
85
+ // every `NoYes::Yes` read was being reported as "extends". A where-used on /Enums/NoYes
86
+ // came back 500-for-500 "extends", which nothing can extend. Extends is Kind 4.
87
+ private const byte KindCall = 1; // method call: Foo/Methods/a -> Bar/Methods/b
88
+ private const byte KindTypeRef = 2; // names a type: ... -> /Edts/Counter
89
+ private const byte KindImplements = 3; // class -> interface: AbsNettingMarkTransMgr -> INettingMarkTrans
90
+ private const byte KindExtends = 4; // class -> base: _Performance -> RunBaseBatch
91
+ private const byte KindDelegate = 6; // ... -> /Property/IsDelegate
92
+ private const byte KindAttribute = 7; // ... -> /Classes/HookableAttribute
93
+ private const byte KindTag = 9; // ... -> /Tags/<tag>
94
+ private const byte KindOverride = 10; // super(): Foo/Methods/pack -> Base/Methods/pack
95
+
76
96
  /// <summary>
77
- /// Categorize a reference based on the xref Kind value and path context.
78
- /// Kind: 1=Read/Reference, 2=DerivedFrom/Extends, 3+ = other
97
+ /// Escape the LIKE metacharacters in a literal that is being CONCATENATED into a LIKE
98
+ /// pattern. Parameterising the pattern stops SQL injection but not this: inside a LIKE,
99
+ /// "_" still means "any single character".
100
+ ///
101
+ /// That matters here because the D365 CoC naming convention is "&lt;Base&gt;_Extension",
102
+ /// so essentially every class name this service handles contains an underscore, and an
103
+ /// unescaped "/Classes/Foo_Extension/Methods/%" also matches "/Classes/FooXExtension/…".
104
+ /// A census of this xref DB found no name pair that actually collides that way, so this
105
+ /// is closing a latent hole rather than a live miscount — but the patterns are built from
106
+ /// class names and element paths, and nothing stops the next model from shipping the pair.
107
+ ///
108
+ /// "[" must be replaced FIRST, or it would go on to escape the brackets introduced by the
109
+ /// other two replacements.
110
+ /// </summary>
111
+ private static string EscapeLike(string literal) =>
112
+ literal.Replace("[", "[[]").Replace("%", "[%]").Replace("_", "[_]");
113
+
114
+ /// <summary>
115
+ /// Last slash-separated segment of an xref path — the member name for a
116
+ /// "/Container/Owner/Methods/name" path. Null when the path has no segments.
117
+ /// </summary>
118
+ private static string? LeafSegment(string path)
119
+ {
120
+ var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
121
+ return parts.Length > 0 ? parts[parts.Length - 1] : null;
122
+ }
123
+
124
+ /// <summary>
125
+ /// Categorize a reference from its xref Kind, refining the generic type-reference
126
+ /// kind by what the target actually is. See the Kind constants above for how the
127
+ /// values were established.
79
128
  /// </summary>
80
129
  private static string CategorizeReference(byte? kind, string sourcePath, string targetPath)
81
130
  {
82
- if (kind == 2) return "extends";
131
+ switch (kind)
132
+ {
133
+ case KindCall: return "call";
134
+ case KindImplements: return "implements";
135
+ case KindExtends: return "extends";
136
+ case KindDelegate: return "delegate-declaration";
137
+ case KindAttribute: return "attribute";
138
+ case KindTag: return "tag";
139
+ case KindOverride: return "override";
140
+ }
83
141
 
84
- // Check if source is referencing a field
142
+ // Kind 2 (and anything unrecognised): the source line names the target. Say what
143
+ // kind of naming it is from the target's shape.
85
144
  if (targetPath.Contains("/Fields/")) return "field-access";
86
145
 
87
- // Check if source path suggests instantiation (heuristic: method referencing a class, not a method)
88
146
  var (_, _, targetSeg, _) = ParsePath(targetPath);
89
- if (targetSeg == null || targetSeg == "")
90
- {
91
- // Target is a class/table itself (not a method/field) — could be type-reference or instantiation
92
- return "type-reference";
93
- }
94
-
95
- if (targetSeg == "Methods") return "call";
147
+ if (targetSeg == "Methods") return "method-reference";
148
+ if (string.IsNullOrEmpty(targetSeg)) return "type-reference";
96
149
 
97
150
  return "reference";
98
151
  }
@@ -140,14 +193,28 @@ namespace D365MetadataBridge.Services
140
193
  }
141
194
  else
142
195
  {
143
- // Try common AOT path prefixes
144
- pathVariants.Add($"/Tables/{objectPath}");
145
- pathVariants.Add($"/Classes/{objectPath}");
146
- pathVariants.Add($"/Enums/{objectPath}");
147
- pathVariants.Add($"/Views/{objectPath}");
148
- pathVariants.Add($"/DataEntityViews/{objectPath}");
149
- pathVariants.Add($"/Queries/{objectPath}");
150
- pathVariants.Add($"/Forms/{objectPath}");
196
+ // Bare name — we do not know which AOT type it is, so try every container
197
+ // that can be the TARGET of a reference. All of these were verified against a
198
+ // live DYNAMICSXREFDB: the target convention is plural + leading slash, even
199
+ // though SOURCE paths for declarative metadata use the singular, slash-free
200
+ // form ("EdtString/Foo?HelpText") that parseLabelSource() on the TS side
201
+ // handles. In particular an EDT is "/Edts/<name>" — NOT "/EdtString/<name>":
202
+ // the concrete subtype appears only in source paths.
203
+ //
204
+ // Edts/Maps/Reports/MenuItem* were missing here, so a where-used on any of
205
+ // them returned zero rows and the TS caller silently degraded to its
206
+ // name-based index scan. For an EDT that is close to useless, because an
207
+ // EDT's real usages are table fields and form control properties — metadata,
208
+ // not X++ text.
209
+ foreach (var c in new[]
210
+ {
211
+ "Tables", "Classes", "Enums", "Views", "DataEntityViews", "Queries", "Forms",
212
+ "Edts", "Maps", "Reports",
213
+ "MenuItemDisplays", "MenuItemActions", "MenuItemOutputs",
214
+ })
215
+ {
216
+ pathVariants.Add($"/{c}/{objectPath}");
217
+ }
151
218
  }
152
219
 
153
220
  // Also add sub-paths (methods, fields) so we catch method-level references.
@@ -158,7 +225,10 @@ namespace D365MetadataBridge.Services
158
225
  {
159
226
  foreach (var p in pathVariants)
160
227
  {
161
- extraPaths.Add(p + "/%"); // LIKE pattern for children
228
+ // LIKE pattern for children. Only this derived copy is escaped — the
229
+ // variants themselves go into an IN (...) equality list, where "_" is
230
+ // an ordinary character and escaping it would stop them matching.
231
+ extraPaths.Add(EscapeLike(p) + "/%");
162
232
  }
163
233
  }
164
234
 
@@ -247,13 +317,97 @@ namespace D365MetadataBridge.Services
247
317
  // ============================================================
248
318
 
249
319
  /// <summary>
250
- /// Find classes that extend (CoC) a given base class. Enriched: returns
251
- /// which specific methods each extension class wraps via CoC, by querying
252
- /// the Names table for method-level paths under each extension class.
320
+ /// The attribute a Chain of Command class carries. Stored under this exact path — it is
321
+ /// "ExtensionOf", NOT "ExtensionOfAttribute" like most other attribute names in Names.
322
+ /// </summary>
323
+ private const string ExtensionOfAttributePath = "/Classes/ExtensionOf";
324
+
325
+ /// <summary>
326
+ /// Containers a CoC class extension can be based on. [ExtensionOf] accepts
327
+ /// classStr/tableStr/formStr/viewStr/mapStr/dataEntityViewStr/queryStr, so the base is
328
+ /// by no means always a class — the caller passes a bare name and we resolve which.
329
+ /// </summary>
330
+ private static readonly string[] CocBaseContainers =
331
+ { "Classes", "Tables", "Forms", "Views", "Maps", "DataEntityViews", "Queries" };
332
+
333
+ /// <summary>
334
+ /// The extended artifact from one [ExtensionOf] declaration: the terminal element of the
335
+ /// prefix chain the intrinsic's arguments produce.
336
+ ///
337
+ /// Two details that are load-bearing:
338
+ /// * Comparison is case-INSENSITIVE. The xref stores the same element under inconsistent
339
+ /// casing — "/Forms/PurchTable/DataSources/purchLine" alongside
340
+ /// "/Forms/PurchTable/DataSources/PurchLine/DataFields/PriceUnit" — so an ordinal
341
+ /// StartsWith fails to see the chain and returns the ANCESTOR. That was 3 wrong answers
342
+ /// in 4,696 before this was fixed. The database collation is CI_AS, so SQL already
343
+ /// matches this way and the two layers must agree.
344
+ /// * Targets are bounded to the ExtensionOf attribute's own argument window. 52 declarations
345
+ /// share their line with a second attribute, and that attribute's arguments are references
346
+ /// on the same line. On the corpus measured, none of them changed an answer (the
347
+ /// co-attribute either takes no metadata argument or names the same element), so this is
348
+ /// insurance rather than a live fix — but it is what makes those 52 safe by design.
349
+ ///
350
+ /// Returns null when the declaration produced no usable target.
351
+ /// </summary>
352
+ private static string? ResolveExtendedElement(List<(string target, int kind, int col)> refs, int extCol)
353
+ {
354
+ // The next attribute to the right closes ExtensionOf's argument window.
355
+ var limit = int.MaxValue;
356
+ foreach (var (target, kind, col) in refs)
357
+ if (kind == KindAttribute && target != ExtensionOfAttributePath && col > extCol && col < limit)
358
+ limit = col;
359
+
360
+ var scoped = new List<string>();
361
+ foreach (var (target, kind, col) in refs)
362
+ if (kind == KindTypeRef && col > extCol && col < limit && !scoped.Contains(target))
363
+ scoped.Add(target);
364
+
365
+ if (scoped.Count == 0) return null;
366
+
367
+ // Maximum of the prefix order: the one nothing else extends.
368
+ foreach (var candidate in scoped)
369
+ {
370
+ var isPrefixOfAnother = false;
371
+ foreach (var other in scoped)
372
+ {
373
+ if (ReferenceEquals(other, candidate)) continue;
374
+ if (other.StartsWith(candidate + "/", StringComparison.OrdinalIgnoreCase)) { isPrefixOfAnother = true; break; }
375
+ }
376
+ if (!isPrefixOfAnother) return candidate;
377
+ }
378
+
379
+ // Not a chain — shouldn't happen (zero cases in 4,696), so prefer the deepest rather
380
+ // than silently returning an ancestor.
381
+ scoped.Sort((a, b) => b.Length.CompareTo(a.Length));
382
+ return scoped[0];
383
+ }
384
+
385
+ /// <summary>
386
+ /// True when <paramref name="element"/> IS the requested object, or is nested inside it
387
+ /// (a form's data source, control or data field). Nested hits are kept deliberately: an
388
+ /// agent about to wrap SalesLine.active on a form needs to see it is already wrapped. The
389
+ /// caller groups them by element so they are never pooled with the parent's own count.
390
+ /// </summary>
391
+ private static bool IsRequestedOrNested(string element, string baseName)
392
+ {
393
+ foreach (var container in CocBaseContainers)
394
+ {
395
+ var basePath = $"/{container}/{baseName}";
396
+ if (string.Equals(element, basePath, StringComparison.OrdinalIgnoreCase)) return true;
397
+ if (element.StartsWith(basePath + "/", StringComparison.OrdinalIgnoreCase)) return true;
398
+ }
399
+ return false;
400
+ }
401
+
402
+ /// <summary>
403
+ /// Find the Chain of Command extension classes of a given base object, together with
404
+ /// the base methods each one wraps. The base may be a class, table, form, view, map,
405
+ /// data entity or query — [ExtensionOf] accepts all of them.
253
406
  /// </summary>
254
407
  public object FindExtensionClasses(string baseClassName)
255
408
  {
256
- var extensionClassNames = new Dictionary<string, string?>(); // className → module
409
+ // className → (module, the element the class actually extends)
410
+ var extensionClassNames = new Dictionary<string, (string? module, string element)>();
257
411
 
258
412
  try
259
413
  {
@@ -261,29 +415,87 @@ namespace D365MetadataBridge.Services
261
415
  {
262
416
  conn.Open();
263
417
 
264
- // Step 1: Find extension classes via xref (Kind=2 DerivedFrom + naming convention)
265
- var sql = @"
266
- SELECT DISTINCT src.Path, m.Module
267
- FROM [References] r
268
- JOIN [Names] src ON r.SourceId = src.Id
269
- JOIN [Names] tgt ON r.TargetId = tgt.Id
270
- LEFT JOIN [Modules] m ON src.ModuleId = m.Id
271
- WHERE (
272
- tgt.Path LIKE @TargetClass
273
- OR tgt.Path LIKE @TargetClassMethod
274
- )
275
- AND (
276
- r.Kind = 2
277
- OR src.Path LIKE @ExtensionPattern
418
+ // Step 1 — identify genuine [ExtensionOf] classes and READ what each extends.
419
+ //
420
+ // History: this accepted `r.Kind = 2 OR src.Path LIKE '%_Extension%'`, believing
421
+ // Kind 2 meant DerivedFrom. It does not (see the Kind constants above) — it is
422
+ // the generic type reference, so every class that merely MENTIONED the base was
423
+ // reported as extending it. On SalesFormLetter: 259 reported against 9 real.
424
+ //
425
+ // How the extended element is recovered. `ExtensionOf` takes ONE string
426
+ // (`public void new(str name)`); the multiplicity comes from the INTRINSIC that
427
+ // produces it. While resolving e.g. formDataFieldStr(Form, DataSource, Field)
428
+ // the compiler emits one Kind 2 reference per metadata level it names, and those
429
+ // levels are nested by construction:
430
+ // /Forms/VendOpenTrans
431
+ // /Forms/VendOpenTrans/DataSources/TaxWithholdTrans
432
+ // /Forms/VendOpenTrans/DataSources/TaxWithholdTrans/DataFields/TaxReimbursement_IT
433
+ // So the targets form a PREFIX CHAIN and the extended artifact is its terminal
434
+ // element — the one that is not a proper prefix of any other. That is a maximum
435
+ // of a partial order, not a "longest string" guess, and it was checked rather
436
+ // than assumed: across all 4,696 ExtensionOf classes in a live xref DB the set
437
+ // is a single chain every time (zero ambiguous). Measured element shapes:
438
+ // /Classes/* 2633, /Tables/* 828, /Forms/* 696, /Forms/*/DataSources/* 241,
439
+ // /DataEntityViews/* 170, /Forms/*/Controls/* 77,
440
+ // /Forms/*/DataSources/*/DataFields/* 41, /Maps/* 6, /Views/* 4.
441
+ //
442
+ // Reading the element (rather than matching any declaration-level reference)
443
+ // is what keeps a table CoC apart from a form CoC of the same name, and it drops
444
+ // classes that name the base for some OTHER reason — e.g.
445
+ // SalesCopyingTAMDeduction_Extension extends /Classes/SalesCopying but also
446
+ // references /Tables/SalesTable from a different attribute on another line.
447
+ //
448
+ // Candidate classes are those naming the requested object anywhere in their
449
+ // ExtensionOf declaration; the element test below then decides. `LIKE base + '/%'`
450
+ // admits nested elements (a form's data sources, controls and data fields), which
451
+ // the caller groups and labels separately.
452
+ var whereTargets = new List<string>();
453
+ var sqlParams = new List<(string name, string value)>();
454
+ for (int i = 0; i < CocBaseContainers.Length; i++)
455
+ {
456
+ whereTargets.Add($"cand.Path = @T{i} OR cand.Path LIKE @P{i}");
457
+ // @T is an equality test and takes the name as-is; only @P is a LIKE pattern.
458
+ sqlParams.Add(($"@T{i}", $"/{CocBaseContainers[i]}/{baseClassName}"));
459
+ sqlParams.Add(($"@P{i}", $"/{CocBaseContainers[i]}/{EscapeLike(baseClassName)}/%"));
460
+ }
461
+
462
+ // Returns every Kind 2 target on each candidate's ExtensionOf line, plus the
463
+ // columns needed to bound them to that attribute's own arguments. The chain is
464
+ // resolved in C# because SQL cannot express "maximum of the prefix order"
465
+ // cheaply, and the set per class is tiny (1-3 rows).
466
+ var sql = $@"
467
+ WITH extLine AS (
468
+ SELECT DISTINCT ra.SourceId, ra.Line, ra.[Column] AS ExtCol
469
+ FROM [References] ra
470
+ JOIN [Names] att ON att.Id = ra.TargetId
471
+ WHERE ra.Kind = {KindAttribute} AND att.Path = @ExtensionOfAttr
472
+ ),
473
+ candidates AS (
474
+ SELECT DISTINCT e.SourceId, e.Line, e.ExtCol
475
+ FROM extLine e
476
+ JOIN [References] rc ON rc.SourceId = e.SourceId AND rc.Line = e.Line AND rc.Kind = {KindTypeRef}
477
+ JOIN [Names] cand ON cand.Id = rc.TargetId
478
+ WHERE {string.Join(" OR ", whereTargets.Select(w => $"({w})"))}
278
479
  )
279
- AND src.Path LIKE '/Classes/%'
480
+ SELECT src.Path, m.Module, tgt.Path, r.Kind, r.[Column]
481
+ FROM candidates c
482
+ JOIN [Names] src ON src.Id = c.SourceId
483
+ LEFT JOIN [Modules] m ON m.Id = src.ModuleId
484
+ JOIN [References] r ON r.SourceId = c.SourceId AND r.Line = c.Line
485
+ AND r.Kind IN ({KindTypeRef}, {KindAttribute})
486
+ JOIN [Names] tgt ON tgt.Id = r.TargetId
487
+ WHERE c.ExtCol = (SELECT MIN(c2.ExtCol) FROM candidates c2 WHERE c2.SourceId = c.SourceId)
280
488
  ORDER BY src.Path";
281
489
 
490
+ // class → (module, extCol, [(target, kind, column)])
491
+ var raw = new Dictionary<string, (string? module, int extCol, List<(string target, int kind, int col)> refs)>();
492
+
282
493
  using (var cmd = new SqlCommand(sql, conn))
283
494
  {
284
- cmd.Parameters.AddWithValue("@TargetClass", $"/Classes/{baseClassName}");
285
- cmd.Parameters.AddWithValue("@TargetClassMethod", $"/Classes/{baseClassName}/%");
286
- cmd.Parameters.AddWithValue("@ExtensionPattern", "%_Extension%");
495
+ foreach (var (name, value) in sqlParams)
496
+ cmd.Parameters.AddWithValue(name, value);
497
+ cmd.Parameters.AddWithValue("@ExtensionOfAttr", ExtensionOfAttributePath);
498
+ cmd.CommandTimeout = 60;
287
499
 
288
500
  using (var reader = cmd.ExecuteReader())
289
501
  {
@@ -293,51 +505,85 @@ namespace D365MetadataBridge.Services
293
505
  var parts = path.Split('/');
294
506
  var className = parts.Length >= 3 ? parts[2] : path;
295
507
  var module = reader.IsDBNull(1) ? null : reader.GetString(1);
508
+ var target = reader.GetString(2);
509
+ var kind = reader.IsDBNull(3) ? 0 : reader.GetByte(3);
510
+ var col = reader.IsDBNull(4) ? 0 : (int)reader.GetInt16(4);
296
511
 
297
- if (!extensionClassNames.ContainsKey(className))
298
- extensionClassNames[className] = module;
512
+ if (!raw.TryGetValue(className, out var entry))
513
+ {
514
+ entry = (module, int.MaxValue, new List<(string, int, int)>());
515
+ raw[className] = entry;
516
+ }
517
+ entry.refs.Add((target, kind, col));
518
+ // The ExtensionOf attribute's own column anchors the argument window.
519
+ if (kind == KindAttribute && target == ExtensionOfAttributePath && col < entry.extCol)
520
+ entry = (entry.module, col, entry.refs);
521
+ raw[className] = entry;
299
522
  }
300
523
  }
301
524
  }
302
525
 
303
- // Step 2: For each extension class, find which methods reference the base class methods
304
- // This identifies which methods are actually wrapped via CoC
526
+ foreach (var kv in raw)
527
+ {
528
+ var element = ResolveExtendedElement(kv.Value.refs, kv.Value.extCol);
529
+ if (element == null) continue;
530
+ // Keep only classes whose ELEMENT is the requested object or nested inside
531
+ // it. This is what discards a class that named the base incidentally.
532
+ if (!IsRequestedOrNested(element, baseClassName)) continue;
533
+ extensionClassNames[kv.Key] = (kv.Value.module, element);
534
+ }
535
+
536
+ // Step 2 — which base methods each extension actually WRAPS.
537
+ //
538
+ // A CoC wrap shows up as a Kind 1 (call) from the extension's method to the
539
+ // base method OF THE SAME NAME — that call is the `next`. The previous query
540
+ // filtered on neither the kind nor the name, so it collected every base
541
+ // method the class happened to call: that is why non-extensions came back
542
+ // claiming to wrap "construct, update", which were merely the calls they made.
305
543
  var results = new List<ExtensionClassDetailModel>();
306
544
 
307
545
  foreach (var kvp in extensionClassNames)
308
546
  {
309
547
  var extClassName = kvp.Key;
310
- var module = kvp.Value;
548
+ var (module, element) = kvp.Value;
311
549
 
312
- // Query: find method-level Names entries under this extension class
313
- // that reference methods of the base class
314
- var methodSql = @"
315
- SELECT DISTINCT tgt.Path
550
+ var methodSql = $@"
551
+ SELECT DISTINCT src.Path, tgt.Path
316
552
  FROM [References] r
317
553
  JOIN [Names] src ON r.SourceId = src.Id
318
554
  JOIN [Names] tgt ON r.TargetId = tgt.Id
319
555
  WHERE src.Path LIKE @ExtClassMethods
320
- AND tgt.Path LIKE @BaseClassMethods";
556
+ AND tgt.Path LIKE @BaseClassMethods
557
+ AND r.Kind = {KindCall}";
321
558
 
322
559
  var wrappedMethods = new List<string>();
323
560
 
324
561
  using (var cmd2 = new SqlCommand(methodSql, conn))
325
562
  {
326
- cmd2.Parameters.AddWithValue("@ExtClassMethods", $"/Classes/{extClassName}/Methods/%");
327
- cmd2.Parameters.AddWithValue("@BaseClassMethods", $"/Classes/{baseClassName}/Methods/%");
563
+ // Both patterns are escaped: "_Extension" is the CoC naming convention, so
564
+ // extClassName almost always carries an underscore, and an element path
565
+ // can too ("/Forms/…/DataFields/TaxReimbursement_IT").
566
+ cmd2.Parameters.AddWithValue("@ExtClassMethods", $"/Classes/{EscapeLike(extClassName)}/Methods/%");
567
+ // Anchored to the EXTENDED ELEMENT, not to the requested object. That is
568
+ // the whole payoff of reading the element: a form CoC wraps a method on
569
+ // one specific data source ("/Forms/SalesTable/DataSources/SalesLine/
570
+ // Methods/active"), and the SalesTable form has NINE data sources with an
571
+ // `active` method. Scoping to the base and matching leaf names alone could
572
+ // not tell them apart; scoping to the element makes the question exact.
573
+ cmd2.Parameters.AddWithValue("@BaseClassMethods", $"{EscapeLike(element)}/Methods/%");
574
+ cmd2.CommandTimeout = 60;
328
575
 
329
576
  using (var reader2 = cmd2.ExecuteReader())
330
577
  {
331
578
  while (reader2.Read())
332
579
  {
333
- var tgtPath = reader2.GetString(0);
334
- var tgtParts = tgtPath.Split('/');
335
- if (tgtParts.Length >= 5)
336
- {
337
- var methodName = tgtParts[4];
338
- if (!wrappedMethods.Contains(methodName))
339
- wrappedMethods.Add(methodName);
340
- }
580
+ // Same leaf name on both sides = the `next` call.
581
+ var srcLeaf = LeafSegment(reader2.GetString(0));
582
+ var tgtLeaf = LeafSegment(reader2.GetString(1));
583
+ if (srcLeaf == null || tgtLeaf == null) continue;
584
+ if (!string.Equals(srcLeaf, tgtLeaf, StringComparison.OrdinalIgnoreCase)) continue;
585
+ if (!wrappedMethods.Contains(tgtLeaf))
586
+ wrappedMethods.Add(tgtLeaf);
341
587
  }
342
588
  }
343
589
  }
@@ -346,10 +592,20 @@ namespace D365MetadataBridge.Services
346
592
  {
347
593
  ClassName = extClassName,
348
594
  Module = module,
595
+ ExtendedElement = element,
349
596
  WrappedMethods = wrappedMethods,
350
597
  });
351
598
  }
352
599
 
600
+ // Ordered so the caller's grouping is stable and the requested object's own
601
+ // extensions lead, with nested elements (data sources, controls, data fields)
602
+ // following in path order.
603
+ results.Sort((a, b) =>
604
+ {
605
+ var byElement = string.Compare(a.ExtendedElement, b.ExtendedElement, StringComparison.OrdinalIgnoreCase);
606
+ return byElement != 0 ? byElement : string.Compare(a.ClassName, b.ClassName, StringComparison.OrdinalIgnoreCase);
607
+ });
608
+
353
609
  return new
354
610
  {
355
611
  baseClassName,
@@ -409,10 +665,14 @@ namespace D365MetadataBridge.Services
409
665
 
410
666
  using (var cmd = new SqlCommand(sql, conn))
411
667
  {
412
- cmd.Parameters.AddWithValue("@TargetTable", $"/Tables/{targetName}");
413
- cmd.Parameters.AddWithValue("@TargetTablePath", $"/Tables/{targetName}/%");
414
- cmd.Parameters.AddWithValue("@TargetClass", $"/Classes/{targetName}");
415
- cmd.Parameters.AddWithValue("@TargetClassPath", $"/Classes/{targetName}/%");
668
+ // All four are compared with LIKE (see the WHERE above), so all four
669
+ // are escaped — including the two with no trailing wildcard, where an
670
+ // unescaped "_" would still match any single character.
671
+ var escTarget = EscapeLike(targetName);
672
+ cmd.Parameters.AddWithValue("@TargetTable", $"/Tables/{escTarget}");
673
+ cmd.Parameters.AddWithValue("@TargetTablePath", $"/Tables/{escTarget}/%");
674
+ cmd.Parameters.AddWithValue("@TargetClass", $"/Classes/{escTarget}");
675
+ cmd.Parameters.AddWithValue("@TargetClassPath", $"/Classes/{escTarget}/%");
416
676
 
417
677
  using (var reader = cmd.ExecuteReader())
418
678
  {
@@ -525,10 +785,12 @@ namespace D365MetadataBridge.Services
525
785
  }
526
786
  else
527
787
  {
788
+ // The "/%" children are LIKE patterns and get escaped; the bare paths are
789
+ // dispatched to "=" below, where "_" is an ordinary character.
528
790
  pathVariants.Add($"/Classes/{apiName}");
529
- pathVariants.Add($"/Classes/{apiName}/%");
791
+ pathVariants.Add($"/Classes/{EscapeLike(apiName)}/%");
530
792
  pathVariants.Add($"/Tables/{apiName}");
531
- pathVariants.Add($"/Tables/{apiName}/%");
793
+ pathVariants.Add($"/Tables/{EscapeLike(apiName)}/%");
532
794
  }
533
795
 
534
796
  // Build WHERE clause
@@ -2553,23 +2553,82 @@ function formatCocExtensions(r, methodNameFilter) {
2553
2553
  filtered = r.extensions.filter(ext => ext.wrappedMethods?.some(m => m.toLowerCase() === methodNameFilter.toLowerCase())
2554
2554
  || !ext.wrappedMethods || ext.wrappedMethods.length === 0);
2555
2555
  }
2556
- out += `Found ${filtered.length} extension class(es)${methodNameFilter ? ` wrapping "${methodNameFilter}"` : ''}:\n\n`;
2557
- const seen = new Set();
2556
+ // Dedupe by class name ONCE, before anything is counted. A class carries a single
2557
+ // [ExtensionOf] and the bridge keys its results by class name, so a duplicate cannot
2558
+ // reach here today — but the count and the list it introduces have to agree whatever
2559
+ // arrives, and skipping an entry inside the render loop after the heading above it had
2560
+ // already counted that entry is exactly how the two come apart.
2561
+ const byClassName = new Map();
2558
2562
  for (const ext of filtered) {
2559
- if (seen.has(ext.className))
2560
- continue;
2561
- seen.add(ext.className);
2562
- out += `- **${ext.className}**`;
2563
- if (ext.module)
2564
- out += ` (${ext.module})`;
2565
- if (ext.wrappedMethods && ext.wrappedMethods.length > 0) {
2566
- out += `\n Wraps methods: ${ext.wrappedMethods.join(', ')}`;
2567
- out += `\n Uses 'next' keyword: ✓`;
2563
+ if (!byClassName.has(ext.className))
2564
+ byClassName.set(ext.className, ext);
2565
+ }
2566
+ const unique = [...byClassName.values()];
2567
+ out += `Found ${unique.length} extension class(es)${methodNameFilter ? ` wrapping "${methodNameFilter}"` : ''}:\n\n`;
2568
+ // Grouped by the element each class actually extends, never pooled into one list. A name can
2569
+ // denote several objects at once — "SalesTable" is a table (14 extensions) AND a form (17),
2570
+ // and the form's data sources, controls and data fields are each extended separately again
2571
+ // (31 more across 12 elements). Reporting "49 extensions of SalesTable" told an agent about
2572
+ // to write a table CoC that 34 form extensions were its concern, and hid which of the form's
2573
+ // NINE data sources with an `active` method was already wrapped.
2574
+ const groups = new Map();
2575
+ for (const ext of unique) {
2576
+ const key = ext.extendedElement ?? '';
2577
+ const bucket = groups.get(key) ?? [];
2578
+ bucket.push(ext);
2579
+ groups.set(key, bucket);
2580
+ }
2581
+ for (const [element, exts] of groups) {
2582
+ // Elements sort with the requested object first and its nested members after (the bridge
2583
+ // orders by path); the heading names the element so the reader never has to infer it.
2584
+ if (element)
2585
+ out += `### ${describeXrefElement(element)} — ${exts.length}\n\n`;
2586
+ for (const ext of exts) {
2587
+ out += `- **${ext.className}**`;
2588
+ if (ext.module)
2589
+ out += ` (${ext.module})`;
2590
+ if (ext.wrappedMethods && ext.wrappedMethods.length > 0) {
2591
+ // Deliberately no `Uses 'next' keyword: ✓` line on THIS path, unlike the index /
2592
+ // filesystem path in findCocExtensions.ts. That one earns it — fsExtensionScanner
2593
+ // builds cocMethods by testing each method body against /\bnext\s+\w/i — whereas
2594
+ // here the claim was printed unconditionally for anything with a non-empty
2595
+ // wrappedMethods, and nothing had read a line of source. It therefore asserted a
2596
+ // `next` in classes that contained none, back when wrappedMethods was every base
2597
+ // method the class called. The list is now the same-named base-method call that IS
2598
+ // the `next`, but that is inferred from xref shape rather than seen, so the list
2599
+ // stands on its own instead of carrying a tick it cannot back.
2600
+ out += `\n Wraps methods: ${ext.wrappedMethods.join(', ')}`;
2601
+ }
2602
+ out += `\n`;
2568
2603
  }
2569
2604
  out += `\n`;
2570
2605
  }
2571
2606
  return out;
2572
2607
  }
2608
+ /**
2609
+ * Human-readable name for an xref element path, for the CoC grouping headings.
2610
+ * /Tables/SalesTable -> Table SalesTable
2611
+ * /Forms/SalesTable -> Form SalesTable
2612
+ * /Forms/SalesTable/DataSources/SalesLine -> Form SalesTable › DataSource SalesLine
2613
+ * /Forms/X/DataSources/Y/DataFields/Z -> Form X › DataSource Y › DataField Z
2614
+ * Falls back to the raw path for any shape not seen in the corpus, so a new [ExtensionOf]
2615
+ * intrinsic degrades to something still readable rather than being mislabelled.
2616
+ */
2617
+ function describeXrefElement(path) {
2618
+ const parts = path.split('/').filter(Boolean);
2619
+ if (parts.length < 2)
2620
+ return path;
2621
+ const singular = {
2622
+ Classes: 'Class', Tables: 'Table', Forms: 'Form', Views: 'View', Maps: 'Map',
2623
+ Queries: 'Query', DataEntityViews: 'Data entity',
2624
+ DataSources: 'DataSource', Controls: 'Control', DataFields: 'DataField',
2625
+ };
2626
+ const segs = [];
2627
+ for (let i = 0; i + 1 < parts.length; i += 2) {
2628
+ segs.push(`${singular[parts[i]] ?? parts[i]} ${parts[i + 1]}`);
2629
+ }
2630
+ return segs.join(' › ');
2631
+ }
2573
2632
  // FIND EVENT HANDLERS via XREF (Phase 6)
2574
2633
  export async function tryBridgeEventHandlers(bridge, targetName, eventName, handlerType) {
2575
2634
  if (!bridge?.isReady || !bridge.xrefAvailable)