fix(galaxyrepository): scoped browse surfaces ancestors as navigation-only nodes (scadaproj#42)

A browse-subtree scope such as Plant/LineA/* filtered out Plant and
Plant/LineA, so scoped lazy browse from the root was empty and
DiscoverHierarchy returned orphans. Out-of-scope ancestors of an allowed
object are now returned as navigation-only containers: attributes stripped,
child_has_children = true, and only their children on the path to an allowed
object are listed (siblings stay hidden). DiscoverHierarchy reports them in
GalaxyHierarchyQueryResult.NavigationAncestorCount; WatchDeployEvents
subtracts it so its scoped counts stay allowed-objects-only.

- tests: ScopedAncestorBrowseTests; deploy-event scope test updated
- IGalaxyBrowseScopeProvider documents the semantics; CLAUDE.md 0.2.1 notes
- code-reviews: GalaxyRepository-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:48:02 -05:00
parent 20cf3bfec7
commit 414050935e
10 changed files with 275 additions and 30 deletions
@@ -80,14 +80,17 @@ public sealed class GalaxyRepositoryGrpcServiceScopeTests
Assert.Equal(6, full.TotalObjectCount);
Assert.Equal(4, full.Objects.Sum(obj => obj.Attributes.Count));
// The glob selects only LineA's two leaf objects (Pump01, Valve01), each with one
// attribute. That scoped projection (2 objects / 2 attributes) is a non-empty subset
// distinct from both the full count and the raw notifier values below.
// The glob allows only LineA's two leaf objects (Pump01, Valve01), each with one
// attribute. The projection also returns their two ancestors (PlantArea, LineA) as
// attribute-stripped navigation nodes (GalaxyRepository-002); the deploy event must count
// only the 2 allowed objects / 2 attributes — a non-empty subset distinct from both the
// full count and the raw notifier values below.
GalaxyHierarchyQueryResult scopedProjection = GalaxyHierarchyProjector.Project(
entry,
new DiscoverHierarchyRequest(),
browseSubtreeGlobs: ["PlantArea/LineA/*"]);
Assert.Equal(2, scopedProjection.TotalObjectCount);
Assert.Equal(4, scopedProjection.TotalObjectCount);
Assert.Equal(2, scopedProjection.NavigationAncestorCount);
Assert.Equal(2, scopedProjection.Objects.Sum(obj => obj.Attributes.Count));
// Publish a deploy event whose RAW counts differ from both full and scoped, so an
@@ -114,7 +117,7 @@ public sealed class GalaxyRepositoryGrpcServiceScopeTests
new TestServerCallContext());
DeployEvent emitted = Assert.Single(responseStream.Written);
Assert.Equal(scopedProjection.TotalObjectCount, emitted.ObjectCount);
Assert.Equal(2, emitted.ObjectCount);
Assert.Equal(2, emitted.AttributeCount);
// The raw notifier values were overridden by the scoped re-projection.
Assert.NotEqual(999, emitted.ObjectCount);
@@ -0,0 +1,131 @@
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// GalaxyRepository-002 (scadaproj#42): a browse-subtree scope such as <c>Plant/LineA/*</c>
/// hid the ancestors of the allowed subtree, so scoped lazy browse from the root was empty.
/// Ancestors must now surface as attribute-stripped navigation containers, while their
/// non-allowed children stay hidden.
/// </summary>
public sealed class ScopedAncestorBrowseTests
{
private static readonly string[] Scope = ["Plant/LineA/*"];
[Fact]
public void Root_browse_surfaces_the_ancestor_as_a_navigation_container()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest());
GalaxyObject plant = Assert.Single(result.Children);
Assert.Equal("Plant", plant.TagName);
Assert.True(result.ChildHasChildren[0]);
Assert.Empty(plant.Attributes);
Assert.Equal(1, result.TotalChildCount);
}
[Fact]
public void Ancestor_browse_shows_only_the_path_to_the_allowed_subtree()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { ParentContainedPath = "Plant" });
GalaxyObject lineA = Assert.Single(result.Children);
Assert.Equal("Plant.LineA", lineA.TagName);
Assert.True(result.ChildHasChildren[0]);
Assert.Empty(lineA.Attributes);
}
[Fact]
public void Allowed_subtree_is_returned_with_its_attributes()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { ParentContainedPath = "Plant/LineA" });
GalaxyObject pump = Assert.Single(result.Children);
Assert.Equal("Plant.LineA.Pump01", pump.TagName);
Assert.Single(pump.Attributes);
}
[Fact]
public void Non_allowed_sibling_subtree_does_not_leak()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { ParentContainedPath = "Plant/LineB" });
Assert.Empty(result.Children);
Assert.Equal(0, result.TotalChildCount);
}
[Fact]
public void Ancestor_is_not_surfaced_when_no_allowed_descendant_matches_the_request_filter()
{
GalaxyBrowseChildrenResult result = Browse(new BrowseChildrenRequest { TagNameGlob = "*Nothing*" });
Assert.Empty(result.Children);
}
[Fact]
public void DiscoverHierarchy_includes_stripped_ancestors_and_no_other_objects()
{
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
CreateEntry(), new DiscoverHierarchyRequest(), Scope);
Assert.Equal(
new[] { "Plant", "Plant.LineA", "Plant.LineA.Pump01" },
result.Objects.Select(o => o.TagName).Order(StringComparer.Ordinal));
Assert.Empty(result.Objects.Single(o => o.TagName == "Plant").Attributes);
Assert.Empty(result.Objects.Single(o => o.TagName == "Plant.LineA").Attributes);
Assert.Single(result.Objects.Single(o => o.TagName == "Plant.LineA.Pump01").Attributes);
Assert.Equal(2, result.NavigationAncestorCount);
}
[Fact]
public void Unscoped_DiscoverHierarchy_is_unchanged()
{
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(CreateEntry(), new DiscoverHierarchyRequest());
Assert.Equal(6, result.TotalObjectCount);
Assert.Equal(0, result.NavigationAncestorCount);
Assert.Single(result.Objects.Single(o => o.TagName == "Plant").Attributes);
}
private static GalaxyBrowseChildrenResult Browse(BrowseChildrenRequest request) =>
GalaxyBrowseProjector.ProjectChildren(CreateEntry(), request, Scope, 0, 100);
private static GalaxyHierarchyCacheEntry CreateEntry()
{
GalaxyObject[] objects =
[
Obj(1, 0, "Plant", "Plant", isArea: true),
Obj(2, 1, "LineA", "Plant.LineA", isArea: true),
Obj(3, 2, "Pump01", "Plant.LineA.Pump01"),
Obj(4, 1, "LineB", "Plant.LineB", isArea: true),
Obj(5, 4, "Pump02", "Plant.LineB.Pump02"),
Obj(6, 1, "Loose", "Plant.Loose"),
];
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 1,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
ObjectCount = objects.Length,
};
}
private static GalaxyObject Obj(int id, int parent, string name, string tag, bool isArea = false)
{
GalaxyObject obj = new()
{
GobjectId = id,
ParentGobjectId = parent,
IsArea = isArea,
ContainedName = name,
BrowseName = name,
TagName = tag,
};
// Every object, ancestors included, carries an attribute so stripping is observable.
obj.Attributes.Add(new GalaxyAttribute { AttributeName = "Secret", FullTagReference = tag + ".Secret" });
return obj;
}
}