Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow `<Version>` 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
Expand Down
2 changes: 1 addition & 1 deletion src/XppLens/McpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 43 additions & 2 deletions src/XppLens/Queries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> NoDefaults = [];

/// <summary>
/// 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).
/// </summary>
static readonly Dictionary<string, Dictionary<string, string>> Defaults = new(StringComparer.OrdinalIgnoreCase)
{
["AxTable"] = new() { ["SaveDataPerCompany"] = "Yes", ["TableType"] = "RegularTable" },
};

internal static Dictionary<string, string> PropertyDefaults(string type) =>
Defaults.TryGetValue(type, out var d) ? d : NoDefaults;

/// <summary>"Key=Value; Key2=Value2" as stored in objects.props.</summary>
internal static Dictionary<string, string> ParseProps(string? props)
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -974,10 +990,33 @@ string Chain(string via, string? member)

// ---------------------------------------------------------------- refs / extensions

/// <summary>
/// "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.
/// </summary>
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 '!')
Expand Down Expand Up @@ -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)}");
Expand Down Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion src/XppLens/XppLens.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<AssemblyTitle>xpp-lens — X++ code index for MCP</AssemblyTitle>
<Description>X++ (Dynamics 365 F&amp;O) code index served to AI assistants over MCP</Description>
<Copyright>Copyright © 2026 WillowDev</Copyright>
<Version>1.3.0</Version>
<Version>1.3.1</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
20 changes: 20 additions & 0 deletions tests/XppLens.Tests/QueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading