feat: show zone resolver diagnostics

This commit is contained in:
2026-05-25 00:09:18 +02:00
parent f1cd74dc7b
commit 1ad432270b
6 changed files with 126 additions and 3 deletions

View File

@@ -182,6 +182,12 @@ Useful warnings for the graph explorer include:
- local-only surfaces that appear in shared or production scenarios;
- conflicting port or host claims within the same deployment scenario.
Zone resolvers should also expose scoped diagnostics in zone detail panels. The
initial diagnostic set includes empty zone seed sets, visible nodes matched by
multiple zone definitions, and edges crossing zone boundaries. Attraction
diagnostics such as multiple attraction candidates or depth-limit stops belong
to the same resolver diagnostic channel when attraction rules are enabled.
## Repo-Scoping Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload

View File

@@ -1111,6 +1111,14 @@ def graph_explorer_page() -> str:
};
};
const zoneDiagnosticCodes = {
emptySeedSet: "ZONE_EMPTY_SEED_SET",
nodeSeededByMultipleZones: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
nodeAttractedByMultipleZones: "ZONE_NODE_ATTRACTED_BY_MULTIPLE_ZONES",
attractionDepthLimitReached: "ZONE_ATTRACTION_DEPTH_LIMIT_REACHED",
edgeCrossesZoneBoundary: "ZONE_EDGE_CROSSES_ZONE_BOUNDARY",
};
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") {
@@ -1176,7 +1184,7 @@ def graph_explorer_page() -> str:
if (candidates.length > 1) {
const diagnostic = {
severity: "warning",
code: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
code: zoneDiagnosticCodes.nodeSeededByMultipleZones,
message: `${elementLabel(node)} matched multiple zone definitions.`,
};
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
@@ -1189,6 +1197,15 @@ def graph_explorer_page() -> str:
zone.nodes.push(node);
addElementToZone(chosen.id, node);
});
zones.forEach((zone) => {
if (!zone.nodes.length) {
zone.diagnostics.push({
severity: "warning",
code: zoneDiagnosticCodes.emptySeedSet,
message: `${zone.label} has no visible seed nodes.`,
});
}
});
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
const data = zoneElementData(edge);
const zoneIds = new Set(
@@ -1198,6 +1215,16 @@ def graph_explorer_page() -> str:
);
const sourceZoneId = assignments.get(edge.data("source"));
const targetZoneId = assignments.get(edge.data("target"));
if ((sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
const diagnostic = {
severity: "info",
code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
message: `${elementLabel(edge)} crosses a zone boundary.`,
};
[sourceZoneId, targetZoneId]
.filter(Boolean)
.forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
}
if (sourceZoneId) zoneIds.add(sourceZoneId);
if (targetZoneId) zoneIds.add(targetZoneId);
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
@@ -1321,6 +1348,8 @@ def graph_explorer_page() -> str:
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
`${elementLabel(element)}: ${warning}`
));
const diagnostics = (zone.diagnostics || [])
.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`);
const rows = [
["visible nodes", String(visibleNodes.length)],
["deployment environments", valuesFor("deploymentEnvironment")],
@@ -1328,12 +1357,14 @@ def graph_explorer_page() -> str:
["access zones", valuesFor("accessZone")],
["routing authorities", valuesFor("routingAuthority")],
["policy authorities", valuesFor("policyAuthority")],
...diagnostics.slice(0, 8).map((diagnostic) => ["diagnostic", diagnostic]),
...warnings.slice(0, 8).map((warning) => ["warning", warning]),
];
if (diagnostics.length > 8) rows.push(["diagnostic", `${diagnostics.length - 8} additional zone diagnostics`]);
if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
detailList.innerHTML = rows
.filter(([, value]) => value)
.map(([key, value]) => `<li class="${key === "warning" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.map(([key, value]) => `<li class="${key === "warning" || key === "diagnostic" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.join("");
const contextIds = new Set(visibleNodes.map((node) => node.id()));
@@ -1346,6 +1377,7 @@ def graph_explorer_page() -> str:
orientationList.innerHTML = [
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
{label: "zone", value: zone.label},
{label: "diagnostics", value: diagnostics.length ? String(diagnostics.length) : "none", state: diagnostics.length ? "warning" : "good"},
{label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
].map((row) => `
<li class="orientation-item ${orientationStateClass(row.state)}">

View File

@@ -332,6 +332,7 @@ def resolve_zones(
edge_records,
enabled_definitions,
seed_node_ids_by_zone_id,
diagnostics,
)
for candidate in attraction_candidates:
candidates_by_node_id[candidate.node_id].append(candidate)
@@ -406,6 +407,16 @@ def resolve_zones(
internal_edge_ids_by_zone_id[source_zone_id].append(edge.id)
continue
if source_zone_id or target_zone_id:
zone_ids = tuple(sorted(str(zone_id) for zone_id in {source_zone_id, target_zone_id} - {None}))
diagnostics.append(
ZoneDiagnostic(
severity="INFO",
code="ZONE_EDGE_CROSSES_ZONE_BOUNDARY",
message=f"Edge {edge.id} crosses a zone boundary.",
edge_id=edge.id,
zone_ids=zone_ids,
)
)
boundary_edges.append(
ZoneBoundaryEdge(
edge_id=edge.id,
@@ -468,6 +479,7 @@ def _attraction_candidates(
edge_records: tuple[_EdgeRecord, ...],
enabled_definitions: tuple[tuple[int, ZoneDefinition], ...],
seed_node_ids_by_zone_id: Mapping[str, set[str]],
diagnostics: list[ZoneDiagnostic],
) -> list[_Candidate]:
nodes_by_id = {node.id: node for node in node_records}
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
@@ -476,6 +488,7 @@ def _attraction_candidates(
adjacency[edge.target].append(edge)
candidates: dict[tuple[str, str], _Candidate] = {}
depth_limit_diagnostics: set[tuple[str, str]] = set()
for definition_order, definition in enabled_definitions:
seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set())
if not seed_node_ids:
@@ -486,6 +499,27 @@ def _attraction_candidates(
while queue:
node_id, depth = queue.popleft()
if depth >= rule.depth:
if _has_matching_attraction_neighbor(
node_id,
adjacency.get(node_id, []),
nodes_by_id,
rule,
):
key = (definition.id, node_id)
if key not in depth_limit_diagnostics:
depth_limit_diagnostics.add(key)
diagnostics.append(
ZoneDiagnostic(
severity="INFO",
code="ZONE_ATTRACTION_DEPTH_LIMIT_REACHED",
message=(
f"Zone {definition.id} reached attraction depth "
f"{rule.depth} at node {node_id}."
),
node_id=node_id,
zone_ids=(definition.id,),
)
)
continue
for edge in adjacency.get(node_id, []):
if not _edge_matches_attraction_rule(edge, rule):
@@ -521,6 +555,24 @@ def _attraction_candidates(
return sorted(candidates.values(), key=lambda candidate: (candidate.node_id, candidate.zone_id))
def _has_matching_attraction_neighbor(
node_id: str,
edges: Iterable[_EdgeRecord],
nodes_by_id: Mapping[str, _NodeRecord],
rule: ZoneAttractionRule,
) -> bool:
for edge in edges:
if not _edge_matches_attraction_rule(edge, rule):
continue
neighbor_id = _neighbor_for_direction(node_id, edge, rule.direction)
if not neighbor_id:
continue
neighbor = nodes_by_id.get(neighbor_id)
if neighbor and _rule_matches(neighbor.data, rule.node_filter, empty_matches=True):
return True
return False
def _node_records(nodes: Iterable[Mapping[str, Any]]) -> tuple[_NodeRecord, ...]:
records: list[_NodeRecord] = []
for order, element in enumerate(nodes):

View File

@@ -417,6 +417,11 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "zoneForData" in page
assert "defaultZoneDefinitions" in page
assert "resolveZoneInstances" in page
assert "zoneDiagnosticCodes" in page
assert "ZONE_EMPTY_SEED_SET" in page
assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
assert "zone.diagnostics" in page
assert 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page

View File

@@ -91,6 +91,9 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
assert resolution.boundary_edges[0].edge_id == "edge.prod-test"
assert resolution.boundary_edges[0].source_zone_id == "prod"
assert resolution.boundary_edges[0].target_zone_id == "test"
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in {
diagnostic.code for diagnostic in resolution.diagnostics
}
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
@@ -128,6 +131,9 @@ def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
assert "far" not in resolution.node_assignments
assert resolution.zone_by_id("prod").internal_edge_ids == ("edge.seed-near",)
assert resolution.zone_by_id("prod").boundary_edge_ids == ("edge.near-far",)
assert "ZONE_ATTRACTION_DEPTH_LIMIT_REACHED" in {
diagnostic.code for diagnostic in resolution.diagnostics
}
def test_resolver_keeps_seed_membership_over_attraction() -> None:
@@ -229,3 +235,19 @@ def test_resolver_serializes_resolution() -> None:
assert serialized["zones"][0]["id"] == "prod"
assert serialized["node_assignments"]["svc"]["zone_id"] == "prod"
assert serialized["node_assignments"]["svc"]["reason"] == "seed"
def test_resolver_reports_empty_zone_seed_set() -> None:
resolution = resolve_zones(
nodes=[_node("svc", deploymentEnvironment="dev")],
edges=[],
zone_definitions=[
{
"id": "prod",
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
}
],
)
assert resolution.zone_by_id("prod").node_ids == ()
assert "ZONE_EMPTY_SEED_SET" in {diagnostic.code for diagnostic in resolution.diagnostics}

View File

@@ -127,7 +127,7 @@ preserving edge evidence in zone details.
```task
id: RAIL-FAB-WP-0022-T04
status: todo
status: done
priority: medium
state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9"
```
@@ -146,6 +146,12 @@ Diagnostics should include at least:
Expected result: zone detail panels show scoped diagnostics, and tests verify
that diagnostics are generated by the resolver rather than ad hoc UI checks.
Result: Added resolver diagnostics for empty seed sets, overlapping zone
membership, attraction depth-limit stops, and boundary-crossing edges. The graph
explorer now surfaces scoped zone diagnostics in the selected zone detail panel
and orientation context, with assertions proving diagnostics come from the zone
resolver path.
## Task 5: Persist Zone View Settings In Profiles
```task