d365fo-mcp 1.17.3 → 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.
- package/bridge/D365MetadataBridge/Models/Models.cs +13 -0
- package/bridge/D365MetadataBridge/Services/CrossReferenceService.cs +331 -69
- package/dist/bridge/bridgeAdapter.js +70 -11
- package/dist/bridge/bridgeTypes.d.ts +7 -0
- package/dist/cli/commands/doctor.js +10 -8
- package/dist/index.js +21 -1
- package/dist/metadata/buildIndexWorker.d.ts +1 -1
- package/dist/metadata/buildIndexWorker.js +1 -1
- package/dist/metadata/startupIndexWorker.d.ts +11 -1
- package/dist/metadata/startupIndexWorker.js +10 -2
- package/dist/metadata/startupIndexing.d.ts +3 -0
- package/dist/metadata/startupIndexing.js +7 -1
- package/dist/metadata/symbolIndex.d.ts +120 -74
- package/dist/metadata/symbolIndex.js +179 -113
- package/dist/scripts/build-database.js +123 -120
- package/dist/scripts/build-fts.js +123 -120
- package/dist/scripts/extract-metadata.js +29 -10
- package/dist/scripts/startupIndexWorker.js +96 -110
- package/dist/server/toolSchemas/findReferences.js +1 -1
- package/dist/tools/analysis/findReferences.js +92 -2
- package/dist/tools/prepare/prepareCreate.js +8 -5
- package/dist/tools/toolHandler.js +5 -4
- package/dist/utils/configManager.js +7 -1
- package/dist/utils/packagesRoot.d.ts +41 -1
- package/dist/utils/packagesRoot.js +152 -16
- package/dist/utils/startupProgress.d.ts +57 -0
- package/dist/utils/startupProgress.js +102 -0
- 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
|
-
///
|
|
78
|
-
///
|
|
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 "<Base>_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
|
-
|
|
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
|
-
//
|
|
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 ==
|
|
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
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
251
|
-
///
|
|
252
|
-
///
|
|
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
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
cmd.Parameters.AddWithValue("@
|
|
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 (!
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
334
|
-
var
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
2557
|
-
|
|
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 (
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
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)
|