d365fo-mcp 1.17.3 → 1.18.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 (77) hide show
  1. package/.github/copilot-instructions.md +1 -0
  2. package/bridge/D365MetadataBridge/Models/Models.cs +13 -0
  3. package/bridge/D365MetadataBridge/Services/CrossReferenceService.cs +331 -69
  4. package/bridge/D365MetadataBridge/Services/MetadataWriteService.cs +13 -11
  5. package/dist/bridge/bridgeAdapter.d.ts +25 -0
  6. package/dist/bridge/bridgeAdapter.js +112 -19
  7. package/dist/bridge/bridgeTypes.d.ts +21 -0
  8. package/dist/cli/commands/doctor.js +10 -8
  9. package/dist/cli/ui.d.ts +9 -2
  10. package/dist/cli/ui.js +8 -1
  11. package/dist/config/settings.js +18 -0
  12. package/dist/index.js +21 -1
  13. package/dist/metadata/buildIndexWorker.d.ts +1 -1
  14. package/dist/metadata/buildIndexWorker.js +1 -1
  15. package/dist/metadata/modelDescriptor.d.ts +139 -2
  16. package/dist/metadata/modelDescriptor.js +249 -3
  17. package/dist/metadata/startupIndexWorker.d.ts +11 -1
  18. package/dist/metadata/startupIndexWorker.js +10 -2
  19. package/dist/metadata/startupIndexing.d.ts +3 -0
  20. package/dist/metadata/startupIndexing.js +7 -1
  21. package/dist/metadata/symbolIndex.d.ts +120 -74
  22. package/dist/metadata/symbolIndex.js +179 -113
  23. package/dist/scripts/build-database.js +138 -120
  24. package/dist/scripts/build-fts.js +138 -120
  25. package/dist/scripts/extract-metadata.js +44 -10
  26. package/dist/scripts/startupIndexWorker.js +96 -110
  27. package/dist/server/toolSchemas/d365foFile.js +2 -1
  28. package/dist/server/toolSchemas/findReferences.js +1 -1
  29. package/dist/tools/analysis/findReferences.js +167 -27
  30. package/dist/tools/analysis/validateObjectNaming.js +15 -4
  31. package/dist/tools/d365foFile.js +27 -8
  32. package/dist/tools/prepare/prepareCreate.js +8 -5
  33. package/dist/tools/readers/classInfo.js +58 -6
  34. package/dist/tools/readers/getMethodSource.d.ts +12 -1
  35. package/dist/tools/readers/getMethodSource.js +75 -20
  36. package/dist/tools/readers/getWorkspaceInfo.js +53 -6
  37. package/dist/tools/sdlc/buildProject.js +90 -0
  38. package/dist/tools/sdlc/compilerMetadataPrune.d.ts +50 -0
  39. package/dist/tools/sdlc/compilerMetadataPrune.js +129 -0
  40. package/dist/tools/sdlc/updateSymbolIndex.d.ts +1 -0
  41. package/dist/tools/sdlc/updateSymbolIndex.js +26 -0
  42. package/dist/tools/smart/codeGen.js +47 -50
  43. package/dist/tools/specs/d365foFileOpSpecs.js +37 -0
  44. package/dist/tools/toolHandler.js +5 -4
  45. package/dist/tools/write/directXmlWriters.d.ts +31 -0
  46. package/dist/tools/write/directXmlWriters.js +129 -0
  47. package/dist/tools/write/formDataSourceNote.d.ts +22 -0
  48. package/dist/tools/write/formDataSourceNote.js +64 -0
  49. package/dist/tools/write/inlineIndexUpsert.js +6 -0
  50. package/dist/tools/write/inlineWriteVerification.d.ts +1 -1
  51. package/dist/tools/write/inlineWriteVerification.js +10 -1
  52. package/dist/tools/write/modifyD365File.d.ts +11 -0
  53. package/dist/tools/write/modifyD365File.js +150 -15
  54. package/dist/tools/write/preserveMetadataElements.js +1 -0
  55. package/dist/utils/configManager.js +7 -1
  56. package/dist/utils/indexedMethodSource.d.ts +76 -0
  57. package/dist/utils/indexedMethodSource.js +108 -0
  58. package/dist/utils/methodBodyHint.d.ts +12 -0
  59. package/dist/utils/methodBodyHint.js +12 -0
  60. package/dist/utils/modelClassifier.d.ts +26 -0
  61. package/dist/utils/modelClassifier.js +52 -3
  62. package/dist/utils/objectFileLookup.d.ts +41 -0
  63. package/dist/utils/objectFileLookup.js +84 -0
  64. package/dist/utils/objectNaming.js +12 -5
  65. package/dist/utils/objectNamingRules.d.ts +2 -0
  66. package/dist/utils/objectNamingRules.js +73 -5
  67. package/dist/utils/packagesRoot.d.ts +41 -1
  68. package/dist/utils/packagesRoot.js +152 -16
  69. package/dist/utils/pathContainment.d.ts +7 -1
  70. package/dist/utils/pathContainment.js +39 -1
  71. package/dist/utils/startupProgress.d.ts +57 -0
  72. package/dist/utils/startupProgress.js +102 -0
  73. package/dist/utils/toolProgressMessage.js +4 -0
  74. package/dist/utils/xmlScan.js +7 -1
  75. package/dist/workspace/projectMembership.d.ts +15 -0
  76. package/dist/workspace/projectMembership.js +17 -0
  77. package/package.json +1 -1
@@ -74,6 +74,7 @@ PowerShell / any terminal command **WILL HANG** in VS 2022 / VS 2026 MCP integra
74
74
  - `model-name` → class `{Target}_{ModelName}_Extension`, element `{Target}.{ModelName}`
75
75
 
76
76
  Pass the BASE object name to `d365fo_file(action="create")` and let the tool inject the token — don't hand-build the infix.
77
+ These patterns describe how the tool names extensions, and the tool applies them itself — editing them here does not change the names it writes. Change the style with `d365fo-mcp config naming`.
77
78
 
78
79
  ### Reuse & diff safety
79
80
 
@@ -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
@@ -3483,14 +3483,11 @@ namespace D365MetadataBridge.Services
3483
3483
  ?? throw new ArgumentException($"Form extension '{objectName}' not found");
3484
3484
  var msi = GetModelSaveInfoForObject(_provider.FormExtensions, objectName);
3485
3485
 
3486
- // Same idempotency rule as the form branch below: skip on a name match,
3487
- // and on a different-named data source already bound to the same table.
3486
+ // Same idempotency rule as the form branch below: skip on a NAME match only.
3488
3487
  foreach (AxFormDataSourceRoot existing in axExt.DataSources)
3489
3488
  {
3490
3489
  if (string.Equals(existing.Name, dsName, StringComparison.OrdinalIgnoreCase))
3491
3490
  return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{dsName}' already exists", api = "IMetaFormExtensionProvider.Update" };
3492
- if (string.Equals(existing.Table, table, StringComparison.OrdinalIgnoreCase))
3493
- return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{existing.Name}' already binds table '{table}'", api = "IMetaFormExtensionProvider.Update" };
3494
3491
  }
3495
3492
 
3496
3493
  axExt.DataSources.Add((AxFormDataSourceRoot)CreateFormDataSourceRoot(dsName, table, joinSource, linkType));
@@ -3506,18 +3503,23 @@ namespace D365MetadataBridge.Services
3506
3503
  ?? throw new ArgumentException($"Form '{objectName}' not found");
3507
3504
  var msi = GetModelSaveInfoForObject(_provider.Forms, objectName);
3508
3505
 
3509
- // Idempotency: don't append a duplicate. If a data source with the same
3510
- // NAME already exists, skip (it may be a template stub already bound to the
3511
- // right table). If a DIFFERENT-named data source already binds the same
3512
- // TABLE, skip too — adding a second binding to the same table is almost
3513
- // always an accident (a stub the caller meant to replace, not duplicate).
3506
+ // Idempotency: don't append a duplicate. A data source with the same NAME
3507
+ // already there is a real conflict (it may be a template stub already bound
3508
+ // to the right table), so skip that one.
3509
+ //
3510
+ // A different-named data source already bound to the same TABLE is NOT a
3511
+ // conflict and must still be written. This used to skip too, on the theory
3512
+ // that "adding a second binding to the same table is almost always an
3513
+ // accident" — which is simply untrue: a form routinely joins one table into
3514
+ // several branches of its query. Microsoft's own PurchLineBackOrder carries
3515
+ // PurchTable and PurchTable1, both over PurchTable, with different link
3516
+ // types. The rule silently dropped three legitimate data sources and, because
3517
+ // `skipped` never reached the caller, reported all three as added.
3514
3518
  foreach (var existing in axForm.DataSources)
3515
3519
  {
3516
3520
  dynamic dyn = existing;
3517
3521
  if (string.Equals((string)dyn.Name, dsName, StringComparison.OrdinalIgnoreCase))
3518
3522
  return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{dsName}' already exists", api = "IMetaFormProvider.Update" };
3519
- if (string.Equals((string)dyn.Table, table, StringComparison.OrdinalIgnoreCase))
3520
- return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{(string)dyn.Name}' already binds table '{table}'", api = "IMetaFormProvider.Update" };
3521
3523
  }
3522
3524
 
3523
3525
  axForm.AddDataSource(CreateFormDataSourceRoot(dsName, table, joinSource, linkType));