Compare commits

...

2 Commits

Author SHA1 Message Date
1ad432270b feat: show zone resolver diagnostics 2026-05-25 00:09:18 +02:00
f1cd74dc7b feat: resolve graph explorer zones from definitions 2026-05-25 00:00:28 +02:00
6 changed files with 377 additions and 55 deletions

View File

@@ -156,7 +156,9 @@ reach it here?" without mutating the underlying Fabric responsibility boundary.
Zone boundary overlays are a visual layer over the graph canvas. They should be Zone boundary overlays are a visual layer over the graph canvas. They should be
computed from visible node positions after layout/filtering rather than modeled computed from visible node positions after layout/filtering rather than modeled
as graph parent nodes. The default boundary grouping is `deploymentEnvironment`: as graph parent nodes. The overlay should be backed by explicit zone definitions
and a resolver so visible nodes are assigned to at most one rendered zone. The
default boundary grouping is `deploymentEnvironment`:
| Overlay label | Matching nodes | | Overlay label | Matching nodes |
|---------------|----------------| |---------------|----------------|
@@ -180,6 +182,12 @@ Useful warnings for the graph explorer include:
- local-only surfaces that appear in shared or production scenarios; - local-only surfaces that appear in shared or production scenarios;
- conflicting port or host claims within the same deployment scenario. - 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 Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload Repo-scoping can adapt without a rewrite because its current graph payload

View File

@@ -988,10 +988,257 @@ def graph_explorer_page() -> str:
.split("") .split("")
.reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0); .reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0);
const normalizeDeploymentZoneValue = (value) => {
const raw = String(value || "").trim();
const normalized = raw.toLowerCase();
if (!normalized || normalized === "all") return "";
if (normalized === "dev") return "dev-tegwick";
if (normalized === "test" || normalized === "staging") return "test";
if (normalized === "prod") return "prod";
return raw;
};
const defaultZoneDefinitions = {
deploymentEnvironment: [
{
id: "deploymentEnvironment:dev-tegwick",
label: "dev-tegwick",
field: "deploymentEnvironment",
value: "dev-tegwick",
rank: 0,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "dev-tegwick",
normalize: "deploymentEnvironment",
},
presentation: {height: 10, color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
},
{
id: "deploymentEnvironment:test",
label: "test",
field: "deploymentEnvironment",
value: "test",
rank: 1,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "test",
normalize: "deploymentEnvironment",
},
presentation: {height: 20, color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
},
{
id: "deploymentEnvironment:prod",
label: "prod",
field: "deploymentEnvironment",
value: "prod",
rank: 2,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "prod",
normalize: "deploymentEnvironment",
},
presentation: {height: 30, color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
},
],
};
const zoneElementData = (element) => {
if (!element) return {};
if (typeof element.data === "function") return element.data();
return element.data && typeof element.data === "object" ? element.data : element;
};
const zoneFieldValue = (data, field) => {
if (!field) return "";
let current = data || {};
String(field).split(".").forEach((part) => {
current = current && typeof current === "object" ? current[part] : undefined;
});
return current;
};
const zoneRuleValue = (data, rule) => {
const value = zoneFieldValue(data, rule.field);
if (rule.normalize === "deploymentEnvironment") return normalizeDeploymentZoneValue(value);
return hasValue(value) ? String(value).trim() : "";
};
const zoneRuleMatches = (data, rule, emptyMatches = false) => {
if (!rule || !Object.keys(rule).length) return emptyMatches;
if (Array.isArray(rule.all)) {
return rule.all.every((child) => zoneRuleMatches(data, child));
}
if (Array.isArray(rule.any)) {
return rule.any.some((child) => zoneRuleMatches(data, child));
}
if (Array.isArray(rule.rules)) {
return rule.rules.every((child) => zoneRuleMatches(data, child));
}
const actual = zoneRuleValue(data, rule);
if (rule.op === "exists") return hasValue(actual);
if (rule.op === "in") {
const expected = Array.isArray(rule.value) ? rule.value : [rule.value];
return expected.map((value) => String(value)).includes(String(actual));
}
return String(actual) === String(rule.value ?? "");
};
const zoneDescriptorFromDefinition = (definition) => ({
field: definition.field,
id: definition.id,
label: definition.label || definition.id,
value: definition.value || definition.label || definition.id,
rank: definition.rank,
presentation: definition.presentation || {},
membership: definition.membership || {},
diagnostics: [],
});
const accessZoneDefinition = (value) => {
const label = String(value || "").trim();
if (!label) return null;
return {
id: `accessZone:${label}`,
label,
field: "accessZone",
value: label,
rank: 10 + hashString(label) % 20,
membership: {field: "accessZone", op: "equals", value: label},
presentation: {},
};
};
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") {
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
return definition ? [definition] : [];
}
return [];
};
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") {
const values = new Set();
elements.forEach((element) => {
const value = String(zoneFieldValue(zoneElementData(element), "accessZone") || "").trim();
if (value) values.add(value);
});
return Array.from(values)
.sort((left, right) => left.localeCompare(right))
.map(accessZoneDefinition)
.filter(Boolean);
}
return [];
};
const chooseZoneCandidate = (candidates) => candidates
.slice()
.sort((left, right) => {
const leftHeight = Number(left.definition.presentation?.height) || 0;
const rightHeight = Number(right.definition.presentation?.height) || 0;
return rightHeight - leftHeight || left.index - right.index || left.definition.id.localeCompare(right.definition.id);
})[0];
const resolveZoneInstances = (elements, definitions) => {
const zones = new Map();
definitions.forEach((definition, index) => {
zones.set(definition.id, {
...zoneDescriptorFromDefinition(definition),
definition,
definitionIndex: index,
nodes: [],
elements: [],
nodeIds: new Set(),
elementIds: new Set(),
diagnostics: [],
});
});
const assignments = new Map();
const addElementToZone = (zoneId, element) => {
const zone = zones.get(zoneId);
if (!zone) return;
const elementId = element.id ? element.id() : zoneElementData(element).id;
if (elementId && zone.elementIds.has(elementId)) return;
if (elementId) zone.elementIds.add(elementId);
zone.elements.push(element);
};
elements.filter((element) => element.isNode && element.isNode()).forEach((node) => {
const data = zoneElementData(node);
const candidates = definitions
.map((definition, index) => ({definition, index}))
.filter((candidate) => zoneRuleMatches(data, candidate.definition.membership));
if (!candidates.length) return;
if (candidates.length > 1) {
const diagnostic = {
severity: "warning",
code: zoneDiagnosticCodes.nodeSeededByMultipleZones,
message: `${elementLabel(node)} matched multiple zone definitions.`,
};
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
}
const chosen = chooseZoneCandidate(candidates).definition;
const zone = zones.get(chosen.id);
if (!zone) return;
assignments.set(node.id(), chosen.id);
zone.nodeIds.add(node.id());
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(
definitions
.filter((definition) => zoneRuleMatches(data, definition.membership))
.map((definition) => definition.id)
);
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));
});
return zones;
};
const zoneStyle = (zone) => const zoneStyle = (zone) =>
zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length]; zone.presentation?.color
? {color: zone.presentation.color, fill: zone.presentation.fill || zonePalette[zone.id]?.fill || "rgba(37, 99, 235, .07)"}
: zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
const zoneRank = (zone) => { const zoneRank = (zone) => {
if (Number.isFinite(zone.rank)) return zone.rank;
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0; if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
if (zone.id === "deploymentEnvironment:test") return 1; if (zone.id === "deploymentEnvironment:test") return 1;
if (zone.id === "deploymentEnvironment:prod") return 2; if (zone.id === "deploymentEnvironment:prod") return 2;
@@ -1011,36 +1258,9 @@ def graph_explorer_page() -> str:
}; };
const zoneForData = (data, grouping = activeZoneGrouping) => { const zoneForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") { const definition = zoneDefinitionsForData(data, grouping)
const value = String(data.deploymentEnvironment || "").trim(); .find((candidate) => zoneRuleMatches(data, candidate.membership));
if (!value) return null; return definition ? zoneDescriptorFromDefinition(definition) : null;
const normalized = value.toLowerCase();
if (normalized === "all") return null;
const label = normalized === "dev"
? "dev-tegwick"
: normalized === "test" || normalized === "staging"
? "test"
: normalized === "prod"
? normalized
: value;
return {
field: "deploymentEnvironment",
id: `deploymentEnvironment:${label}`,
label,
value,
};
}
if (grouping === "accessZone") {
const value = String(data.accessZone || "").trim();
if (!value) return null;
return {
field: "accessZone",
id: `accessZone:${value}`,
label: value,
value,
};
}
return null;
}; };
const renderedNodeBox = (node) => { const renderedNodeBox = (node) => {
@@ -1091,30 +1311,22 @@ def graph_explorer_page() -> str:
}; };
const collectZoneSummaries = () => { const collectZoneSummaries = () => {
const groups = new Map(); if (!cy) return new Map();
if (!cy) return groups; const visibleElements = cy.elements()
cy.elements().filter((element) => element.style("display") !== "none").forEach((element) => { .filter((element) => element.style("display") !== "none")
const zone = zoneForData(element.data()); .toArray();
if (!zone) return; return resolveZoneInstances(
if (!groups.has(zone.id)) { visibleElements,
groups.set(zone.id, {...zone, nodes: [], elements: []}); zoneDefinitionsForElements(visibleElements, activeZoneGrouping)
} );
const group = groups.get(zone.id);
group.elements.push(element);
if (element.isNode()) group.nodes.push(element);
});
return groups;
}; };
const zoneCountsForField = (elements, field) => { const zoneCountsForField = (elements, field) => {
if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field); if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field);
const groups = new Map(); const zones = resolveZoneInstances(elements, zoneDefinitionsForElements(elements, field));
elements.forEach((element) => { return Array.from(zones.values())
const zone = zoneForData(element.data(), field); .filter((zone) => zone.elements.length > 0)
if (!zone) return; .map((zone) => [zone.label, zone.elements.length])
groups.set(zone.label, (groups.get(zone.label) || 0) + 1);
});
return Array.from(groups.entries())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
}; };
@@ -1136,6 +1348,8 @@ def graph_explorer_page() -> str:
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) => .flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
`${elementLabel(element)}: ${warning}` `${elementLabel(element)}: ${warning}`
)); ));
const diagnostics = (zone.diagnostics || [])
.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`);
const rows = [ const rows = [
["visible nodes", String(visibleNodes.length)], ["visible nodes", String(visibleNodes.length)],
["deployment environments", valuesFor("deploymentEnvironment")], ["deployment environments", valuesFor("deploymentEnvironment")],
@@ -1143,12 +1357,14 @@ def graph_explorer_page() -> str:
["access zones", valuesFor("accessZone")], ["access zones", valuesFor("accessZone")],
["routing authorities", valuesFor("routingAuthority")], ["routing authorities", valuesFor("routingAuthority")],
["policy authorities", valuesFor("policyAuthority")], ["policy authorities", valuesFor("policyAuthority")],
...diagnostics.slice(0, 8).map((diagnostic) => ["diagnostic", diagnostic]),
...warnings.slice(0, 8).map((warning) => ["warning", warning]), ...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`]); if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
detailList.innerHTML = rows detailList.innerHTML = rows
.filter(([, value]) => value) .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(""); .join("");
const contextIds = new Set(visibleNodes.map((node) => node.id())); const contextIds = new Set(visibleNodes.map((node) => node.id()));
@@ -1161,6 +1377,7 @@ def graph_explorer_page() -> str:
orientationList.innerHTML = [ orientationList.innerHTML = [
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"}, {label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
{label: "zone", value: zone.label}, {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"}, {label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
].map((row) => ` ].map((row) => `
<li class="orientation-item ${orientationStateClass(row.state)}"> <li class="orientation-item ${orientationStateClass(row.state)}">

View File

@@ -332,6 +332,7 @@ def resolve_zones(
edge_records, edge_records,
enabled_definitions, enabled_definitions,
seed_node_ids_by_zone_id, seed_node_ids_by_zone_id,
diagnostics,
) )
for candidate in attraction_candidates: for candidate in attraction_candidates:
candidates_by_node_id[candidate.node_id].append(candidate) 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) internal_edge_ids_by_zone_id[source_zone_id].append(edge.id)
continue continue
if source_zone_id or target_zone_id: 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( boundary_edges.append(
ZoneBoundaryEdge( ZoneBoundaryEdge(
edge_id=edge.id, edge_id=edge.id,
@@ -468,6 +479,7 @@ def _attraction_candidates(
edge_records: tuple[_EdgeRecord, ...], edge_records: tuple[_EdgeRecord, ...],
enabled_definitions: tuple[tuple[int, ZoneDefinition], ...], enabled_definitions: tuple[tuple[int, ZoneDefinition], ...],
seed_node_ids_by_zone_id: Mapping[str, set[str]], seed_node_ids_by_zone_id: Mapping[str, set[str]],
diagnostics: list[ZoneDiagnostic],
) -> list[_Candidate]: ) -> list[_Candidate]:
nodes_by_id = {node.id: node for node in node_records} nodes_by_id = {node.id: node for node in node_records}
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list) adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
@@ -476,6 +488,7 @@ def _attraction_candidates(
adjacency[edge.target].append(edge) adjacency[edge.target].append(edge)
candidates: dict[tuple[str, str], _Candidate] = {} candidates: dict[tuple[str, str], _Candidate] = {}
depth_limit_diagnostics: set[tuple[str, str]] = set()
for definition_order, definition in enabled_definitions: for definition_order, definition in enabled_definitions:
seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set()) seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set())
if not seed_node_ids: if not seed_node_ids:
@@ -486,6 +499,27 @@ def _attraction_candidates(
while queue: while queue:
node_id, depth = queue.popleft() node_id, depth = queue.popleft()
if depth >= rule.depth: 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 continue
for edge in adjacency.get(node_id, []): for edge in adjacency.get(node_id, []):
if not _edge_matches_attraction_rule(edge, rule): 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)) 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, ...]: def _node_records(nodes: Iterable[Mapping[str, Any]]) -> tuple[_NodeRecord, ...]:
records: list[_NodeRecord] = [] records: list[_NodeRecord] = []
for order, element in enumerate(nodes): for order, element in enumerate(nodes):

View File

@@ -415,6 +415,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "ruleRemovalSignature" in page assert "ruleRemovalSignature" in page
assert "zoneModeFields" in page assert "zoneModeFields" in page
assert "zoneForData" in page 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 "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page assert "renderZoneOverlay" in page
assert "renderZoneDetails" in page assert "renderZoneDetails" 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].edge_id == "edge.prod-test"
assert resolution.boundary_edges[0].source_zone_id == "prod" assert resolution.boundary_edges[0].source_zone_id == "prod"
assert resolution.boundary_edges[0].target_zone_id == "test" 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: 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 "far" not in resolution.node_assignments
assert resolution.zone_by_id("prod").internal_edge_ids == ("edge.seed-near",) 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 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: 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["zones"][0]["id"] == "prod"
assert serialized["node_assignments"]["svc"]["zone_id"] == "prod" assert serialized["node_assignments"]["svc"]["zone_id"] == "prod"
assert serialized["node_assignments"]["svc"]["reason"] == "seed" 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

@@ -95,7 +95,7 @@ summaries, and conflict diagnostics. Covered the core behavior in
```task ```task
id: RAIL-FAB-WP-0022-T03 id: RAIL-FAB-WP-0022-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "c89d8d53-72a4-4590-a0e5-67b012c3550c" state_hub_task_id: "c89d8d53-72a4-4590-a0e5-67b012c3550c"
``` ```
@@ -114,11 +114,20 @@ The current operator-facing behavior should remain available:
Expected result: existing graph explorer tests continue to pass, with new tests Expected result: existing graph explorer tests continue to pass, with new tests
showing that the UI obtains zone rectangles from resolved zone instances. showing that the UI obtains zone rectangles from resolved zone instances.
Result: Refactored the graph explorer overlay to use explicit default zone
definitions and a client-side `resolveZoneInstances()` path. Deployment
environment zones now resolve through declarative membership rules with
`deploymentEnvironment` normalization for `dev` -> `dev-tegwick`,
`test`/`staging` -> `test`, `prod` -> `prod`, and ignored `all` values. Access
zone overlays are generated as dynamic definitions from visible graph evidence.
The overlay keeps the single-zone visible-node assignment behavior while
preserving edge evidence in zone details.
## Task 4: Add Zone Diagnostics To The Explorer ## Task 4: Add Zone Diagnostics To The Explorer
```task ```task
id: RAIL-FAB-WP-0022-T04 id: RAIL-FAB-WP-0022-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9" state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9"
``` ```
@@ -137,6 +146,12 @@ Diagnostics should include at least:
Expected result: zone detail panels show scoped diagnostics, and tests verify Expected result: zone detail panels show scoped diagnostics, and tests verify
that diagnostics are generated by the resolver rather than ad hoc UI checks. 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 5: Persist Zone View Settings In Profiles
```task ```task