diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b29ba7..3198552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow `` in `src/XppLens/XppLens.csproj`; release tags are `vX.Y.Z`. +## 1.3.1 — 2026-10-26 + +### Fixed +- `xpp_refs name="Table.Field"` answered "no references": the whole text was taken as an object name, while + `xpp_find` takes that form. `xpp_refs` now reads `Table.Field` / `Class::method` as the object and its member + (unless an object has that whole name, as extensions named `Base.Model` do) and says how it read the name, so the + answer equals `name=Table member=Field`: code reads (also after `Table::find().Field`) and form controls. +- `xpp_object` did not show whether a table is per company: the XML leaves out `SaveDataPerCompany` and `TableType` + when they have their default values. Tables now always list them, marked `(default)` when not in the file, and + `xpp_find property=SaveDataPerCompany` reports `Yes` for those tables instead of `-`. + ## 1.3.0 — 2026-10-26 ### Added diff --git a/src/XppLens/McpTools.cs b/src/XppLens/McpTools.cs index 30e57ad..548619c 100644 --- a/src/XppLens/McpTools.cs +++ b/src/XppLens/McpTools.cs @@ -113,7 +113,7 @@ public static string Callees(Queries q, [McpServerTool(Name = "xpp_refs", ReadOnly = true, Idempotent = true)] [Description("All references to an object or member: code (calls, types, fields — also on chained receivers, intrinsics, labels) and metadata (EDT/enum of fields, form data sources/controls, menu items, security entry points…). Use member for a table field or method, or a label id like @Contoso:MyLabel as name.")] public static string Refs(Queries q, - [Description("Referenced object name (class, table, EDT, enum, menu item, form…) or label id @File:Key")] string name, + [Description("Referenced object name (class, table, EDT, enum, menu item, form…), 'Table.Field' / 'Class::method', or label id @File:Key")] string name, [Description("Optional member: field or method name")] string? member = null, [Description("Optional kind: call, new, type, member, static, intrinsic, meta, label, coc, handler, extends, implements, extension")] string? kind = null, [Description("Optional model/package filter of the referencing code")] string? model = null, diff --git a/src/XppLens/Queries.cs b/src/XppLens/Queries.cs index ef29c8f..9bb2eb9 100644 --- a/src/XppLens/Queries.cs +++ b/src/XppLens/Queries.cs @@ -224,6 +224,20 @@ public string Find(string query, string? kind, string? type, string? model, int ? items : [(query, () => "nothing found (empty query)")]); + static readonly Dictionary NoDefaults = []; + + /// + /// Properties the XML leaves out when they have their default value, but which decide how code must use the + /// object (a table that is not per company has no DataAreaId to join on; a temporary table has no rows in SQL). + /// + static readonly Dictionary> Defaults = new(StringComparer.OrdinalIgnoreCase) + { + ["AxTable"] = new() { ["SaveDataPerCompany"] = "Yes", ["TableType"] = "RegularTable" }, + }; + + internal static Dictionary PropertyDefaults(string type) => + Defaults.TryGetValue(type, out var d) ? d : NoDefaults; + /// "Key=Value; Key2=Value2" as stored in objects.props. internal static Dictionary ParseProps(string? props) { @@ -280,6 +294,7 @@ string ListProperties(string query, string? type, string? model, string property Log.Debug($"props {o.Path}: {ex.Message}"); } } + foreach (var d in PropertyDefaults(o.Type)) props.TryAdd(d.Key, d.Value); if (wanted.Any(w => w.Filter != null && !(props.TryGetValue(w.Name, out var v) && w.Filter.IsMatch(v)))) continue; matched++; if (shown >= limit) continue; @@ -474,9 +489,10 @@ string ObjectOne(string name, string? type, string? sections, string? parent, in if (rows.Count > 1) sb.AppendLine("same name: " + string.Join(", ", rows.Skip(1).Select(r => $"{r.Type} {r.Tag}"))); - if (Show("props") && po.RootProps.Count > 0) + if (Show("props") && (po.RootProps.Count > 0 || PropertyDefaults(o.Type).Count > 0)) { - var props = string.Join("; ", po.RootProps.Where(kv => kv.Value.Length is > 0 and < 200).Select(kv => $"{kv.Key}={kv.Value}")); + var props = string.Join("; ", po.RootProps.Where(kv => kv.Value.Length is > 0 and < 200).Select(kv => $"{kv.Key}={kv.Value}") + .Concat(PropertyDefaults(o.Type).Where(d => !po.RootProps.Any(kv => kv.Key == d.Key)).Select(d => $"{d.Key}={d.Value} (default)"))); sb.AppendLine("props: " + WithLabels(s, Trunc(props, 900), cache)); } @@ -974,10 +990,33 @@ string Chain(string via, string? member) // ---------------------------------------------------------------- refs / extensions + /// + /// "Table.Field" / "Class::method" without a separate member, as xpp_find takes it, unless an object has that + /// whole name (extensions are named "Base.Model"). (null, null) when the name is not of that form. + /// + static (string? Name, string? Member) SplitMemberName(Store s, string name) + { + if (name.StartsWith('@')) return (null, null); + int sep = name.LastIndexOf("::", StringComparison.Ordinal); + int len = 2; + if (sep < 0) { sep = name.LastIndexOf('.'); len = 1; } + if (sep <= 0 || sep + len >= name.Length) return (null, null); + if (s.Scalar("SELECT 1 FROM objects WHERE name = $n LIMIT 1", ("$n", name)) != null) return (null, null); + return (name[..sep].Trim(), name[(sep + len)..].Trim()); + } + public string Refs(string name, string? member, string? kind, string? model, int limit) => svc.Read(s => { limit = Math.Clamp(limit, 1, 1000); var modelLike = PatOrNull(model); + name = name.Trim(); + string? readAs = null; + if (string.IsNullOrWhiteSpace(member) && SplitMemberName(s, name) is ({ } objectPart, { } memberPart)) + { + readAs = $"(read as name={objectPart} member={memberPart})"; + name = objectPart; + member = memberPart; + } var where = """ r.target = $n AND ($mem IS NULL OR r.member = $mem) AND ($kind IS NULL OR r.kind = $kind) AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') @@ -1009,6 +1048,7 @@ public string Refs(string name, string? member, string? kind, string? model, int { sb.AppendLine($"no references to {name}{(member != null ? "." + member : "")} in the index."); sb.AppendLine("(Microsoft code keeps calls, new, intrinsics, extensions and handlers only — no field reads, types or labels)"); + if (readAs != null) sb.AppendLine(readAs); return Finish(sb); } sb.AppendLine($"references to {name}{(string.IsNullOrWhiteSpace(member) ? "" : "." + member)}: {string.Join(", ", summary)}"); @@ -1048,6 +1088,7 @@ public string Refs(string name, string? member, string? kind, string? model, int } } } + if (readAs != null) sb.AppendLine(readAs); return Finish(sb); }); diff --git a/src/XppLens/XppLens.csproj b/src/XppLens/XppLens.csproj index 3b4bef2..ee18ae1 100644 --- a/src/XppLens/XppLens.csproj +++ b/src/XppLens/XppLens.csproj @@ -20,7 +20,7 @@ xpp-lens — X++ code index for MCP X++ (Dynamics 365 F&O) code index served to AI assistants over MCP Copyright © 2026 WillowDev - 1.3.0 + 1.3.1 diff --git a/tests/XppLens.Tests/QueryTests.cs b/tests/XppLens.Tests/QueryTests.cs index 08eb834..139d1a3 100644 --- a/tests/XppLens.Tests/QueryTests.cs +++ b/tests/XppLens.Tests/QueryTests.cs @@ -124,6 +124,26 @@ public void Refs_include_members_on_chained_receivers() Assert.Contains("DemoCustomer.find().creditMax", res); } + [Fact] + public void Refs_take_table_dot_field_like_find() + { + // The same answer for name="Table.member" as for name + member; an extension named "Base.Model" stays an object. + var split = Q.Refs("DemoCustomer.creditMax", null, null, null, 150); + Assert.Contains("DemoCustomer.find().creditMax", split); + Assert.Contains("(read as name=DemoCustomer member=creditMax)", split); + Assert.Contains("(read as name=DemoCustomer member=creditMax)", Q.Refs("DemoCustomer::creditMax", null, null, null, 150)); + Assert.DoesNotContain("read as", Q.Refs("DemoCustomer.Contoso", null, null, null, 150)); + } + + [Fact] + public void Table_properties_show_their_defaults() + { + var props = Q.Object("DemoCustomer", "table", "props", null, 0, null); + Assert.Contains("SaveDataPerCompany=Yes (default)", props); + Assert.Contains("TableType=RegularTable (default)", props); + Assert.Contains("SaveDataPerCompany=Yes", Q.Find("Demo*", null, "table", null, 40, "SaveDataPerCompany")); + } + [Fact] public void Callees_show_resolved_chains() {