packet-tracer-skill 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +424 -42
  2. package/README.md +535 -250
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/automation-controller-proof.md +35 -0
  6. package/docs/curated-donor-registry.md +11 -0
  7. package/docs/generate-ready-pilot-design.md +30 -0
  8. package/docs/github-launch-ops-0.2.3.md +37 -0
  9. package/docs/github-metadata.md +6 -4
  10. package/docs/hero-demo-plan.md +1 -1
  11. package/docs/home-iot-donor-proof.md +4 -4
  12. package/docs/industrial-programming-proof.md +48 -0
  13. package/docs/ipv4-routing-management-proof.md +37 -0
  14. package/docs/l2-resiliency-bgp-proof.md +60 -0
  15. package/docs/l2-security-qos-proof.md +59 -0
  16. package/docs/packet-tracer-feature-gap-atlas.md +174 -17
  17. package/docs/post-launch-follow-up.md +9 -5
  18. package/docs/proof-readiness-dashboard.md +69 -0
  19. package/docs/publish-preview-roadmap.md +6 -5
  20. package/docs/release-checklist.md +27 -13
  21. package/docs/release-notes-0.2.2.md +1 -1
  22. package/docs/release-notes-0.2.3.md +59 -0
  23. package/docs/release-notes-0.2.4.md +20 -0
  24. package/docs/runtime-truth.md +33 -8
  25. package/docs/security-edge-deepening-proof.md +65 -0
  26. package/docs/voice-collaboration-proof.md +38 -0
  27. package/docs/wan-security-donor-proof.md +20 -3
  28. package/examples/README.md +98 -69
  29. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  30. package/examples/gallery.md +94 -6
  31. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  32. package/examples/index.json +932 -4
  33. package/examples/local-sample-evidence.json +24 -0
  34. package/examples/proof-cards.json +117 -0
  35. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  36. package/package.json +60 -44
  37. package/pytest.ini +9 -0
  38. package/references/packettracer-feature-atlas.json +67 -17
  39. package/references/packettracer-sample-catalog.json +45287 -4525
  40. package/references/packettracer-sample-catalog.md +599 -259
  41. package/references/proof-readiness-candidates.json +352 -0
  42. package/scripts/build_examples_index.py +228 -35
  43. package/scripts/build_sample_catalog.py +24 -44
  44. package/scripts/corpus_runner.py +430 -0
  45. package/scripts/coverage_matrix.py +1842 -1319
  46. package/scripts/donor_cache.py +354 -0
  47. package/scripts/donor_diagnostics.py +3 -1
  48. package/scripts/feature_atlas.py +65 -1
  49. package/scripts/generate_pkt.py +8762 -4070
  50. package/scripts/intent_parser.py +2242 -1138
  51. package/scripts/local_donors.py +340 -0
  52. package/scripts/packet_tracer_env.py +846 -391
  53. package/scripts/pkt_annotate.py +218 -0
  54. package/scripts/pkt_codec.py +420 -181
  55. package/scripts/pkt_editor.py +2405 -1226
  56. package/scripts/pkt_transformer.py +1072 -727
  57. package/scripts/pkt_verify.py +461 -0
  58. package/scripts/remote_search.py +197 -21
  59. package/scripts/runtime_doctor.py +80 -29
  60. package/scripts/sample_catalog.py +1372 -1195
  61. package/scripts/twofish_diagnostics.py +48 -31
  62. package/scripts/usage_ledger.py +218 -0
  63. package/scripts/vendor/README.md +44 -37
  64. package/scripts/vendor/twofish_pure.py +321 -0
  65. package/scripts/workspace_repair.py +548 -508
  66. package/templates/pt900/donors/README.md +15 -0
@@ -1,508 +1,548 @@
1
- from __future__ import annotations
2
-
3
- import copy
4
- from dataclasses import dataclass
5
- import xml.etree.ElementTree as ET
6
-
7
- PHYSICAL_HOME_PATH = ["Intercity", "Home City", "Corporate Office", "Main Wiring Closet"]
8
-
9
-
10
- @dataclass
11
- class WorkspaceValidationResult:
12
- workspace_mode: str
13
- logical_status: str
14
- physical_status: str
15
- blocking_issues: list[str]
16
-
17
-
18
- @dataclass
19
- class DonorCoherenceResult:
20
- device_metadata_status: str
21
- scenario_status: str
22
- physical_runtime_status: str
23
- visual_runtime_status: str
24
- blocking_issues: list[str]
25
-
26
-
27
- def _make_workspace_node(name: str, node_type: int, x_pos: int, y_pos: int, width: int, height: int, path: str = "") -> ET.Element:
28
- node = ET.Element("NODE")
29
- for tag, value in [
30
- ("X", str(x_pos)),
31
- ("Y", str(y_pos)),
32
- ("TYPE", str(node_type)),
33
- ]:
34
- child = ET.SubElement(node, tag)
35
- child.text = value
36
- name_node = ET.SubElement(node, "NAME")
37
- name_node.set("translate", "true")
38
- name_node.text = name
39
- for tag, value in [
40
- ("SX", "1"),
41
- ("SY", "1"),
42
- ("W", str(width)),
43
- ("H", str(height)),
44
- ("PATH", path),
45
- ]:
46
- child = ET.SubElement(node, tag)
47
- child.text = value
48
- ET.SubElement(node, "CHILDREN")
49
- for tag, value in [
50
- ("MANUAL_SCALING", "false"),
51
- ("SCALED_PIXMAP_WIDTH", "-842150451"),
52
- ("SCALED_PIXMAP_HEIGHT", "-842150451"),
53
- ("INIT_WIDTH", str(width)),
54
- ("INIT_HEIGHT", str(height)),
55
- ("INIT_SX", "1"),
56
- ("INIT_SY", "1"),
57
- ]:
58
- child = ET.SubElement(node, tag)
59
- child.text = value
60
- return node
61
-
62
-
63
- def _build_minimal_physical_workspace() -> tuple[ET.Element, ET.Element, ET.Element]:
64
- physical = ET.Element("PHYSICALWORKSPACE")
65
- homerack = ET.SubElement(physical, "HOMERACK")
66
- homerack.set("translate", "true")
67
- homerack.text = ",".join(PHYSICAL_HOME_PATH)
68
-
69
- intercity = _make_workspace_node("Intercity", 0, 0, 0, 20000, 12416, "../art/Background/gGeoViewInterCity.png")
70
- city = _make_workspace_node("Home City", 1, 200, 200, 2000, 1238, "../art/Background/gGeoViewCity.png")
71
- building = _make_workspace_node("Corporate Office", 2, 100, 100, 200, 125, "../art/Background/gGeoViewBuilding.png")
72
- closet = _make_workspace_node("Main Wiring Closet", 3, 50, 50, 20, 20, "")
73
- table = _make_workspace_node("Table", 5, 0, 0, 0, 0, "")
74
- rack = _make_workspace_node("Rack", 4, 0, 0, 0, 0, "")
75
-
76
- intercity.find("./CHILDREN").append(city)
77
- city.find("./CHILDREN").append(building)
78
- building.find("./CHILDREN").append(closet)
79
- closet.find("./CHILDREN").extend([table, rack])
80
- physical.append(intercity)
81
- return physical, table, rack
82
-
83
-
84
- def _closet_node_from_physical_root(physical_root: ET.Element) -> ET.Element | None:
85
- path = "./NODE/CHILDREN/NODE/CHILDREN/NODE/CHILDREN/NODE"
86
- closet = physical_root.find(path)
87
- if closet is not None and closet.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
88
- return closet
89
- for node in physical_root.findall(".//NODE"):
90
- if node.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
91
- return node
92
- return None
93
-
94
-
95
- def _container_node(closet: ET.Element, container_name: str, node_type: int) -> ET.Element:
96
- children = closet.find("./CHILDREN")
97
- if children is None:
98
- children = ET.SubElement(closet, "CHILDREN")
99
- for node in children.findall("./NODE"):
100
- if node.findtext("./NAME", default="") == container_name:
101
- target = node.find("./TYPE")
102
- if target is not None:
103
- target.text = str(node_type)
104
- return node
105
- node = _make_workspace_node(container_name, node_type, 0, 0, 0, 0, "")
106
- children.append(node)
107
- return node
108
-
109
-
110
- def _leaf_template(container: ET.Element, default_name: str) -> ET.Element:
111
- for node in container.findall("./CHILDREN/NODE"):
112
- if node.findtext("./TYPE", default="") == "6":
113
- return copy.deepcopy(node)
114
- return _make_workspace_node(default_name, 6, 0, 0, 0, 0, "")
115
-
116
-
117
- def _clone_leaf(template: ET.Element, name: str, index: int) -> ET.Element:
118
- leaf = copy.deepcopy(template)
119
- for child in list(leaf):
120
- if child.tag == "CHILDREN":
121
- child.clear()
122
- name_node = leaf.find("./NAME")
123
- if name_node is None:
124
- name_node = ET.SubElement(leaf, "NAME")
125
- name_node.set("translate", "true")
126
- name_node.text = name
127
- x_node = leaf.find("./X")
128
- y_node = leaf.find("./Y")
129
- if x_node is None:
130
- x_node = ET.SubElement(leaf, "X")
131
- if y_node is None:
132
- y_node = ET.SubElement(leaf, "Y")
133
- x_node.text = str((index % 8) * 8)
134
- y_node.text = str((index // 8) * 8)
135
- return leaf
136
-
137
-
138
- def _physical_leaf_path(device_name: str, container_name: str) -> str:
139
- return ",".join([*PHYSICAL_HOME_PATH, container_name, device_name])
140
-
141
-
142
- def _device_physical_container(device: ET.Element) -> str:
143
- device_type = device.findtext("./ENGINE/TYPE", default="")
144
- if device_type in {"Router", "Switch", "MultiLayerSwitch", "Server", "WirelessRouter", "WirelessLanController", "LightWeightAccessPoint"}:
145
- return "Rack"
146
- return "Table"
147
-
148
-
149
- def sanitize_generated_physical_workspace(root: ET.Element) -> None:
150
- devices_parent = root.find(".//DEVICES")
151
- if devices_parent is None:
152
- return
153
- version = root.findtext("./VERSION", default="")
154
- if version.startswith("9.") and any(device.find("./WORKSPACE/PHYSICAL_CPUR") is not None for device in devices_parent.findall("./DEVICE")):
155
- return
156
-
157
- physical_root = root.find("./PHYSICALWORKSPACE")
158
- if physical_root is None:
159
- physical_root, _, _ = _build_minimal_physical_workspace()
160
- insert_at = 1 if root.find("./VERSION") is not None else 0
161
- root.insert(insert_at, physical_root)
162
-
163
- closet = _closet_node_from_physical_root(physical_root)
164
- if closet is None:
165
- replacement_root, _, _ = _build_minimal_physical_workspace()
166
- existing = root.find("./PHYSICALWORKSPACE")
167
- if existing is not None:
168
- root.remove(existing)
169
- insert_at = 1 if root.find("./VERSION") is not None else 0
170
- root.insert(insert_at, replacement_root)
171
- physical_root = replacement_root
172
- closet = _closet_node_from_physical_root(physical_root)
173
- if closet is None:
174
- return
175
-
176
- table = _container_node(closet, "Table", 5)
177
- rack = _container_node(closet, "Rack", 4)
178
- table_template = _leaf_template(table, "PC0")
179
- rack_template = _leaf_template(rack, "Server0")
180
- table_children = table.find("./CHILDREN")
181
- rack_children = rack.find("./CHILDREN")
182
- assert table_children is not None and rack_children is not None
183
- table_children.clear()
184
- rack_children.clear()
185
-
186
- for index, device in enumerate(devices_parent.findall("./DEVICE"), start=1):
187
- name = device.findtext("./ENGINE/NAME", default=f"Device{index}")
188
- container_name = _device_physical_container(device)
189
- template = rack_template if container_name == "Rack" else table_template
190
- leaf = _clone_leaf(template, name, index - 1)
191
- if container_name == "Rack":
192
- rack_children.append(leaf)
193
- else:
194
- table_children.append(leaf)
195
-
196
- workspace = device.find("./WORKSPACE")
197
- if workspace is None:
198
- workspace = ET.SubElement(device, "WORKSPACE")
199
- logical = workspace.find("./LOGICAL")
200
- logical_copy = ET.fromstring(ET.tostring(logical, encoding="unicode")) if logical is not None else ET.Element("LOGICAL")
201
- for child in list(workspace):
202
- workspace.remove(child)
203
- workspace.append(logical_copy)
204
- physical = ET.SubElement(workspace, "PHYSICAL")
205
- physical.set("translate", "true")
206
- physical.text = _physical_leaf_path(name, container_name)
207
-
208
-
209
- def _collect_physical_paths(root: ET.Element) -> set[str]:
210
- physical_root = root.find("./PHYSICALWORKSPACE")
211
- if physical_root is None:
212
- return set()
213
- paths: set[str] = set()
214
- for top in physical_root.findall("./NODE"):
215
- _walk_physical_node(top, [], paths)
216
- return paths
217
-
218
-
219
- def _walk_physical_node(node: ET.Element, ancestors: list[str], sink: set[str]) -> None:
220
- name = node.findtext("./NAME", default="")
221
- if not name:
222
- return
223
- current = [*ancestors, name]
224
- sink.add(",".join(current))
225
- for child in node.findall("./CHILDREN/NODE"):
226
- _walk_physical_node(child, current, sink)
227
-
228
-
229
- def inspect_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
230
- issues: list[str] = []
231
- if root.tag != "PACKETTRACER5":
232
- issues.append("Root element must be PACKETTRACER5")
233
- if root.find("VERSION") is None:
234
- issues.append("Packet Tracer XML is missing VERSION")
235
-
236
- devices_parent = root.find(".//DEVICES")
237
- links_parent = root.find(".//LINKS")
238
- if devices_parent is None or links_parent is None:
239
- issues.append("Packet Tracer XML is missing DEVICES or LINKS container")
240
- return WorkspaceValidationResult("unknown", "invalid", "invalid", issues)
241
-
242
- devices = devices_parent.findall("./DEVICE")
243
- links = links_parent.findall("./LINK")
244
- if not devices:
245
- issues.append("No devices remain in generated Packet Tracer XML")
246
-
247
- logical_mem_addrs: list[str] = []
248
- save_ref_ids: list[str] = []
249
- for device in devices:
250
- if device.find("./WORKSPACE/LOGICAL/X") is None or device.find("./WORKSPACE/LOGICAL/Y") is None:
251
- issues.append("Device is missing logical workspace coordinates")
252
- mem_addr = device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")
253
- if not mem_addr:
254
- issues.append("Device is missing logical workspace MEM_ADDR")
255
- else:
256
- logical_mem_addrs.append(mem_addr)
257
- save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
258
- if save_ref:
259
- save_ref_ids.append(save_ref)
260
-
261
- device_count = len(devices)
262
- for link in links:
263
- cable = link.find("./CABLE")
264
- if cable is None:
265
- issues.append("Link is missing CABLE node")
266
- continue
267
- from_idx = cable.findtext("FROM", default="")
268
- to_idx = cable.findtext("TO", default="")
269
- if not from_idx or not to_idx:
270
- issues.append("Link is missing FROM or TO index")
271
- continue
272
- if from_idx.isdigit() and to_idx.isdigit():
273
- if int(from_idx) >= device_count or int(to_idx) >= device_count:
274
- issues.append("Link references a device index outside the DEVICES list")
275
- elif save_ref_ids:
276
- if from_idx not in save_ref_ids or to_idx not in save_ref_ids:
277
- issues.append("Link references device SAVE_REF_ID values not present in DEVICES")
278
- else:
279
- issues.append("Link FROM or TO index is not numeric")
280
- continue
281
-
282
- ports = [port.text or "" for port in cable.findall("PORT")]
283
- if len(ports) < 2 or not ports[0] or not ports[1]:
284
- issues.append("Link is missing endpoint port names")
285
-
286
- from_device_mem = cable.findtext("FROM_DEVICE_MEM_ADDR", default="")
287
- to_device_mem = cable.findtext("TO_DEVICE_MEM_ADDR", default="")
288
- if not from_device_mem or not to_device_mem:
289
- issues.append("Link is missing device MEM_ADDR references")
290
- elif from_device_mem not in logical_mem_addrs or to_device_mem not in logical_mem_addrs:
291
- issues.append("Link device MEM_ADDR references do not match device workspace records")
292
-
293
- physical_paths = _collect_physical_paths(root)
294
- if not physical_paths:
295
- issues.append("Packet Tracer XML is missing PHYSICALWORKSPACE tree")
296
- all_physical_values = [device.findtext("./WORKSPACE/PHYSICAL", default="") for device in devices]
297
- legacy_uuid_physical = any("{" in value and "}" in value for value in all_physical_values if value)
298
- for device in devices:
299
- physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
300
- if not physical:
301
- issues.append("Device is missing physical workspace path")
302
- continue
303
- if not legacy_uuid_physical and physical not in physical_paths:
304
- issues.append(f"Device physical path does not exist in PHYSICALWORKSPACE: {physical}")
305
-
306
- if all(device.find("./WORKSPACE/PHYSICAL_CPUR") is None for device in devices):
307
- workspace_mode = "logical_only_safe"
308
- elif legacy_uuid_physical:
309
- workspace_mode = "legacy_uuid_physical"
310
- else:
311
- workspace_mode = "mixed_physical"
312
- logical_status = "ok" if not any("logical" in issue.lower() or "mem_addr" in issue.lower() for issue in issues) else "invalid"
313
- physical_status = "ok" if not any("physical" in issue.lower() for issue in issues) else "invalid"
314
- return WorkspaceValidationResult(workspace_mode, logical_status, physical_status, issues)
315
-
316
-
317
- def validate_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
318
- result = inspect_workspace_integrity(root)
319
- if result.blocking_issues:
320
- raise ValueError("; ".join(result.blocking_issues))
321
- return result
322
-
323
-
324
- def _section_xml(root: ET.Element, path: str) -> str:
325
- node = root.find(path)
326
- if node is None:
327
- return ""
328
- return ET.tostring(node, encoding="unicode")
329
-
330
-
331
- def _section_content_score(node: ET.Element | None) -> int:
332
- if node is None:
333
- return 0
334
- score = len(node.attrib)
335
- if (node.text or "").strip():
336
- score += 1
337
- score += sum(1 for _ in node.iter()) - 1
338
- return score
339
-
340
-
341
- def _section_content_score_many(nodes: list[ET.Element]) -> int:
342
- return sum(_section_content_score(node) for node in nodes)
343
-
344
-
345
- def _device_by_save_ref(root: ET.Element) -> dict[str, ET.Element]:
346
- devices: dict[str, ET.Element] = {}
347
- for device in root.findall(".//DEVICES/DEVICE"):
348
- save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="").strip()
349
- if save_ref:
350
- devices[save_ref] = device
351
- return devices
352
-
353
-
354
- def _device_leaf_uuid(device: ET.Element) -> str:
355
- physical = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
356
- if not physical:
357
- return ""
358
- token = physical.split(",")[-1].strip()
359
- if token.startswith("{") and token.endswith("}"):
360
- return token
361
- return ""
362
-
363
-
364
- def _physical_leaf_index(root: ET.Element) -> dict[str, ET.Element]:
365
- nodes: dict[str, ET.Element] = {}
366
- for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
367
- uuid = node.findtext("./UUID_STR", default="").strip()
368
- if uuid:
369
- nodes[uuid] = node
370
- return nodes
371
-
372
-
373
- def _config_hostname_hits(device: ET.Element, old_name: str) -> bool:
374
- target = f"hostname {old_name}"
375
- for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
376
- if (line.text or "").strip() == target:
377
- return True
378
- for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
379
- if (line.text or "").strip() == target:
380
- return True
381
- for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
382
- if (line.text or "").strip() == target:
383
- return True
384
- return False
385
-
386
-
387
- def inspect_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
388
- issues: list[str] = []
389
- donor_devices = _device_by_save_ref(donor_root)
390
- generated_devices = _device_by_save_ref(generated_root)
391
-
392
- scenario_sections = {
393
- "SCENARIOSET": _section_xml(generated_root, "./SCENARIOSET"),
394
- "COMMAND_LOGS": _section_xml(generated_root, "./COMMAND_LOGS"),
395
- "CEPS": _section_xml(generated_root, "./CEPS"),
396
- "FILTERS": _section_xml(generated_root, "./FILTERS"),
397
- }
398
- physical_sections = {
399
- "PHYSICALWORKSPACE": _section_xml(generated_root, "./PHYSICALWORKSPACE"),
400
- "GEOVIEW_GRAPHICSITEMS": _section_xml(generated_root, "./GEOVIEW_GRAPHICSITEMS"),
401
- "CLUSTERS": _section_xml(generated_root, "./CLUSTERS"),
402
- }
403
- global_visual_paths = {
404
- "FILTERS": "./FILTERS",
405
- "CLUSTERS": "./CLUSTERS",
406
- "GEOVIEW_GRAPHICSITEMS": "./GEOVIEW_GRAPHICSITEMS",
407
- "RECTANGLES": "./RECTANGLES",
408
- "ELLIPSES": "./ELLIPSES",
409
- "POLYGONS": "./POLYGONS",
410
- }
411
- preserved_scenario_paths = {
412
- "SCENARIOSET": "./SCENARIOSET",
413
- "COMMAND_LOGS": "./COMMAND_LOGS",
414
- "CEPS": "./CEPS",
415
- }
416
- for section_name, path in preserved_scenario_paths.items():
417
- donor_score = _section_content_score(donor_root.find(path))
418
- generated_score = _section_content_score(generated_root.find(path))
419
- if donor_score > 0 and generated_score == 0:
420
- issues.append(f"Generated file unexpectedly emptied donor runtime section {section_name}")
421
- for section_name, path in global_visual_paths.items():
422
- donor_score = _section_content_score(donor_root.find(path))
423
- generated_score = _section_content_score(generated_root.find(path))
424
- if donor_score > 0 and generated_score == 0:
425
- issues.append(f"Generated file unexpectedly emptied donor visual section {section_name}")
426
-
427
- donor_notes_score = _section_content_score_many(donor_root.findall("./PHYSICALWORKSPACE//NOTES"))
428
- generated_notes_score = _section_content_score_many(generated_root.findall("./PHYSICALWORKSPACE//NOTES"))
429
- if donor_notes_score > 0 and generated_notes_score == 0:
430
- issues.append("Generated file unexpectedly emptied donor visual section PHYSICALWORKSPACE/NOTES")
431
-
432
- for tag in ["ANSWER_TREE_SELECTED", "PHYSICALALIGN", "HIDEPHYSICAL", "CABLE_POPUP_IN_PHYSICAL"]:
433
- donor_value = donor_root.findtext(f"./{tag}", default="").strip()
434
- generated_value = generated_root.findtext(f"./{tag}", default="").strip()
435
- if donor_value != generated_value:
436
- issues.append(f"Generated file changed donor physical view state {tag} from {donor_value} to {generated_value}")
437
-
438
- pruned_ids = sorted(set(donor_devices) - set(generated_devices))
439
- for save_ref in pruned_ids:
440
- donor_device = donor_devices[save_ref]
441
- donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
442
- original_uuid = donor_device.findtext(".//ORIGINAL_DEVICE_UUID", default="").strip()
443
- physical_uuid = _device_leaf_uuid(donor_device)
444
- for section_name, text in scenario_sections.items():
445
- for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid)]:
446
- if value and value in text:
447
- issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
448
- for section_name, text in physical_sections.items():
449
- for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid), ("physical-leaf", physical_uuid)]:
450
- if value and value in text:
451
- issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
452
-
453
- physical_index = _physical_leaf_index(generated_root)
454
- for save_ref in sorted(set(donor_devices) & set(generated_devices)):
455
- donor_device = donor_devices[save_ref]
456
- generated_device = generated_devices[save_ref]
457
- donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
458
- generated_name = generated_device.findtext("./ENGINE/NAME", default="").strip()
459
- sys_name = generated_device.findtext("./ENGINE/SYS_NAME", default="").strip()
460
- if donor_name != generated_name and sys_name == donor_name:
461
- issues.append(f"Renamed device {generated_name} still keeps donor SYS_NAME {donor_name}")
462
- if donor_name != generated_name and _config_hostname_hits(generated_device, donor_name):
463
- issues.append(f"Renamed device {generated_name} still keeps donor hostname {donor_name} in config")
464
- leaf_uuid = _device_leaf_uuid(generated_device)
465
- if leaf_uuid:
466
- leaf = physical_index.get(leaf_uuid)
467
- if leaf is None:
468
- issues.append(f"Generated device {generated_name} references missing physical leaf {leaf_uuid}")
469
- else:
470
- leaf_name = leaf.findtext("./NAME", default="").strip()
471
- if leaf_name != generated_name:
472
- issues.append(f"Generated device {generated_name} physical leaf name is {leaf_name}")
473
-
474
- device_issues = [issue for issue in issues if "SYS_NAME" in issue or "hostname" in issue]
475
- scenario_issues = [issue for issue in issues if any(section in issue for section in ["SCENARIOSET", "COMMAND_LOGS", "CEPS"])]
476
- physical_issues = [issue for issue in issues if any(section in issue for section in ["PHYSICALWORKSPACE", "physical leaf"])]
477
- visual_issues = [
478
- issue
479
- for issue in issues
480
- if any(
481
- section in issue
482
- for section in [
483
- "FILTERS",
484
- "CLUSTERS",
485
- "GEOVIEW_GRAPHICSITEMS",
486
- "RECTANGLES",
487
- "ELLIPSES",
488
- "POLYGONS",
489
- "PHYSICALWORKSPACE/NOTES",
490
- "physical view state",
491
- ]
492
- )
493
- ]
494
-
495
- return DonorCoherenceResult(
496
- device_metadata_status="ok" if not device_issues else "invalid",
497
- scenario_status="ok" if not scenario_issues else "invalid",
498
- physical_runtime_status="ok" if not physical_issues else "invalid",
499
- visual_runtime_status="ok" if not visual_issues else "invalid",
500
- blocking_issues=issues,
501
- )
502
-
503
-
504
- def validate_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
505
- result = inspect_donor_coherence(donor_root, generated_root)
506
- if result.blocking_issues:
507
- raise ValueError("; ".join(result.blocking_issues))
508
- return result
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from dataclasses import dataclass
5
+ import xml.etree.ElementTree as ET
6
+
7
+ PHYSICAL_HOME_PATH = ["Intercity", "Home City", "Corporate Office", "Main Wiring Closet"]
8
+
9
+
10
+ @dataclass
11
+ class WorkspaceValidationResult:
12
+ workspace_mode: str
13
+ logical_status: str
14
+ physical_status: str
15
+ blocking_issues: list[str]
16
+
17
+
18
+ @dataclass
19
+ class DonorCoherenceResult:
20
+ device_metadata_status: str
21
+ scenario_status: str
22
+ physical_runtime_status: str
23
+ visual_runtime_status: str
24
+ blocking_issues: list[str]
25
+
26
+
27
+ def _make_workspace_node(name: str, node_type: int, x_pos: int, y_pos: int, width: int, height: int, path: str = "") -> ET.Element:
28
+ node = ET.Element("NODE")
29
+ for tag, value in [
30
+ ("X", str(x_pos)),
31
+ ("Y", str(y_pos)),
32
+ ("TYPE", str(node_type)),
33
+ ]:
34
+ child = ET.SubElement(node, tag)
35
+ child.text = value
36
+ name_node = ET.SubElement(node, "NAME")
37
+ name_node.set("translate", "true")
38
+ name_node.text = name
39
+ for tag, value in [
40
+ ("SX", "1"),
41
+ ("SY", "1"),
42
+ ("W", str(width)),
43
+ ("H", str(height)),
44
+ ("PATH", path),
45
+ ]:
46
+ child = ET.SubElement(node, tag)
47
+ child.text = value
48
+ ET.SubElement(node, "CHILDREN")
49
+ for tag, value in [
50
+ ("MANUAL_SCALING", "false"),
51
+ ("SCALED_PIXMAP_WIDTH", "-842150451"),
52
+ ("SCALED_PIXMAP_HEIGHT", "-842150451"),
53
+ ("INIT_WIDTH", str(width)),
54
+ ("INIT_HEIGHT", str(height)),
55
+ ("INIT_SX", "1"),
56
+ ("INIT_SY", "1"),
57
+ ]:
58
+ child = ET.SubElement(node, tag)
59
+ child.text = value
60
+ return node
61
+
62
+
63
+ def _build_minimal_physical_workspace() -> tuple[ET.Element, ET.Element, ET.Element]:
64
+ physical = ET.Element("PHYSICALWORKSPACE")
65
+ homerack = ET.SubElement(physical, "HOMERACK")
66
+ homerack.set("translate", "true")
67
+ homerack.text = ",".join(PHYSICAL_HOME_PATH)
68
+
69
+ intercity = _make_workspace_node("Intercity", 0, 0, 0, 20000, 12416, "../art/Background/gGeoViewInterCity.png")
70
+ city = _make_workspace_node("Home City", 1, 200, 200, 2000, 1238, "../art/Background/gGeoViewCity.png")
71
+ building = _make_workspace_node("Corporate Office", 2, 100, 100, 200, 125, "../art/Background/gGeoViewBuilding.png")
72
+ closet = _make_workspace_node("Main Wiring Closet", 3, 50, 50, 20, 20, "")
73
+ table = _make_workspace_node("Table", 5, 0, 0, 0, 0, "")
74
+ rack = _make_workspace_node("Rack", 4, 0, 0, 0, 0, "")
75
+
76
+ intercity.find("./CHILDREN").append(city)
77
+ city.find("./CHILDREN").append(building)
78
+ building.find("./CHILDREN").append(closet)
79
+ closet.find("./CHILDREN").extend([table, rack])
80
+ physical.append(intercity)
81
+ return physical, table, rack
82
+
83
+
84
+ def _closet_node_from_physical_root(physical_root: ET.Element) -> ET.Element | None:
85
+ path = "./NODE/CHILDREN/NODE/CHILDREN/NODE/CHILDREN/NODE"
86
+ closet = physical_root.find(path)
87
+ if closet is not None and closet.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
88
+ return closet
89
+ for node in physical_root.findall(".//NODE"):
90
+ if node.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
91
+ return node
92
+ return None
93
+
94
+
95
+ def _container_node(closet: ET.Element, container_name: str, node_type: int) -> ET.Element:
96
+ children = closet.find("./CHILDREN")
97
+ if children is None:
98
+ children = ET.SubElement(closet, "CHILDREN")
99
+ for node in children.findall("./NODE"):
100
+ if node.findtext("./NAME", default="") == container_name:
101
+ target = node.find("./TYPE")
102
+ if target is not None:
103
+ target.text = str(node_type)
104
+ return node
105
+ node = _make_workspace_node(container_name, node_type, 0, 0, 0, 0, "")
106
+ children.append(node)
107
+ return node
108
+
109
+
110
+ def _leaf_template(container: ET.Element, default_name: str) -> ET.Element:
111
+ for node in container.findall("./CHILDREN/NODE"):
112
+ if node.findtext("./TYPE", default="") == "6":
113
+ return copy.deepcopy(node)
114
+ return _make_workspace_node(default_name, 6, 0, 0, 0, 0, "")
115
+
116
+
117
+ def _clone_leaf(template: ET.Element, name: str, index: int) -> ET.Element:
118
+ leaf = copy.deepcopy(template)
119
+ for child in list(leaf):
120
+ if child.tag == "CHILDREN":
121
+ child.clear()
122
+ name_node = leaf.find("./NAME")
123
+ if name_node is None:
124
+ name_node = ET.SubElement(leaf, "NAME")
125
+ name_node.set("translate", "true")
126
+ name_node.text = name
127
+ x_node = leaf.find("./X")
128
+ y_node = leaf.find("./Y")
129
+ if x_node is None:
130
+ x_node = ET.SubElement(leaf, "X")
131
+ if y_node is None:
132
+ y_node = ET.SubElement(leaf, "Y")
133
+ x_node.text = str((index % 8) * 8)
134
+ y_node.text = str((index // 8) * 8)
135
+ return leaf
136
+
137
+
138
+ def _physical_leaf_path(device_name: str, container_name: str) -> str:
139
+ return ",".join([*PHYSICAL_HOME_PATH, container_name, device_name])
140
+
141
+
142
+ def _device_physical_container(device: ET.Element) -> str:
143
+ device_type = device.findtext("./ENGINE/TYPE", default="")
144
+ if device_type in {"Router", "Switch", "MultiLayerSwitch", "Server", "WirelessRouter", "WirelessLanController", "LightWeightAccessPoint"}:
145
+ return "Rack"
146
+ return "Table"
147
+
148
+
149
+ def sanitize_generated_physical_workspace(root: ET.Element) -> None:
150
+ devices_parent = root.find(".//DEVICES")
151
+ if devices_parent is None:
152
+ return
153
+ version = root.findtext("./VERSION", default="")
154
+ if version.startswith("9.") and any(device.find("./WORKSPACE/PHYSICAL_CPUR") is not None for device in devices_parent.findall("./DEVICE")):
155
+ return
156
+
157
+ physical_root = root.find("./PHYSICALWORKSPACE")
158
+ if physical_root is None:
159
+ physical_root, _, _ = _build_minimal_physical_workspace()
160
+ insert_at = 1 if root.find("./VERSION") is not None else 0
161
+ root.insert(insert_at, physical_root)
162
+
163
+ closet = _closet_node_from_physical_root(physical_root)
164
+ if closet is None:
165
+ replacement_root, _, _ = _build_minimal_physical_workspace()
166
+ existing = root.find("./PHYSICALWORKSPACE")
167
+ if existing is not None:
168
+ root.remove(existing)
169
+ insert_at = 1 if root.find("./VERSION") is not None else 0
170
+ root.insert(insert_at, replacement_root)
171
+ physical_root = replacement_root
172
+ closet = _closet_node_from_physical_root(physical_root)
173
+ if closet is None:
174
+ return
175
+
176
+ table = _container_node(closet, "Table", 5)
177
+ rack = _container_node(closet, "Rack", 4)
178
+ table_template = _leaf_template(table, "PC0")
179
+ rack_template = _leaf_template(rack, "Server0")
180
+ table_children = table.find("./CHILDREN")
181
+ rack_children = rack.find("./CHILDREN")
182
+ assert table_children is not None and rack_children is not None
183
+ table_children.clear()
184
+ rack_children.clear()
185
+
186
+ for index, device in enumerate(devices_parent.findall("./DEVICE"), start=1):
187
+ name = device.findtext("./ENGINE/NAME", default=f"Device{index}")
188
+ container_name = _device_physical_container(device)
189
+ template = rack_template if container_name == "Rack" else table_template
190
+ leaf = _clone_leaf(template, name, index - 1)
191
+ if container_name == "Rack":
192
+ rack_children.append(leaf)
193
+ else:
194
+ table_children.append(leaf)
195
+
196
+ workspace = device.find("./WORKSPACE")
197
+ if workspace is None:
198
+ workspace = ET.SubElement(device, "WORKSPACE")
199
+ logical = workspace.find("./LOGICAL")
200
+ logical_copy = ET.fromstring(ET.tostring(logical, encoding="unicode")) if logical is not None else ET.Element("LOGICAL")
201
+ for child in list(workspace):
202
+ workspace.remove(child)
203
+ workspace.append(logical_copy)
204
+ physical = ET.SubElement(workspace, "PHYSICAL")
205
+ physical.set("translate", "true")
206
+ physical.text = _physical_leaf_path(name, container_name)
207
+
208
+
209
+ def _collect_physical_paths(root: ET.Element) -> set[str]:
210
+ physical_root = root.find("./PHYSICALWORKSPACE")
211
+ if physical_root is None:
212
+ return set()
213
+ paths: set[str] = set()
214
+ for top in physical_root.findall("./NODE"):
215
+ _walk_physical_node(top, [], paths)
216
+ return paths
217
+
218
+
219
+ def _walk_physical_node(node: ET.Element, ancestors: list[str], sink: set[str]) -> None:
220
+ name = node.findtext("./NAME", default="")
221
+ if not name:
222
+ return
223
+ current = [*ancestors, name]
224
+ sink.add(",".join(current))
225
+ for child in node.findall("./CHILDREN/NODE"):
226
+ _walk_physical_node(child, current, sink)
227
+
228
+
229
+ def inspect_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
230
+ issues: list[str] = []
231
+ if root.tag != "PACKETTRACER5":
232
+ issues.append("Root element must be PACKETTRACER5")
233
+ if root.find("VERSION") is None:
234
+ issues.append("Packet Tracer XML is missing VERSION")
235
+
236
+ devices_parent = root.find(".//DEVICES")
237
+ links_parent = root.find(".//LINKS")
238
+ if devices_parent is None or links_parent is None:
239
+ issues.append("Packet Tracer XML is missing DEVICES or LINKS container")
240
+ return WorkspaceValidationResult("unknown", "invalid", "invalid", issues)
241
+
242
+ devices = devices_parent.findall("./DEVICE")
243
+ links = links_parent.findall("./LINK")
244
+ if not devices:
245
+ issues.append("No devices remain in generated Packet Tracer XML")
246
+
247
+ logical_mem_addrs: list[str] = []
248
+ save_ref_ids: list[str] = []
249
+ for device in devices:
250
+ if device.find("./WORKSPACE/LOGICAL/X") is None or device.find("./WORKSPACE/LOGICAL/Y") is None:
251
+ issues.append("Device is missing logical workspace coordinates")
252
+ mem_addr = device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")
253
+ if not mem_addr:
254
+ issues.append("Device is missing logical workspace MEM_ADDR")
255
+ else:
256
+ logical_mem_addrs.append(mem_addr)
257
+ save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
258
+ if save_ref:
259
+ save_ref_ids.append(save_ref)
260
+
261
+ device_count = len(devices)
262
+ for link in links:
263
+ cable = link.find("./CABLE")
264
+ if cable is None:
265
+ issues.append("Link is missing CABLE node")
266
+ continue
267
+ from_idx = cable.findtext("FROM", default="")
268
+ to_idx = cable.findtext("TO", default="")
269
+ if not from_idx or not to_idx:
270
+ issues.append("Link is missing FROM or TO index")
271
+ continue
272
+ if from_idx.isdigit() and to_idx.isdigit():
273
+ if int(from_idx) >= device_count or int(to_idx) >= device_count:
274
+ issues.append("Link references a device index outside the DEVICES list")
275
+ elif save_ref_ids:
276
+ missing = [ref for ref in (from_idx, to_idx) if ref not in save_ref_ids]
277
+ if missing:
278
+ # Naming the dangling reference matters: this message is what a
279
+ # failed generation reports, and "values not present" gave no
280
+ # way to tell which device went missing without re-running the
281
+ # whole plan under a debugger.
282
+ issues.append(
283
+ "Link references device SAVE_REF_ID values not present in DEVICES "
284
+ f"({', '.join(missing)})"
285
+ )
286
+ else:
287
+ issues.append("Link FROM or TO index is not numeric")
288
+ continue
289
+
290
+ ports = [port.text or "" for port in cable.findall("PORT")]
291
+ if len(ports) < 2 or not ports[0] or not ports[1]:
292
+ issues.append("Link is missing endpoint port names")
293
+
294
+ # A link may legitimately carry no device MEM_ADDR references. They are
295
+ # runtime pointers from the session that saved the file — in working
296
+ # donors they resolve to nothing — and a link built without them opens
297
+ # in Packet Tracer, while one built with invented values does not.
298
+ # Requiring them here made the repo reject files Packet Tracer accepts.
299
+ from_device_mem = cable.findtext("FROM_DEVICE_MEM_ADDR", default="")
300
+ to_device_mem = cable.findtext("TO_DEVICE_MEM_ADDR", default="")
301
+ if bool(from_device_mem) != bool(to_device_mem):
302
+ issues.append("Link has a device MEM_ADDR reference on only one end")
303
+ elif from_device_mem not in logical_mem_addrs or to_device_mem not in logical_mem_addrs:
304
+ issues.append("Link device MEM_ADDR references do not match device workspace records")
305
+
306
+ physical_paths = _collect_physical_paths(root)
307
+ if not physical_paths:
308
+ issues.append("Packet Tracer XML is missing PHYSICALWORKSPACE tree")
309
+ all_physical_values = [device.findtext("./WORKSPACE/PHYSICAL", default="") for device in devices]
310
+ legacy_uuid_physical = any("{" in value and "}" in value for value in all_physical_values if value)
311
+ for device in devices:
312
+ physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
313
+ if not physical:
314
+ issues.append("Device is missing physical workspace path")
315
+ continue
316
+ if not legacy_uuid_physical and physical not in physical_paths:
317
+ issues.append(f"Device physical path does not exist in PHYSICALWORKSPACE: {physical}")
318
+
319
+ if all(device.find("./WORKSPACE/PHYSICAL_CPUR") is None for device in devices):
320
+ workspace_mode = "logical_only_safe"
321
+ elif legacy_uuid_physical:
322
+ workspace_mode = "legacy_uuid_physical"
323
+ else:
324
+ workspace_mode = "mixed_physical"
325
+ logical_status = "ok" if not any("logical" in issue.lower() or "mem_addr" in issue.lower() for issue in issues) else "invalid"
326
+ physical_status = "ok" if not any("physical" in issue.lower() for issue in issues) else "invalid"
327
+ return WorkspaceValidationResult(workspace_mode, logical_status, physical_status, issues)
328
+
329
+
330
+ def validate_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
331
+ result = inspect_workspace_integrity(root)
332
+ if result.blocking_issues:
333
+ raise ValueError("; ".join(result.blocking_issues))
334
+ return result
335
+
336
+
337
+ def _section_xml(root: ET.Element, path: str) -> str:
338
+ node = root.find(path)
339
+ if node is None:
340
+ return ""
341
+ return ET.tostring(node, encoding="unicode")
342
+
343
+
344
+ def _section_content_score(node: ET.Element | None) -> int:
345
+ if node is None:
346
+ return 0
347
+ score = len(node.attrib)
348
+ if (node.text or "").strip():
349
+ score += 1
350
+ score += sum(1 for _ in node.iter()) - 1
351
+ return score
352
+
353
+
354
+ def _section_content_score_many(nodes: list[ET.Element]) -> int:
355
+ return sum(_section_content_score(node) for node in nodes)
356
+
357
+
358
+ def _device_by_save_ref(root: ET.Element) -> dict[str, ET.Element]:
359
+ devices: dict[str, ET.Element] = {}
360
+ for device in root.findall(".//DEVICES/DEVICE"):
361
+ save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="").strip()
362
+ if save_ref:
363
+ devices[save_ref] = device
364
+ return devices
365
+
366
+
367
+ def _device_leaf_uuid(device: ET.Element) -> str:
368
+ physical = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
369
+ if not physical:
370
+ return ""
371
+ token = physical.split(",")[-1].strip()
372
+ if token.startswith("{") and token.endswith("}"):
373
+ return token
374
+ return ""
375
+
376
+
377
+ def _physical_leaf_index(root: ET.Element) -> dict[str, ET.Element]:
378
+ nodes: dict[str, ET.Element] = {}
379
+ for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
380
+ uuid = node.findtext("./UUID_STR", default="").strip()
381
+ if uuid:
382
+ nodes[uuid] = node
383
+ return nodes
384
+
385
+
386
+ def _config_hostname_hits(device: ET.Element, old_name: str) -> bool:
387
+ target = f"hostname {old_name}"
388
+ for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
389
+ if (line.text or "").strip() == target:
390
+ return True
391
+ for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
392
+ if (line.text or "").strip() == target:
393
+ return True
394
+ for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
395
+ if (line.text or "").strip() == target:
396
+ return True
397
+ return False
398
+
399
+
400
+ def inspect_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
401
+ issues: list[str] = []
402
+ donor_devices = _device_by_save_ref(donor_root)
403
+ generated_devices = _device_by_save_ref(generated_root)
404
+
405
+ scenario_sections = {
406
+ "SCENARIOSET": _section_xml(generated_root, "./SCENARIOSET"),
407
+ "COMMAND_LOGS": _section_xml(generated_root, "./COMMAND_LOGS"),
408
+ "CEPS": _section_xml(generated_root, "./CEPS"),
409
+ "FILTERS": _section_xml(generated_root, "./FILTERS"),
410
+ }
411
+ physical_sections = {
412
+ "PHYSICALWORKSPACE": _section_xml(generated_root, "./PHYSICALWORKSPACE"),
413
+ "GEOVIEW_GRAPHICSITEMS": _section_xml(generated_root, "./GEOVIEW_GRAPHICSITEMS"),
414
+ "CLUSTERS": _section_xml(generated_root, "./CLUSTERS"),
415
+ }
416
+ global_visual_paths = {
417
+ "FILTERS": "./FILTERS",
418
+ "CLUSTERS": "./CLUSTERS",
419
+ "GEOVIEW_GRAPHICSITEMS": "./GEOVIEW_GRAPHICSITEMS",
420
+ "RECTANGLES": "./RECTANGLES",
421
+ "ELLIPSES": "./ELLIPSES",
422
+ "POLYGONS": "./POLYGONS",
423
+ }
424
+ preserved_scenario_paths = {
425
+ "SCENARIOSET": "./SCENARIOSET",
426
+ "COMMAND_LOGS": "./COMMAND_LOGS",
427
+ "CEPS": "./CEPS",
428
+ }
429
+ for section_name, path in preserved_scenario_paths.items():
430
+ donor_score = _section_content_score(donor_root.find(path))
431
+ generated_score = _section_content_score(generated_root.find(path))
432
+ if donor_score > 0 and generated_score == 0:
433
+ issues.append(f"Generated file unexpectedly emptied donor runtime section {section_name}")
434
+ for section_name, path in global_visual_paths.items():
435
+ donor_score = _section_content_score(donor_root.find(path))
436
+ generated_score = _section_content_score(generated_root.find(path))
437
+ if donor_score > 0 and generated_score == 0:
438
+ issues.append(f"Generated file unexpectedly emptied donor visual section {section_name}")
439
+
440
+ donor_notes_score = _section_content_score_many(donor_root.findall("./PHYSICALWORKSPACE//NOTES"))
441
+ generated_notes_score = _section_content_score_many(generated_root.findall("./PHYSICALWORKSPACE//NOTES"))
442
+ if donor_notes_score > 0 and generated_notes_score == 0:
443
+ issues.append("Generated file unexpectedly emptied donor visual section PHYSICALWORKSPACE/NOTES")
444
+
445
+ for tag in ["ANSWER_TREE_SELECTED", "PHYSICALALIGN", "HIDEPHYSICAL", "CABLE_POPUP_IN_PHYSICAL"]:
446
+ donor_value = donor_root.findtext(f"./{tag}", default="").strip()
447
+ generated_value = generated_root.findtext(f"./{tag}", default="").strip()
448
+ if donor_value != generated_value:
449
+ issues.append(f"Generated file changed donor physical view state {tag} from {donor_value} to {generated_value}")
450
+
451
+ # A pruned device's *name* is often reused: the donor's PC1 goes and the
452
+ # generated lab has a PC1 of its own, because that is what was asked for.
453
+ # The checks below are substring searches, so the reused name matched and a
454
+ # perfectly good donor was rejected -- which is why a VoIP lab carrying the
455
+ # analog phone a prompt asked for could never be used.
456
+ generated_names = {
457
+ device.findtext("./ENGINE/NAME", default="").strip()
458
+ for device in generated_devices.values()
459
+ }
460
+ pruned_ids = sorted(set(donor_devices) - set(generated_devices))
461
+ for save_ref in pruned_ids:
462
+ donor_device = donor_devices[save_ref]
463
+ donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
464
+ if donor_name in generated_names:
465
+ donor_name = ""
466
+ original_uuid = donor_device.findtext(".//ORIGINAL_DEVICE_UUID", default="").strip()
467
+ physical_uuid = _device_leaf_uuid(donor_device)
468
+ for section_name, text in scenario_sections.items():
469
+ for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid)]:
470
+ if _leftover_hit(label, value, text):
471
+ issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
472
+ for section_name, text in physical_sections.items():
473
+ for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid), ("physical-leaf", physical_uuid)]:
474
+ if _leftover_hit(label, value, text):
475
+ issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
476
+
477
+ physical_index = _physical_leaf_index(generated_root)
478
+ for save_ref in sorted(set(donor_devices) & set(generated_devices)):
479
+ donor_device = donor_devices[save_ref]
480
+ generated_device = generated_devices[save_ref]
481
+ donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
482
+ generated_name = generated_device.findtext("./ENGINE/NAME", default="").strip()
483
+ sys_name = generated_device.findtext("./ENGINE/SYS_NAME", default="").strip()
484
+ if donor_name != generated_name and sys_name == donor_name:
485
+ issues.append(f"Renamed device {generated_name} still keeps donor SYS_NAME {donor_name}")
486
+ if donor_name != generated_name and _config_hostname_hits(generated_device, donor_name):
487
+ issues.append(f"Renamed device {generated_name} still keeps donor hostname {donor_name} in config")
488
+ leaf_uuid = _device_leaf_uuid(generated_device)
489
+ if leaf_uuid:
490
+ leaf = physical_index.get(leaf_uuid)
491
+ if leaf is None:
492
+ issues.append(f"Generated device {generated_name} references missing physical leaf {leaf_uuid}")
493
+ else:
494
+ leaf_name = leaf.findtext("./NAME", default="").strip()
495
+ if leaf_name != generated_name:
496
+ issues.append(f"Generated device {generated_name} physical leaf name is {leaf_name}")
497
+
498
+ device_issues = [issue for issue in issues if "SYS_NAME" in issue or "hostname" in issue]
499
+ scenario_issues = [issue for issue in issues if any(section in issue for section in ["SCENARIOSET", "COMMAND_LOGS", "CEPS"])]
500
+ physical_issues = [issue for issue in issues if any(section in issue for section in ["PHYSICALWORKSPACE", "physical leaf"])]
501
+ visual_issues = [
502
+ issue
503
+ for issue in issues
504
+ if any(
505
+ section in issue
506
+ for section in [
507
+ "FILTERS",
508
+ "CLUSTERS",
509
+ "GEOVIEW_GRAPHICSITEMS",
510
+ "RECTANGLES",
511
+ "ELLIPSES",
512
+ "POLYGONS",
513
+ "PHYSICALWORKSPACE/NOTES",
514
+ "physical view state",
515
+ ]
516
+ )
517
+ ]
518
+
519
+ return DonorCoherenceResult(
520
+ device_metadata_status="ok" if not device_issues else "invalid",
521
+ scenario_status="ok" if not scenario_issues else "invalid",
522
+ physical_runtime_status="ok" if not physical_issues else "invalid",
523
+ visual_runtime_status="ok" if not visual_issues else "invalid",
524
+ blocking_issues=issues,
525
+ )
526
+
527
+
528
+ def _leftover_hit(label: str, value: str, text: str) -> bool:
529
+ """Whether `value` really names a leftover in a serialised section.
530
+
531
+ A device name has to match a whole element, not a substring. Measured on a
532
+ donor that carries both `Switch1` and `Multilayer Switch1`: pruning
533
+ `Switch1` was reported as a leftover because the string still occurs inside
534
+ the multilayer switch's name, and the donor was rejected for a device that
535
+ had gone. Ids are unique tokens, so they keep the plain search.
536
+ """
537
+ if not value:
538
+ return False
539
+ if label != "device-name":
540
+ return value in text
541
+ return f">{value}<" in text
542
+
543
+
544
+ def validate_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
545
+ result = inspect_donor_coherence(donor_root, generated_root)
546
+ if result.blocking_issues:
547
+ raise ValueError("; ".join(result.blocking_issues))
548
+ return result