packet-tracer-skill 0.2.3 → 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 (53) hide show
  1. package/CHANGELOG.md +424 -73
  2. package/README.md +557 -442
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/github-launch-ops-0.2.3.md +37 -0
  6. package/docs/github-metadata.md +6 -4
  7. package/docs/hero-demo-plan.md +1 -1
  8. package/docs/home-iot-donor-proof.md +4 -4
  9. package/docs/l2-security-qos-proof.md +1 -1
  10. package/docs/packet-tracer-feature-gap-atlas.md +4 -4
  11. package/docs/post-launch-follow-up.md +9 -5
  12. package/docs/proof-readiness-dashboard.md +69 -0
  13. package/docs/publish-preview-roadmap.md +6 -5
  14. package/docs/release-checklist.md +17 -8
  15. package/docs/release-notes-0.2.4.md +20 -0
  16. package/docs/runtime-truth.md +33 -8
  17. package/docs/security-edge-deepening-proof.md +1 -1
  18. package/examples/README.md +98 -69
  19. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  20. package/examples/gallery.md +94 -6
  21. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  22. package/examples/index.json +932 -4
  23. package/examples/local-sample-evidence.json +24 -0
  24. package/examples/proof-cards.json +117 -0
  25. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  26. package/package.json +60 -53
  27. package/pytest.ini +9 -0
  28. package/references/packettracer-sample-catalog.json +45287 -4525
  29. package/references/packettracer-sample-catalog.md +599 -259
  30. package/references/proof-readiness-candidates.json +352 -0
  31. package/scripts/build_examples_index.py +228 -35
  32. package/scripts/build_sample_catalog.py +24 -44
  33. package/scripts/corpus_runner.py +430 -0
  34. package/scripts/coverage_matrix.py +1842 -1812
  35. package/scripts/donor_cache.py +354 -0
  36. package/scripts/donor_diagnostics.py +3 -1
  37. package/scripts/generate_pkt.py +8762 -4228
  38. package/scripts/intent_parser.py +2242 -1657
  39. package/scripts/local_donors.py +340 -0
  40. package/scripts/packet_tracer_env.py +846 -391
  41. package/scripts/pkt_annotate.py +218 -0
  42. package/scripts/pkt_codec.py +420 -181
  43. package/scripts/pkt_editor.py +2405 -1703
  44. package/scripts/pkt_transformer.py +1072 -727
  45. package/scripts/pkt_verify.py +461 -0
  46. package/scripts/runtime_doctor.py +80 -29
  47. package/scripts/sample_catalog.py +1372 -1250
  48. package/scripts/twofish_diagnostics.py +48 -31
  49. package/scripts/usage_ledger.py +218 -0
  50. package/scripts/vendor/README.md +44 -37
  51. package/scripts/vendor/twofish_pure.py +321 -0
  52. package/scripts/workspace_repair.py +548 -508
  53. package/templates/pt900/donors/README.md +15 -0
@@ -1,1250 +1,1372 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from functools import lru_cache
5
- from dataclasses import dataclass, field
6
- from pathlib import Path
7
- from typing import Any
8
- import xml.etree.ElementTree as ET
9
-
10
- from packet_tracer_env import get_packet_tracer_saves_root, get_packet_tracer_target_version
11
- from pkt_codec import decode_pkt_modern
12
- from workspace_repair import inspect_workspace_integrity
13
-
14
- SCRIPT_DIR = Path(__file__).resolve().parent
15
- SKILL_ROOT = SCRIPT_DIR.parent
16
- DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
17
- DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
18
- DEFAULT_CURATED_DONOR_REGISTRY = SKILL_ROOT / "references" / "curated-donor-registry.json"
19
-
20
- CAPABILITY_KEYWORDS = {
21
- "vlan": ["vlan", "trunk", "access"],
22
- "trunk": ["trunk"],
23
- "access_port": ["access"],
24
- "dhcp_pool": ["dhcp", "reservation", "apipa"],
25
- "router_dhcp": ["dhcp", "reservation", "apipa"],
26
- "server_dhcp": ["dhcp", "reservation", "apipa"],
27
- "dhcpv6_stateful": ["dhcpv6", "stateful_dhcpv6"],
28
- "dhcpv6_stateless": ["stateless_dhcpv6"],
29
- "ipv6_slaac": ["slaac"],
30
- "ipv6_prefix_delegation": ["prefix_delegation"],
31
- "ipv6_dns_aaaa": ["aaaa"],
32
- "ipv6_tunneling": ["ipv6ip", "ipv6 tunneling"],
33
- "isatap": ["isatap"],
34
- "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
35
- "ospf": ["ospf"],
36
- "ospfv2": ["ospf", "ospfv2", "single-area ospf"],
37
- "ospfv3": ["ospfv3", "ipv6_ospf"],
38
- "eigrp": ["eigrp"],
39
- "eigrp_ipv4": ["eigrp", "ipv4 eigrp"],
40
- "eigrp_ipv6": ["ipv6_eigrp"],
41
- "rip": ["rip"],
42
- "ripv2": ["ripv2", "rip"],
43
- "ripng": ["ripng", "ipv6 rip"],
44
- "hsrp": ["hsrp"],
45
- "nat": ["nat"],
46
- "nat_static": ["static nat"],
47
- "nat_dynamic": ["dynamic nat"],
48
- "pat": ["pat", "overload"],
49
- "static_route": ["static route", "ip route"],
50
- "default_route": ["default route"],
51
- "dhcp_relay": ["dhcp relay", "helper-address"],
52
- "ssh_ios": ["ssh"],
53
- "ntp_ios": ["ntp"],
54
- "syslog_ios": ["syslog"],
55
- "acl": ["acl", "access-list"],
56
- "dai": ["dai", "dynamic arp inspection"],
57
- "dot1x": ["dot1x", "802.1x", "port-based nac"],
58
- "lldp": ["lldp"],
59
- "rep": ["rep_"],
60
- "snmp": ["snmp"],
61
- "netflow": ["netflow"],
62
- "span": ["rspan", "monitor session", "span session"],
63
- "qos": ["qos"],
64
- "port_security": ["port security", "port-security"],
65
- "bgp": ["bgp"],
66
- "stp": ["stp", "spanning tree", "spanning-tree"],
67
- "rstp": ["rstp", "rapid-pvst"],
68
- "etherchannel": ["etherchannel", "port-channel", "channel-group"],
69
- "lacp": ["lacp"],
70
- "pagp": ["pagp"],
71
- "vtp": ["vtp"],
72
- "dtp": ["dtp"],
73
- "vpn": ["vpn", "ipsec", "gre"],
74
- "ipsec": ["ipsec", "ike"],
75
- "gre": ["gre"],
76
- "ppp": ["ppp", "chap", "pap"],
77
- "wan": ["wan", "cloud", "serial", "pppoe", "ppp"],
78
- "security_edge": ["asa", "firewall", "security appliance"],
79
- "asa_acl_nat": ["asa_acl_nat"],
80
- "asa_service_policy": ["asa_service_policy", "service policy"],
81
- "clientless_vpn": ["clientless_vpn"],
82
- "cbac": ["cbac"],
83
- "zfw": ["zfw", "zone based firewall"],
84
- "sniffer": ["sniffer"],
85
- "multilayer_switching": ["multilayer", "layer 3", "3560", "svi"],
86
- "wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
87
- "wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
88
- "wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
89
- "wlc": ["wlc", "wireless lan controller"],
90
- "wpa_enterprise": ["wpa_radius", "wpa2_enterprise"],
91
- "wep": ["wep"],
92
- "guest_wifi": ["guest"],
93
- "beamforming": ["beamforming"],
94
- "meraki": ["meraki"],
95
- "cellular_5g": ["5g", "cellular", "cell tower"],
96
- "bluetooth": ["bluetooth"],
97
- "network_controller": ["network controller", "netcon"],
98
- "python_programming": ["python"],
99
- "javascript_programming": ["javascript"],
100
- "blockly_programming": ["blockly"],
101
- "tcp_udp_app": ["tcp_test_app", "udp_test_app"],
102
- "vm_iox": ["iox", "uploading_and_running_vm"],
103
- "voip": ["voip"],
104
- "ip_phone": ["ip phone", "phone"],
105
- "call_manager": ["call manager", "telephony", "voip"],
106
- "linksys_voice": ["voip linksys"],
107
- "mqtt": ["mqtt"],
108
- "real_http": ["real-http", "real http"],
109
- "real_websocket": ["real-websocket", "real websocket"],
110
- "visual_scripting": ["visual-scripting", "visual scripting"],
111
- "ptp": ["ptp"],
112
- "profinet": ["profinet"],
113
- "l2nat": ["l2nat"],
114
- "cyberobserver": ["cyberobserver"],
115
- "industrial_firewall": ["isa3000", "industrial cybersecurity"],
116
- "coaxial": ["coaxial"],
117
- "cable_dsl": ["cable modem", "dsl modem"],
118
- "central_office": ["centraloffice", "central office"],
119
- "cell_tower": ["cell tower"],
120
- "power_distribution": ["power distribution"],
121
- "hot_swappable": ["hotswappable", "hot swappable"],
122
- "ios_license": ["ios_15", "license"],
123
- "ntp": ["ntp"],
124
- "dns": ["dns"],
125
- "server_dns": ["dns"],
126
- "server_http": ["http", "https", "websocket"],
127
- "server_ftp": ["ftp", "tftp"],
128
- "ftp_http_https": ["ftp", "http", "https", "websocket"],
129
- "iot": ["iot", "sensor", "led", "arduino", "mqtt", "environment"],
130
- "switching": ["switch", "switching", "lldp", "rep"],
131
- "router_on_a_stick": ["router-on-a-stick", "dot1q"],
132
- "host_server": ["server", "pc", "client", "host"],
133
- "management_vlan": ["management", "vlan99", "telnet"],
134
- "telnet": ["telnet", "terminal server"],
135
- "tablet": ["tablet", "pda"],
136
- "printer": ["printer"],
137
- }
138
-
139
- REPORT_ONLY_CAPABILITIES = {
140
- "ipv6_slaac",
141
- "dhcpv6_stateful",
142
- "dhcpv6_stateless",
143
- "ipv6_prefix_delegation",
144
- "ipv6_dns_aaaa",
145
- "ipv6_tunneling",
146
- "isatap",
147
- "ospfv3",
148
- "eigrp_ipv6",
149
- "ripng",
150
- "hsrp",
151
- "dhcp_snooping",
152
- "dai",
153
- "dot1x",
154
- "lldp",
155
- "rep",
156
- "snmp",
157
- "netflow",
158
- "span",
159
- "qos",
160
- "port_security",
161
- "bgp",
162
- "stp",
163
- "rstp",
164
- "etherchannel",
165
- "lacp",
166
- "pagp",
167
- "vtp",
168
- "dtp",
169
- "ospfv2",
170
- "eigrp_ipv4",
171
- "ripv2",
172
- "static_route",
173
- "default_route",
174
- "dhcp_relay",
175
- "nat_static",
176
- "nat_dynamic",
177
- "pat",
178
- "ssh_ios",
179
- "ntp_ios",
180
- "syslog_ios",
181
- "asa_acl_nat",
182
- "asa_service_policy",
183
- "clientless_vpn",
184
- "cbac",
185
- "zfw",
186
- "sniffer",
187
- "wlc",
188
- "wpa_enterprise",
189
- "wep",
190
- "guest_wifi",
191
- "beamforming",
192
- "meraki",
193
- "cellular_5g",
194
- "bluetooth",
195
- "network_controller",
196
- "python_programming",
197
- "javascript_programming",
198
- "blockly_programming",
199
- "tcp_udp_app",
200
- "vm_iox",
201
- "voip",
202
- "ip_phone",
203
- "call_manager",
204
- "linksys_voice",
205
- "mqtt",
206
- "real_http",
207
- "real_websocket",
208
- "visual_scripting",
209
- "ptp",
210
- "profinet",
211
- "l2nat",
212
- "cyberobserver",
213
- "industrial_firewall",
214
- "coaxial",
215
- "cable_dsl",
216
- "central_office",
217
- "cell_tower",
218
- "power_distribution",
219
- "hot_swappable",
220
- "ios_license",
221
- }
222
-
223
- TYPE_NORMALIZATION = {
224
- "pc": "PC",
225
- "pc-pt": "PC",
226
- "server": "Server",
227
- "server-pt": "Server",
228
- "router": "Router",
229
- "switch": "Switch",
230
- "multilayerswitch": "MultiLayerSwitch",
231
- "layer3switch": "MultiLayerSwitch",
232
- "wirelessrouter": "WirelessRouter",
233
- "wirelessrouternewgeneration": "WirelessRouter",
234
- "homegateway": "WirelessRouter",
235
- "wirelesslancontroller": "LightWeightAccessPoint",
236
- "lightweightaccesspoint": "LightWeightAccessPoint",
237
- "accesspoint": "LightWeightAccessPoint",
238
- "pda": "Tablet",
239
- "tabletpc": "Tablet",
240
- "tablet": "Tablet",
241
- "laptop": "Laptop",
242
- "printer": "Printer",
243
- "smartphone": "Smartphone",
244
- "mcucomponent": "IoT",
245
- "networkcontroller": "NetworkController",
246
- "centralofficeserver": "CentralOfficeServer",
247
- "celltower": "CellTower",
248
- }
249
-
250
- LICENSE_FILENAMES = [
251
- "LICENSE",
252
- "LICENSE.txt",
253
- "LICENSE.md",
254
- "COPYING",
255
- "COPYING.txt",
256
- "NOTICE",
257
- ]
258
-
259
-
260
- @dataclass
261
- class SampleDescriptor:
262
- path: str
263
- relative_path: str
264
- version: str
265
- device_count: int
266
- link_count: int
267
- devices: list[dict[str, Any]]
268
- links: list[dict[str, Any]]
269
- capability_tags: list[str]
270
- topology_tags: list[str]
271
- preferred_roles: list[str]
272
- trust_level: str = "trusted"
273
- origin: str = "cisco-local"
274
- role: str = "primary"
275
- prototype_eligible: bool = True
276
- license_or_permission: str = "local-cisco"
277
- promotion_status: str = "validated_primary"
278
- validation_status: str = "validated"
279
- packet_tracer_version: str = ""
280
- wireless_mode_tags: list[str] = field(default_factory=list)
281
- donor_eligible: bool = True
282
- device_families: list[str] = field(default_factory=list)
283
- model_families: list[str] = field(default_factory=list)
284
- port_media_types: list[str] = field(default_factory=list)
285
- service_support: list[str] = field(default_factory=list)
286
- iot_roles: list[str] = field(default_factory=list)
287
- runtime_features: list[str] = field(default_factory=list)
288
- donor_graph_fingerprint: dict[str, Any] = field(default_factory=dict)
289
- apply_safety_level: str = "reference-known"
290
- promotion_evidence: list[str] = field(default_factory=list)
291
- validated_edit_capabilities: list[str] = field(default_factory=list)
292
- acceptance_notes: list[str] = field(default_factory=list)
293
- archetype_tags: list[str] = field(default_factory=list)
294
- acceptance_fixtures: list[str] = field(default_factory=list)
295
- provenance: str = ""
296
- workspace_validation_status: str = ""
297
- evidence_source: str = "inferred"
298
-
299
- def normalized_device_counts(self) -> dict[str, int]:
300
- counts: dict[str, int] = {}
301
- for device in self.devices:
302
- normalized = normalize_device_type(device.get("type", ""))
303
- if normalized:
304
- counts[normalized] = counts.get(normalized, 0) + 1
305
- return counts
306
-
307
-
308
- @dataclass
309
- class SampleCandidate:
310
- sample: SampleDescriptor
311
- capability_score: int
312
- topology_score: int
313
- total_score: int
314
- reasons: list[str]
315
-
316
-
317
- @dataclass
318
- class ReferencePattern:
319
- relative_path: str
320
- origin: str
321
- capability_tags: list[str]
322
- topology_tags: list[str]
323
- device_summary: dict[str, int]
324
- wireless_mode_tags: list[str] = field(default_factory=list)
325
-
326
-
327
- @dataclass
328
- class DonorValidationResult:
329
- packet_tracer_version: str
330
- validation_status: str
331
- promotion_status: str
332
- donor_eligible: bool
333
- issues: list[str]
334
-
335
-
336
- @dataclass
337
- class CuratedDonorRecord:
338
- sample: SampleDescriptor
339
- validation: DonorValidationResult
340
-
341
-
342
- def _normalize_registry_key(value: str) -> str:
343
- return value.strip().replace("\\", "/").lower()
344
-
345
-
346
- @lru_cache(maxsize=4)
347
- def _load_curated_donor_registry_cached(path_str: str) -> dict[str, dict[str, Any]]:
348
- path = Path(path_str)
349
- if not path.exists():
350
- return {}
351
- payload = json.loads(path.read_text(encoding="utf-8"))
352
- donors = list(payload.get("donors", [])) if isinstance(payload, dict) else []
353
- registry: dict[str, dict[str, Any]] = {}
354
- for item in donors:
355
- if not isinstance(item, dict):
356
- continue
357
- key = _normalize_registry_key(str(item.get("relative_path", "")))
358
- if not key:
359
- continue
360
- registry[key] = dict(item)
361
- return registry
362
-
363
-
364
- def load_curated_donor_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
365
- return dict(_load_curated_donor_registry_cached(str(path or DEFAULT_CURATED_DONOR_REGISTRY)))
366
-
367
-
368
- def normalize_device_type(raw_type: str) -> str:
369
- key = raw_type.strip().lower().replace(" ", "")
370
- return TYPE_NORMALIZATION.get(key, raw_type.strip())
371
-
372
-
373
- def _normalized_counts_for_item(item: dict[str, Any]) -> dict[str, int]:
374
- counts: dict[str, int] = {}
375
- for device in item.get("devices", []):
376
- normalized = normalize_device_type(device.get("type", ""))
377
- if normalized:
378
- counts[normalized] = counts.get(normalized, 0) + 1
379
- return counts
380
-
381
-
382
- def infer_capability_tags(item: dict[str, Any]) -> list[str]:
383
- tags: set[str] = set()
384
- rel = item.get("relative_path", "").lower().replace("\\", "/")
385
- rel_flat = rel.replace("/", " ")
386
- devices = item.get("devices", [])
387
- raw_types = {str(device.get("type", "")).strip() for device in devices if str(device.get("type", "")).strip()}
388
- normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
389
- router_count = normalized_types.count("Router")
390
- switch_count = normalized_types.count("Switch")
391
- host_count = sum(1 for dtype in normalized_types if dtype in {"PC", "Server"})
392
-
393
- for tag, words in CAPABILITY_KEYWORDS.items():
394
- if any(word in rel or word in rel_flat for word in words):
395
- tags.add(tag)
396
- if router_count >= 2:
397
- tags.add("multi_router")
398
- if switch_count >= 1:
399
- tags.add("switching")
400
- if host_count >= 1:
401
- tags.add("host_server")
402
- if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
403
- tags.add("wireless")
404
- tags.add("wireless_ap")
405
- if "HomeGateway" in raw_types or any(dtype == "IoT" for dtype in normalized_types):
406
- tags.add("iot")
407
- if any(dtype in {"ASA", "Security Appliance"} for dtype in normalized_types) or raw_types & {"ASA", "Security Appliance"}:
408
- tags.add("security_edge")
409
- tags.add("acl")
410
- if any(dtype in {"Cloud", "Cable Modem", "Dsl Modem"} for dtype in normalized_types):
411
- tags.add("wan")
412
- if any(dtype == "MultiLayerSwitch" for dtype in normalized_types):
413
- tags.add("multilayer_switching")
414
- if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
415
- tags.add("wireless_client")
416
- if any(dtype == "Tablet" for dtype in normalized_types):
417
- tags.add("tablet")
418
- if any(dtype == "Printer" for dtype in normalized_types):
419
- tags.add("printer")
420
- if "telnet" in rel or "terminal server" in rel_flat:
421
- tags.add("telnet")
422
- if "management" in rel_flat:
423
- tags.add("management_vlan")
424
- return sorted(tags or {"switching"})
425
-
426
-
427
- def infer_topology_tags(item: dict[str, Any]) -> list[str]:
428
- tags: set[str] = set()
429
- rel = item.get("relative_path", "").lower().replace("\\", "/")
430
- counts = _normalized_counts_for_item(item)
431
- link_count = int(item.get("link_count", 0))
432
- switch_count = counts.get("Switch", 0)
433
- router_count = counts.get("Router", 0)
434
- wireless_count = counts.get("LightWeightAccessPoint", 0) + counts.get("WirelessRouter", 0)
435
- server_count = counts.get("Server", 0)
436
-
437
- if "router_on_a_stick" in item.get("capability_tags", []):
438
- tags.add("router_on_a_stick")
439
- if "acl" in item.get("capability_tags", []):
440
- tags.add("acl_policy")
441
- if wireless_count:
442
- tags.add("wireless_edge")
443
- if server_count:
444
- tags.add("server_services")
445
- if switch_count >= 3 and router_count >= 1:
446
- tags.add("department_lan")
447
- if switch_count >= 2 and router_count >= 1:
448
- tags.add("core_access")
449
- if switch_count >= 2 and link_count >= switch_count:
450
- tags.add("chain")
451
- if router_count == 1 and switch_count <= 1 and wireless_count:
452
- tags.add("small_office")
453
- if "campus" in rel or "department" in rel or "vlan" in rel:
454
- tags.add("department_lan")
455
- return sorted(tags or {"general"})
456
-
457
-
458
- def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
459
- roles: list[str] = []
460
- tags = set(item.get("capability_tags", []))
461
- if "vlan" in tags:
462
- roles.append("preferred_vlan")
463
- if "dhcp_pool" in tags:
464
- roles.append("preferred_dhcp")
465
- if {"ospf", "eigrp", "rip", "bgp", "ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route"} & tags:
466
- roles.append("preferred_routing")
467
- if {"dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"} & tags:
468
- roles.append("preferred_ipv4_management")
469
- if {"stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"} & tags:
470
- roles.append("preferred_l2_resiliency")
471
- if {"nat", "acl", "vpn"} & tags:
472
- roles.append("preferred_security")
473
- if {"vpn", "ipsec", "gre", "ppp", "multilayer_switching"} & tags:
474
- roles.append("preferred_wan")
475
- if "wireless" in tags:
476
- roles.append("preferred_wireless")
477
- if "telnet" in tags or "management_vlan" in tags:
478
- roles.append("preferred_management")
479
- if "server_dns" in tags or "server_dhcp" in tags:
480
- roles.append("preferred_server")
481
- if "iot" in tags:
482
- roles.append("preferred_iot")
483
- if not roles:
484
- roles.append("preferred_general")
485
- return roles
486
-
487
-
488
- def infer_wireless_mode_tags(item: dict[str, Any]) -> list[str]:
489
- normalized_types = [normalize_device_type(device.get("type", "")) for device in item.get("devices", [])]
490
- tags: set[str] = set()
491
- if "LightWeightAccessPoint" in normalized_types:
492
- tags.add("ap_bridge")
493
- if "WirelessRouter" in normalized_types:
494
- tags.add("home_router_edge")
495
- if len(tags) > 1:
496
- tags.add("mixed")
497
- return sorted(tags)
498
-
499
-
500
- def infer_iot_roles(item: dict[str, Any]) -> list[str]:
501
- roles = {str(role).strip() for role in item.get("iot_roles", []) if str(role).strip()}
502
- raw_types = {str(device.get("type", "")).strip() for device in item.get("devices", []) if str(device.get("type", "")).strip()}
503
- normalized_types = {normalize_device_type(device.get("type", "")) for device in item.get("devices", []) if device.get("type")}
504
- families = set(item.get("device_families", []) or infer_device_families(item))
505
- has_thing = bool(raw_types & {"MCUComponent", "Board", "Sensor", "Actuator"}) or "iot devices" in families
506
- has_gateway = "HomeGateway" in raw_types or ("home/wireless routers" in families and has_thing)
507
- has_server = "Server" in normalized_types or "servers" in families
508
- if has_thing:
509
- roles.add("thing")
510
- if has_gateway:
511
- roles.add("gateway")
512
- if has_server:
513
- roles.add("server")
514
- return sorted(roles)
515
-
516
-
517
- def infer_device_families(item: dict[str, Any]) -> list[str]:
518
- family_map = {
519
- "Router": "routers",
520
- "Switch": "switches",
521
- "MultiLayerSwitch": "multilayer switches",
522
- "Server": "servers",
523
- "PC": "end devices",
524
- "Laptop": "end devices",
525
- "Tablet": "end devices",
526
- "Smartphone": "end devices",
527
- "Printer": "end devices",
528
- "IpPhone": "end devices",
529
- "HomeVoip": "end devices",
530
- "AnalogPhone": "end devices",
531
- "LightWeightAccessPoint": "access points",
532
- "WirelessRouter": "home/wireless routers",
533
- "WirelessRouterNewGeneration": "home/wireless routers",
534
- "HomeGateway": "home/wireless routers",
535
- "WirelessLanController": "access points",
536
- "Power Distribution Device": "pt-specific edge/utility devices",
537
- "Cloud": "wan/cloud/dsl/cable devices",
538
- "Cable Modem": "wan/cloud/dsl/cable devices",
539
- "Dsl Modem": "wan/cloud/dsl/cable devices",
540
- "Security Appliance": "security devices",
541
- "ASA": "security devices",
542
- "NetworkController": "network controllers",
543
- "CentralOfficeServer": "wan/cloud/dsl/cable devices",
544
- "CellTower": "wan/cloud/dsl/cable devices",
545
- "IoT": "iot devices",
546
- "Sensor": "iot devices",
547
- "Actuator": "iot devices",
548
- "MCUComponent": "iot devices",
549
- }
550
- families = {
551
- family_map.get(normalize_device_type(device.get("type", "")), "pt-specific edge/utility devices")
552
- for device in item.get("devices", [])
553
- if device.get("type")
554
- }
555
- return sorted(families)
556
-
557
-
558
- def infer_model_families(item: dict[str, Any]) -> list[str]:
559
- return sorted(
560
- {
561
- str(device.get("model", "")).strip()
562
- for device in item.get("devices", [])
563
- if str(device.get("model", "")).strip()
564
- }
565
- )
566
-
567
-
568
- def infer_port_media_types(item: dict[str, Any]) -> list[str]:
569
- media = {
570
- str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
571
- for link in item.get("links", [])
572
- if str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
573
- }
574
- return sorted(media)
575
-
576
-
577
- def infer_service_support(item: dict[str, Any]) -> list[str]:
578
- tags = set(item.get("capability_tags", []))
579
- services: set[str] = set()
580
- service_map = {
581
- "server_dhcp": "dhcp",
582
- "dhcp_pool": "dhcp",
583
- "dns": "dns",
584
- "server_dns": "dns",
585
- "server_http": "http",
586
- "server_https": "https",
587
- "server_ftp": "ftp",
588
- "server_tftp": "tftp",
589
- "ntp": "ntp",
590
- "server_email": "email",
591
- "server_syslog": "syslog",
592
- "server_aaa": "aaa",
593
- "ftp_http_https": "http",
594
- "telnet": "telnet",
595
- "management_vlan": "management",
596
- }
597
- for tag in tags:
598
- service = service_map.get(tag)
599
- if service:
600
- services.add(service)
601
- return sorted(services)
602
-
603
-
604
- def infer_runtime_features(item: dict[str, Any]) -> list[str]:
605
- features: set[str] = set()
606
- tags = set(item.get("capability_tags", []))
607
- families = set(item.get("device_families", []) or infer_device_families(item))
608
- if item.get("wireless_mode_tags"):
609
- features.add("wireless_runtime")
610
- if item.get("service_support"):
611
- features.add("service_runtime")
612
- if infer_iot_roles(item):
613
- features.add("iot_runtime")
614
- if families & {"wan/cloud/dsl/cable devices"} or tags & {"wan", "ppp"}:
615
- features.add("wan_runtime")
616
- if families & {"security devices"} or tags & {"security_edge", "acl", "nat"}:
617
- features.add("security_runtime")
618
- if tags & {"vpn", "ipsec", "gre"}:
619
- features.add("tunnel_runtime")
620
- if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
621
- features.add("ipv6_runtime")
622
- if tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
623
- features.add("l2_resiliency_runtime")
624
- if tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
625
- features.add("ipv4_routing_management_runtime")
626
- if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
627
- features.add("multilayer_runtime")
628
- if item.get("workspace_validation"):
629
- features.add("workspace_validated")
630
- if any(str(link.get("from", "")) and str(link.get("to", "")) for link in item.get("links", [])):
631
- features.add("named_link_graph")
632
- return sorted(features)
633
-
634
-
635
- def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
636
- tags: set[str] = set()
637
- topology = set(item.get("topology_tags", []))
638
- families = set(item.get("device_families", []))
639
- services = set(item.get("service_support", []))
640
- wireless_modes = set(item.get("wireless_mode_tags", []))
641
- iot_roles = set(infer_iot_roles(item))
642
-
643
- if topology & {"chain", "core_access", "department_lan", "router_on_a_stick"} or (
644
- families & {"routers", "switches", "multilayer switches"}
645
- and set(item.get("capability_tags", [])) & {"vlan", "router_on_a_stick", "management_vlan", "telnet"}
646
- ):
647
- tags.add("campus/core")
648
- if services or "servers" in families or "server_services" in topology:
649
- tags.add("service-heavy")
650
- if wireless_modes or families & {"access points", "home/wireless routers"}:
651
- tags.add("wireless-heavy")
652
- if iot_roles or "iot devices" in families or "HomeGateway" in set(item.get("model_families", [])):
653
- tags.add("IoT/home gateway")
654
- if families & {"wan/cloud/dsl/cable devices", "security devices"} or set(item.get("capability_tags", [])) & {
655
- "vpn",
656
- "ipsec",
657
- "gre",
658
- "ppp",
659
- "wan",
660
- "security_edge",
661
- "multilayer_switching",
662
- "nat",
663
- "acl",
664
- }:
665
- tags.add("WAN/security edge")
666
- capability_tags = set(item.get("capability_tags", []))
667
- if capability_tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ipv6_prefix_delegation", "ipv6_dns_aaaa", "ipv6_tunneling", "isatap", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
668
- tags.add("IPv6/routing")
669
- if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
670
- tags.add("L2 security/monitoring")
671
- if capability_tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
672
- tags.add("L2 resiliency/routing")
673
- if capability_tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
674
- tags.add("IPv4 routing/management")
675
- if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
676
- tags.add("advanced wireless")
677
- if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families:
678
- tags.add("automation/controller")
679
- if capability_tags & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
680
- tags.add("voice/collaboration")
681
- if capability_tags & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
682
- tags.add("industrial IoT")
683
- return sorted(tags)
684
-
685
-
686
- def infer_donor_graph_fingerprint(item: dict[str, Any]) -> dict[str, Any]:
687
- pairs: list[str] = []
688
- media_types = infer_port_media_types(item)
689
- for link in item.get("links", []):
690
- left = str(link.get("from", "")).strip()
691
- right = str(link.get("to", "")).strip()
692
- if left and right:
693
- pairs.append(" <-> ".join(sorted((left, right))))
694
- return {
695
- "link_count": int(item.get("link_count", 0)),
696
- "named_pairs": sorted(dict.fromkeys(pairs)),
697
- "media_types": media_types,
698
- }
699
-
700
-
701
- def infer_apply_safety_level(item: dict[str, Any]) -> str:
702
- if item.get("origin") == "cisco-local" and item.get("prototype_eligible", True) and str(item.get("version", "")).startswith("9."):
703
- return "acceptance-verified"
704
- if item.get("donor_eligible") and item.get("prototype_eligible", False):
705
- return "safe-open-generate-supported"
706
- if item.get("capability_tags"):
707
- return "config-mutation-supported"
708
- if item.get("devices"):
709
- return "inventory-supported"
710
- return "reference-known"
711
-
712
-
713
- def infer_validated_edit_capabilities(item: dict[str, Any]) -> list[str]:
714
- capabilities: set[str] = {str(tag) for tag in item.get("capability_tags", []) if str(tag).strip()}
715
- inferred_iot_roles = set(infer_iot_roles(item))
716
- families = set(item.get("device_families", []) or infer_device_families(item))
717
- service_capability_map = {
718
- "dhcp": "server_dhcp",
719
- "dns": "server_dns",
720
- "http": "server_http",
721
- "https": "server_https",
722
- "ftp": "server_ftp",
723
- "tftp": "server_tftp",
724
- "ntp": "server_ntp",
725
- "email": "server_email",
726
- "syslog": "server_syslog",
727
- "aaa": "server_aaa",
728
- }
729
- for service in item.get("service_support", []):
730
- mapped = service_capability_map.get(str(service))
731
- if mapped:
732
- capabilities.add(mapped)
733
- if item.get("wireless_mode_tags"):
734
- capabilities.add("wireless_mutation")
735
- if any(device_family == "end devices" for device_family in item.get("device_families", [])):
736
- capabilities.add("end_device_mutation")
737
- if inferred_iot_roles:
738
- capabilities.add("iot")
739
- capabilities.add("iot_registration")
740
- if {"server", "gateway"} & inferred_iot_roles:
741
- capabilities.add("iot_control")
742
- if capabilities & {"vpn", "ipsec", "gre"}:
743
- capabilities.update({"vpn", "ipsec", "gre"})
744
- if capabilities & {"ppp", "wan"} or families & {"wan/cloud/dsl/cable devices"}:
745
- capabilities.add("wan")
746
- if "ppp" in capabilities:
747
- capabilities.add("ppp")
748
- if capabilities & {"acl", "nat", "security_edge"} or families & {"security devices"}:
749
- capabilities.add("security_edge")
750
- if "multilayer switches" in families or "multilayer_switching" in capabilities:
751
- capabilities.add("multilayer_switching")
752
- if item.get("apply_safety_level") == "inventory-supported":
753
- return []
754
- capabilities -= REPORT_ONLY_CAPABILITIES
755
- return sorted(capabilities)
756
-
757
-
758
- def infer_promotion_evidence(item: dict[str, Any]) -> list[str]:
759
- evidence: list[str] = []
760
- version = str(item.get("packet_tracer_version") or item.get("version") or "").strip()
761
- target_version = get_packet_tracer_target_version()
762
- if version and version == target_version:
763
- evidence.append(f"version:{version}")
764
- validation_status = str(item.get("validation_status", "")).strip()
765
- if validation_status:
766
- evidence.append(f"validation:{validation_status}")
767
- promotion_status = str(item.get("promotion_status", "")).strip()
768
- if promotion_status:
769
- evidence.append(f"promotion:{promotion_status}")
770
- if item.get("donor_eligible"):
771
- evidence.append("donor_eligible")
772
- runtime_features = set(item.get("runtime_features", []))
773
- if "workspace_validated" in runtime_features:
774
- evidence.append("workspace_validated")
775
- workspace_validation_status = str(item.get("workspace_validation_status", "")).strip()
776
- if workspace_validation_status:
777
- evidence.append(f"workspace_validation:{workspace_validation_status}")
778
- apply_safety_level = str(item.get("apply_safety_level", "")).strip()
779
- if apply_safety_level:
780
- evidence.append(f"apply_safety:{apply_safety_level}")
781
- validated_edit_capabilities = list(item.get("validated_edit_capabilities", []))
782
- if validated_edit_capabilities:
783
- evidence.append(f"edit_capabilities:{len(validated_edit_capabilities)}")
784
- explicit_fixtures = [str(fixture) for fixture in item.get("acceptance_fixtures", []) if str(fixture).strip()]
785
- for fixture in explicit_fixtures:
786
- evidence.append(f"acceptance_fixture:{fixture}")
787
- archetype_to_fixture = {
788
- "campus/core": "campus",
789
- "service-heavy": "service_heavy",
790
- "IoT/home gateway": "home_iot",
791
- "WAN/security edge": "wan_security_edge",
792
- }
793
- if not explicit_fixtures and promotion_status in {"validated_primary", "validated_compat", "acceptance_verified_curated"}:
794
- for archetype in item.get("archetype_tags", []):
795
- fixture = archetype_to_fixture.get(str(archetype))
796
- if fixture:
797
- evidence.append(f"acceptance_fixture:{fixture}")
798
- provenance = str(item.get("provenance", "")).strip()
799
- if provenance:
800
- evidence.append(f"provenance:{provenance}")
801
- evidence_source = str(item.get("evidence_source", "")).strip()
802
- if evidence_source:
803
- evidence.append(f"evidence_source:{evidence_source}")
804
- return evidence
805
-
806
-
807
- def infer_acceptance_notes(item: dict[str, Any]) -> list[str]:
808
- notes: list[str] = []
809
- workspace_validation = item.get("workspace_validation", {})
810
- if isinstance(workspace_validation, dict):
811
- logical_status = str(workspace_validation.get("logical_status", "")).strip()
812
- physical_status = str(workspace_validation.get("physical_status", "")).strip()
813
- if logical_status or physical_status:
814
- notes.append(f"workspace logical={logical_status or 'unknown'} physical={physical_status or 'unknown'}")
815
- wireless_modes = [str(item) for item in item.get("wireless_mode_tags", []) if str(item).strip()]
816
- if wireless_modes:
817
- notes.append(f"wireless modes: {', '.join(wireless_modes)}")
818
- archetypes = [str(item) for item in item.get("archetype_tags", []) if str(item).strip()]
819
- if archetypes:
820
- notes.append(f"archetypes: {', '.join(archetypes)}")
821
- acceptance_fixtures = [str(item) for item in item.get("acceptance_fixtures", []) if str(item).strip()]
822
- if acceptance_fixtures:
823
- notes.append(f"acceptance fixtures: {', '.join(acceptance_fixtures)}")
824
- provenance = str(item.get("provenance", "")).strip()
825
- if provenance:
826
- notes.append(f"provenance: {provenance}")
827
- return notes
828
-
829
-
830
- def _detect_license_or_permission(root: Path) -> str:
831
- for filename in LICENSE_FILENAMES:
832
- candidate = root / filename
833
- if not candidate.exists():
834
- continue
835
- try:
836
- text = candidate.read_text(encoding="utf-8", errors="ignore").lower()
837
- except Exception:
838
- return f"present:{candidate.name}"
839
- if "mit license" in text:
840
- return "MIT"
841
- if "apache license" in text and "version 2.0" in text:
842
- return "Apache-2.0"
843
- if "bsd" in text:
844
- return "BSD"
845
- if "creative commons" in text and "noncommercial" in text:
846
- return "CC-BY-NC"
847
- if "creative commons" in text:
848
- return "CC"
849
- return f"present:{candidate.name}"
850
- return "unknown"
851
-
852
-
853
- def validate_external_sample_summary(item: dict[str, Any], curated: bool) -> DonorValidationResult:
854
- target_version = get_packet_tracer_target_version()
855
- packet_tracer_version = str(item.get("version", ""))
856
- issues: list[str] = []
857
- if packet_tracer_version != target_version:
858
- issues.append(f"version_mismatch:{packet_tracer_version or 'unknown'}")
859
- if int(item.get("device_count", 0)) < 2:
860
- issues.append("insufficient_devices")
861
- if int(item.get("link_count", 0)) < 1:
862
- issues.append("insufficient_links")
863
- counts = _normalized_counts_for_item(item)
864
- if curated and not any(counts.get(kind, 0) for kind in ("Switch", "Router")):
865
- issues.append("missing_network_core")
866
- workspace_validation = item.get("workspace_validation", {})
867
- if curated and isinstance(workspace_validation, dict):
868
- workspace_mode = str(workspace_validation.get("workspace_mode", ""))
869
- logical_status = str(workspace_validation.get("logical_status", ""))
870
- physical_status = str(workspace_validation.get("physical_status", ""))
871
- blocking_issues = [str(issue) for issue in workspace_validation.get("blocking_issues", []) if str(issue)]
872
- legacy_memaddr_only = (
873
- workspace_mode == "legacy_uuid_physical"
874
- and physical_status == "ok"
875
- and logical_status == "invalid"
876
- and bool(blocking_issues)
877
- and all(issue == "Link device MEM_ADDR references do not match device workspace records" for issue in blocking_issues)
878
- )
879
- if physical_status != "ok":
880
- issues.append("workspace_physical_invalid")
881
- elif logical_status != "ok" and not legacy_memaddr_only:
882
- issues.append("workspace_logical_invalid")
883
- donor_eligible = curated and not issues
884
- validation_status = "validated" if not issues else "blocked"
885
- promotion_status = "validated_curated" if donor_eligible else "reference_only"
886
- return DonorValidationResult(
887
- packet_tracer_version=packet_tracer_version,
888
- validation_status=validation_status,
889
- promotion_status=promotion_status,
890
- donor_eligible=donor_eligible,
891
- issues=issues,
892
- )
893
-
894
-
895
- def _merge_curated_registry_entry(
896
- item: dict[str, Any],
897
- registry_entry: dict[str, Any] | None,
898
- validation: DonorValidationResult,
899
- ) -> dict[str, Any]:
900
- merged = dict(item)
901
- if not registry_entry:
902
- return merged
903
- for field in [
904
- "packet_tracer_version",
905
- "apply_safety_level",
906
- "archetype_tags",
907
- "validated_edit_capabilities",
908
- "acceptance_fixtures",
909
- "acceptance_notes",
910
- "provenance",
911
- ]:
912
- if field in registry_entry:
913
- merged[field] = registry_entry[field]
914
- requested_promotion = str(registry_entry.get("promotion_status", "")).strip()
915
- if requested_promotion and validation.donor_eligible:
916
- merged["promotion_status"] = requested_promotion
917
- if not validation.donor_eligible:
918
- merged["promotion_status"] = "reference_only"
919
- merged["donor_eligible"] = False
920
- if registry_entry:
921
- merged["evidence_source"] = "registry+inferred"
922
- workspace_validation_status = str(registry_entry.get("workspace_validation", "")).strip()
923
- if workspace_validation_status:
924
- merged["workspace_validation_status"] = workspace_validation_status
925
- return merged
926
-
927
-
928
- def _descriptor_from_item(item: dict[str, Any]) -> SampleDescriptor:
929
- return SampleDescriptor(
930
- path=item["path"],
931
- relative_path=item["relative_path"],
932
- version=item.get("version", ""),
933
- device_count=item.get("device_count", 0),
934
- link_count=item.get("link_count", 0),
935
- devices=item.get("devices", []),
936
- links=item.get("links", []),
937
- capability_tags=item.get("capability_tags", []),
938
- topology_tags=item.get("topology_tags", []),
939
- preferred_roles=item.get("preferred_roles", []),
940
- trust_level=item.get("trust_level", "trusted"),
941
- origin=item.get("origin", "cisco-local"),
942
- role=item.get("role", "primary"),
943
- prototype_eligible=bool(item.get("prototype_eligible", True)),
944
- license_or_permission=item.get("license_or_permission", "local-cisco"),
945
- promotion_status=item.get("promotion_status", "validated_primary"),
946
- validation_status=item.get("validation_status", "validated"),
947
- packet_tracer_version=item.get("packet_tracer_version", item.get("version", "")),
948
- wireless_mode_tags=item.get("wireless_mode_tags", []),
949
- donor_eligible=bool(item.get("donor_eligible", item.get("prototype_eligible", True))),
950
- device_families=item.get("device_families", []),
951
- model_families=item.get("model_families", []),
952
- port_media_types=item.get("port_media_types", []),
953
- service_support=item.get("service_support", []),
954
- iot_roles=item.get("iot_roles", []),
955
- runtime_features=item.get("runtime_features", []),
956
- donor_graph_fingerprint=item.get("donor_graph_fingerprint", {}),
957
- apply_safety_level=item.get("apply_safety_level", "reference-known"),
958
- promotion_evidence=item.get("promotion_evidence", []),
959
- validated_edit_capabilities=item.get("validated_edit_capabilities", []),
960
- acceptance_notes=item.get("acceptance_notes", []),
961
- archetype_tags=item.get("archetype_tags", []),
962
- acceptance_fixtures=item.get("acceptance_fixtures", []),
963
- provenance=item.get("provenance", ""),
964
- workspace_validation_status=item.get("workspace_validation_status", ""),
965
- evidence_source=item.get("evidence_source", "inferred"),
966
- )
967
-
968
-
969
- def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
970
- enriched: list[dict[str, Any]] = []
971
- for item in items:
972
- if "error" in item:
973
- enriched.append(item)
974
- continue
975
- new_item = dict(item)
976
- new_item["capability_tags"] = infer_capability_tags(new_item)
977
- new_item["topology_tags"] = infer_topology_tags(new_item)
978
- new_item["preferred_roles"] = infer_preferred_roles(new_item)
979
- new_item["wireless_mode_tags"] = infer_wireless_mode_tags(new_item)
980
- new_item.setdefault("trust_level", "trusted")
981
- new_item.setdefault("origin", "cisco-local")
982
- new_item.setdefault("role", "primary")
983
- new_item.setdefault("prototype_eligible", True)
984
- new_item.setdefault("license_or_permission", "local-cisco")
985
- new_item.setdefault("promotion_status", "validated_primary")
986
- new_item.setdefault("validation_status", "validated")
987
- new_item.setdefault("packet_tracer_version", new_item.get("version", ""))
988
- new_item.setdefault("donor_eligible", bool(new_item.get("prototype_eligible", True)))
989
- new_item.setdefault("device_families", infer_device_families(new_item))
990
- new_item.setdefault("model_families", infer_model_families(new_item))
991
- new_item.setdefault("port_media_types", infer_port_media_types(new_item))
992
- new_item.setdefault("service_support", infer_service_support(new_item))
993
- new_item.setdefault("iot_roles", infer_iot_roles(new_item))
994
- new_item["runtime_features"] = sorted(set(new_item.get("runtime_features", [])) | set(infer_runtime_features(new_item)))
995
- new_item.setdefault("donor_graph_fingerprint", infer_donor_graph_fingerprint(new_item))
996
- new_item.setdefault("apply_safety_level", infer_apply_safety_level(new_item))
997
- new_item.setdefault("archetype_tags", infer_archetype_tags(new_item))
998
- new_item.setdefault("validated_edit_capabilities", infer_validated_edit_capabilities(new_item))
999
- new_item.setdefault("acceptance_fixtures", [])
1000
- new_item.setdefault("provenance", "")
1001
- new_item.setdefault("promotion_evidence", infer_promotion_evidence(new_item))
1002
- new_item.setdefault("acceptance_notes", infer_acceptance_notes(new_item))
1003
- new_item.setdefault("workspace_validation_status", "")
1004
- new_item.setdefault("evidence_source", "inferred")
1005
- enriched.append(new_item)
1006
- return enriched
1007
-
1008
-
1009
- @lru_cache(maxsize=8)
1010
- def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
1011
- raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
1012
- items = enrich_catalog_items(raw_items)
1013
- saves_root = get_packet_tracer_saves_root()
1014
- return tuple(
1015
- _descriptor_from_item(
1016
- {
1017
- **item,
1018
- "path": str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
1019
- }
1020
- )
1021
- for item in items
1022
- if "error" not in item
1023
- )
1024
-
1025
-
1026
- def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
1027
- return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
1028
-
1029
-
1030
- def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
1031
- xml = decode_pkt_modern(path.read_bytes())
1032
- root = ET.fromstring(xml)
1033
- from pkt_editor import inventory_root
1034
-
1035
- inventory = inventory_root(root)
1036
- workspace_result = inspect_workspace_integrity(root)
1037
- devices = []
1038
- indexed_devices = root.findall(".//DEVICES/DEVICE")
1039
- index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(indexed_devices)}
1040
- save_ref_to_name = {
1041
- device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
1042
- for device in indexed_devices
1043
- if device.findtext("./ENGINE/SAVE_REF_ID", default="")
1044
- }
1045
- for device in root.findall(".//DEVICES/DEVICE"):
1046
- type_node = device.find("./ENGINE/TYPE")
1047
- devices.append(
1048
- {
1049
- "name": device.findtext("./ENGINE/NAME", default=""),
1050
- "type": device.findtext("./ENGINE/TYPE", default=""),
1051
- "model": type_node.get("model", "") if type_node is not None else "",
1052
- }
1053
- )
1054
- links = []
1055
- for link in root.findall(".//LINKS/LINK"):
1056
- cable = link.find("./CABLE")
1057
- ports = cable.findall("PORT") if cable is not None else []
1058
- from_ref = cable.findtext("FROM", default="") if cable is not None else ""
1059
- to_ref = cable.findtext("TO", default="") if cable is not None else ""
1060
- links.append(
1061
- {
1062
- "from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
1063
- "to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
1064
- "type": link.findtext("./TYPE", default=""),
1065
- "cable_type": cable.findtext("./TYPE", default="") if cable is not None else "",
1066
- "media": cable.findtext("./TYPE", default="") if cable is not None else "",
1067
- "ports": [port.text or "" for port in ports[:2]],
1068
- }
1069
- )
1070
- service_aliases = {
1071
- "dhcp_server": "dhcp",
1072
- "dns_server": "dns",
1073
- "http_server": "http",
1074
- "https_server": "https",
1075
- "ftp_server": "ftp",
1076
- "tftp_server": "tftp",
1077
- "ntp_server": "ntp",
1078
- "email_server": "email",
1079
- "syslog_server": "syslog",
1080
- "acs_server": "aaa",
1081
- }
1082
- service_support = sorted(
1083
- {
1084
- service_aliases[service]
1085
- for services in inventory.get("services", {}).values()
1086
- for service in services
1087
- if service in service_aliases
1088
- }
1089
- )
1090
- iot_roles = sorted({str(entry.get("role", "")).strip() for entry in inventory.get("iot", {}).values() if str(entry.get("role", "")).strip()})
1091
- return {
1092
- "path": str(path),
1093
- "relative_path": relative_path,
1094
- "version": root.findtext("./VERSION", default=""),
1095
- "device_count": len(devices),
1096
- "link_count": len(links),
1097
- "devices": devices,
1098
- "links": links,
1099
- "origin": origin,
1100
- "trust_level": "reference-only" if origin != "cisco-local" else "trusted",
1101
- "role": "reference" if origin != "cisco-local" else "primary",
1102
- "prototype_eligible": prototype_eligible,
1103
- "service_support": service_support,
1104
- "iot_roles": iot_roles,
1105
- "workspace_validation": {
1106
- "workspace_mode": workspace_result.workspace_mode,
1107
- "logical_status": workspace_result.logical_status,
1108
- "physical_status": workspace_result.physical_status,
1109
- "blocking_issue_count": len(workspace_result.blocking_issues),
1110
- "blocking_issues": workspace_result.blocking_issues,
1111
- },
1112
- }
1113
-
1114
-
1115
- def summarize_pkt_descriptor(
1116
- path: Path,
1117
- relative_path: str | None = None,
1118
- *,
1119
- origin: str = "external-reference",
1120
- prototype_eligible: bool = False,
1121
- trust_level: str | None = None,
1122
- role: str | None = None,
1123
- license_or_permission: str | None = None,
1124
- promotion_status: str | None = None,
1125
- validation_status: str | None = None,
1126
- donor_eligible: bool | None = None,
1127
- ) -> SampleDescriptor:
1128
- item = _summarize_pkt(path, relative_path or path.name, origin, prototype_eligible)
1129
- if trust_level is not None:
1130
- item["trust_level"] = trust_level
1131
- if role is not None:
1132
- item["role"] = role
1133
- if license_or_permission is not None:
1134
- item["license_or_permission"] = license_or_permission
1135
- if promotion_status is not None:
1136
- item["promotion_status"] = promotion_status
1137
- if validation_status is not None:
1138
- item["validation_status"] = validation_status
1139
- if donor_eligible is not None:
1140
- item["donor_eligible"] = donor_eligible
1141
- enriched = enrich_catalog_items([item])[0]
1142
- return _descriptor_from_item(enriched)
1143
-
1144
-
1145
- @lru_cache(maxsize=4)
1146
- def _load_reference_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1147
- items: list[dict[str, Any]] = []
1148
- for root_str in roots_key:
1149
- root = Path(root_str)
1150
- if not root.exists():
1151
- continue
1152
- for pkt_path in sorted(root.rglob("*.pkt")):
1153
- try:
1154
- rel = str(pkt_path.relative_to(root))
1155
- item = _summarize_pkt(pkt_path, rel, "external-reference", False)
1156
- validation = validate_external_sample_summary(item, curated=False)
1157
- item["license_or_permission"] = _detect_license_or_permission(root)
1158
- item["validation_status"] = validation.validation_status
1159
- item["promotion_status"] = validation.promotion_status
1160
- item["donor_eligible"] = validation.donor_eligible
1161
- items.append(item)
1162
- except Exception:
1163
- continue
1164
- enriched = enrich_catalog_items(items)
1165
- return tuple(_descriptor_from_item(item) for item in enriched)
1166
-
1167
-
1168
- def load_reference_catalog(reference_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1169
- if not reference_roots:
1170
- return []
1171
- roots_key = tuple(sorted(str(path) for path in reference_roots))
1172
- return list(_load_reference_catalog_cached(roots_key))
1173
-
1174
-
1175
- @lru_cache(maxsize=4)
1176
- def _load_curated_donor_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1177
- items: list[dict[str, Any]] = []
1178
- registry = load_curated_donor_registry()
1179
- for root_str in roots_key:
1180
- root = Path(root_str)
1181
- if not root.exists():
1182
- continue
1183
- license_or_permission = _detect_license_or_permission(root)
1184
- for pkt_path in sorted(root.rglob("*.pkt")):
1185
- try:
1186
- rel = str(pkt_path.relative_to(root))
1187
- item = _summarize_pkt(pkt_path, rel, "external-curated", True)
1188
- validation = validate_external_sample_summary(item, curated=True)
1189
- item["trust_level"] = "curated" if validation.donor_eligible else "reference-only"
1190
- item["role"] = "secondary" if validation.donor_eligible else "reference"
1191
- item["prototype_eligible"] = validation.donor_eligible
1192
- item["license_or_permission"] = license_or_permission
1193
- item["validation_status"] = validation.validation_status
1194
- item["promotion_status"] = validation.promotion_status
1195
- item["packet_tracer_version"] = validation.packet_tracer_version
1196
- item["donor_eligible"] = validation.donor_eligible
1197
- registry_entry = registry.get(_normalize_registry_key(rel)) or registry.get(_normalize_registry_key(pkt_path.name))
1198
- item = _merge_curated_registry_entry(item, registry_entry, validation)
1199
- items.append(item)
1200
- except Exception:
1201
- continue
1202
- enriched = enrich_catalog_items(items)
1203
- return tuple(_descriptor_from_item(item) for item in enriched)
1204
-
1205
-
1206
- def load_curated_donor_catalog(donor_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1207
- if not donor_roots:
1208
- return []
1209
- roots_key = tuple(sorted(str(path) for path in donor_roots))
1210
- return list(_load_curated_donor_catalog_cached(roots_key))
1211
-
1212
-
1213
- def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[ReferencePattern]:
1214
- patterns: list[ReferencePattern] = []
1215
- for sample in samples:
1216
- patterns.append(
1217
- ReferencePattern(
1218
- relative_path=sample.relative_path,
1219
- origin=sample.origin,
1220
- capability_tags=sample.capability_tags,
1221
- topology_tags=sample.topology_tags,
1222
- device_summary=sample.normalized_device_counts(),
1223
- wireless_mode_tags=sample.wireless_mode_tags,
1224
- )
1225
- )
1226
- return patterns
1227
-
1228
-
1229
- def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
1230
- enriched = enrich_catalog_items(items)
1231
- compact_items: list[dict[str, Any]] = []
1232
- for item in enriched:
1233
- saved = dict(item)
1234
- saved.pop("path", None)
1235
- compact_items.append(saved)
1236
- (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
1237
- root_label = "<PACKET_TRACER_SAVES_ROOT>"
1238
- lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
1239
- for item in enriched:
1240
- if "error" in item:
1241
- lines.append(f"- `{item['relative_path']}`")
1242
- lines.append(f" decode error: `{item['error']}`")
1243
- continue
1244
- labels = ", ".join(f"{device['name']} ({device['type']}/{device['model']})" for device in item["devices"])
1245
- lines.append(f"- `{item['relative_path']}`")
1246
- lines.append(f" version: `{item['version']}`, devices: `{item['device_count']}`, links: `{item['link_count']}`")
1247
- lines.append(f" tags: {', '.join(item.get('capability_tags', []))}")
1248
- lines.append(f" topology: {', '.join(item.get('topology_tags', []))}")
1249
- lines.append(f" devices: {labels}")
1250
- (md_path or DEFAULT_CATALOG_MD).write_text("\n".join(lines) + "\n", encoding="utf-8")
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+ import xml.etree.ElementTree as ET
10
+
11
+ from packet_tracer_env import (
12
+ donor_compatibility,
13
+ donor_tier_is_accepted,
14
+ get_packet_tracer_saves_root,
15
+ get_packet_tracer_target_version,
16
+ )
17
+ from pkt_codec import decode_pkt_auto, decode_pkt_modern, parse_pkt_xml
18
+ from workspace_repair import inspect_workspace_integrity
19
+
20
+ SCRIPT_DIR = Path(__file__).resolve().parent
21
+ SKILL_ROOT = SCRIPT_DIR.parent
22
+ DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
23
+ DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
24
+ DEFAULT_CURATED_DONOR_REGISTRY = SKILL_ROOT / "references" / "curated-donor-registry.json"
25
+
26
+ CAPABILITY_KEYWORDS = {
27
+ "vlan": ["vlan", "trunk", "access"],
28
+ "trunk": ["trunk"],
29
+ "access_port": ["access"],
30
+ "dhcp_pool": ["dhcp", "reservation", "apipa"],
31
+ "router_dhcp": ["dhcp", "reservation", "apipa"],
32
+ "server_dhcp": ["dhcp", "reservation", "apipa"],
33
+ "dhcpv6_stateful": ["dhcpv6", "stateful_dhcpv6"],
34
+ "dhcpv6_stateless": ["stateless_dhcpv6"],
35
+ "ipv6_slaac": ["slaac"],
36
+ "ipv6_prefix_delegation": ["prefix_delegation"],
37
+ "ipv6_dns_aaaa": ["aaaa"],
38
+ "ipv6_tunneling": ["ipv6ip", "ipv6 tunneling"],
39
+ "isatap": ["isatap"],
40
+ "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
41
+ "ospf": ["ospf"],
42
+ "ospfv2": ["ospf", "ospfv2", "single-area ospf"],
43
+ "ospfv3": ["ospfv3", "ipv6_ospf"],
44
+ "eigrp": ["eigrp"],
45
+ "eigrp_ipv4": ["eigrp", "ipv4 eigrp"],
46
+ "eigrp_ipv6": ["ipv6_eigrp"],
47
+ "rip": ["rip"],
48
+ "ripv2": ["ripv2", "rip"],
49
+ "ripng": ["ripng", "ipv6 rip"],
50
+ "hsrp": ["hsrp"],
51
+ "nat": ["nat"],
52
+ "nat_static": ["static nat"],
53
+ "nat_dynamic": ["dynamic nat"],
54
+ "pat": ["pat", "overload"],
55
+ "static_route": ["static route", "ip route"],
56
+ "default_route": ["default route"],
57
+ "dhcp_relay": ["dhcp relay", "helper-address"],
58
+ "ssh_ios": ["ssh"],
59
+ "ntp_ios": ["ntp"],
60
+ "syslog_ios": ["syslog"],
61
+ "acl": ["acl", "access-list"],
62
+ "dai": ["dai", "dynamic arp inspection"],
63
+ "dot1x": ["dot1x", "802.1x", "port-based nac"],
64
+ "lldp": ["lldp"],
65
+ "rep": ["rep_"],
66
+ "snmp": ["snmp"],
67
+ "netflow": ["netflow"],
68
+ "span": ["rspan", "monitor session", "span session"],
69
+ "qos": ["qos"],
70
+ "port_security": ["port security", "port-security"],
71
+ "bgp": ["bgp"],
72
+ "stp": ["stp", "spanning tree", "spanning-tree"],
73
+ "rstp": ["rstp", "rapid-pvst"],
74
+ "etherchannel": ["etherchannel", "port-channel", "channel-group"],
75
+ "lacp": ["lacp"],
76
+ "pagp": ["pagp"],
77
+ "vtp": ["vtp"],
78
+ "dtp": ["dtp"],
79
+ "vpn": ["vpn", "ipsec", "gre"],
80
+ "ipsec": ["ipsec", "ike"],
81
+ "gre": ["gre"],
82
+ "ppp": ["ppp", "chap", "pap"],
83
+ "wan": ["wan", "cloud", "serial", "pppoe", "ppp"],
84
+ "security_edge": ["asa", "firewall", "security appliance"],
85
+ "asa_acl_nat": ["asa_acl_nat"],
86
+ "asa_service_policy": ["asa_service_policy", "service policy"],
87
+ "clientless_vpn": ["clientless_vpn"],
88
+ "cbac": ["cbac"],
89
+ "zfw": ["zfw", "zone based firewall"],
90
+ "sniffer": ["sniffer"],
91
+ "multilayer_switching": ["multilayer", "layer 3", "3560", "svi"],
92
+ "wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
93
+ "wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
94
+ "wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
95
+ "wlc": ["wlc", "wireless lan controller"],
96
+ "wpa_enterprise": ["wpa_radius", "wpa2_enterprise"],
97
+ "wep": ["wep"],
98
+ "guest_wifi": ["guest"],
99
+ "beamforming": ["beamforming"],
100
+ "meraki": ["meraki"],
101
+ "cellular_5g": ["5g", "cellular", "cell tower"],
102
+ "bluetooth": ["bluetooth"],
103
+ "network_controller": ["network controller", "netcon"],
104
+ "python_programming": ["python"],
105
+ "javascript_programming": ["javascript"],
106
+ "blockly_programming": ["blockly"],
107
+ "tcp_udp_app": ["tcp_test_app", "udp_test_app"],
108
+ "vm_iox": ["iox", "uploading_and_running_vm"],
109
+ "voip": ["voip"],
110
+ "ip_phone": ["ip phone", "phone"],
111
+ "call_manager": ["call manager", "telephony", "voip"],
112
+ "linksys_voice": ["voip linksys"],
113
+ "mqtt": ["mqtt"],
114
+ "real_http": ["real-http", "real http"],
115
+ "real_websocket": ["real-websocket", "real websocket"],
116
+ "visual_scripting": ["visual-scripting", "visual scripting"],
117
+ "ptp": ["ptp"],
118
+ "profinet": ["profinet"],
119
+ "l2nat": ["l2nat"],
120
+ "cyberobserver": ["cyberobserver"],
121
+ "industrial_firewall": ["isa3000", "industrial cybersecurity"],
122
+ "coaxial": ["coaxial"],
123
+ "cable_dsl": ["cable modem", "dsl modem"],
124
+ "central_office": ["centraloffice", "central office"],
125
+ "cell_tower": ["cell tower"],
126
+ "power_distribution": ["power distribution"],
127
+ "hot_swappable": ["hotswappable", "hot swappable"],
128
+ "ios_license": ["ios_15", "license"],
129
+ "ntp": ["ntp"],
130
+ "dns": ["dns"],
131
+ "server_dns": ["dns"],
132
+ "server_http": ["http", "https", "websocket"],
133
+ "server_ftp": ["ftp", "tftp"],
134
+ "ftp_http_https": ["ftp", "http", "https", "websocket"],
135
+ "iot": ["iot", "sensor", "led", "arduino", "mqtt", "environment"],
136
+ "switching": ["switch", "switching", "lldp", "rep"],
137
+ "router_on_a_stick": ["router-on-a-stick", "dot1q"],
138
+ "host_server": ["server", "pc", "client", "host"],
139
+ "management_vlan": ["management", "vlan99", "telnet"],
140
+ "telnet": ["telnet", "terminal server"],
141
+ "tablet": ["tablet", "pda"],
142
+ "printer": ["printer"],
143
+ }
144
+
145
+ REPORT_ONLY_CAPABILITIES = {
146
+ "ipv6_slaac",
147
+ "dhcpv6_stateful",
148
+ "dhcpv6_stateless",
149
+ "ipv6_prefix_delegation",
150
+ "ipv6_dns_aaaa",
151
+ "ipv6_tunneling",
152
+ "isatap",
153
+ "ospfv3",
154
+ "eigrp_ipv6",
155
+ "ripng",
156
+ "hsrp",
157
+ "dhcp_snooping",
158
+ "dai",
159
+ "dot1x",
160
+ "lldp",
161
+ "rep",
162
+ "snmp",
163
+ "netflow",
164
+ "span",
165
+ "qos",
166
+ "port_security",
167
+ "bgp",
168
+ "stp",
169
+ "rstp",
170
+ "etherchannel",
171
+ "lacp",
172
+ "pagp",
173
+ "vtp",
174
+ "dtp",
175
+ "ospfv2",
176
+ "eigrp_ipv4",
177
+ "ripv2",
178
+ "static_route",
179
+ "default_route",
180
+ "dhcp_relay",
181
+ "nat_static",
182
+ "nat_dynamic",
183
+ "pat",
184
+ "ssh_ios",
185
+ "ntp_ios",
186
+ "syslog_ios",
187
+ "asa_acl_nat",
188
+ "asa_service_policy",
189
+ "clientless_vpn",
190
+ "cbac",
191
+ "zfw",
192
+ "sniffer",
193
+ "wlc",
194
+ "wpa_enterprise",
195
+ "wep",
196
+ "guest_wifi",
197
+ "beamforming",
198
+ "meraki",
199
+ "cellular_5g",
200
+ "bluetooth",
201
+ "network_controller",
202
+ "python_programming",
203
+ "javascript_programming",
204
+ "blockly_programming",
205
+ "tcp_udp_app",
206
+ "vm_iox",
207
+ "voip",
208
+ "ip_phone",
209
+ "call_manager",
210
+ "linksys_voice",
211
+ "mqtt",
212
+ "real_http",
213
+ "real_websocket",
214
+ "visual_scripting",
215
+ "ptp",
216
+ "profinet",
217
+ "l2nat",
218
+ "cyberobserver",
219
+ "industrial_firewall",
220
+ "coaxial",
221
+ "cable_dsl",
222
+ "central_office",
223
+ "cell_tower",
224
+ "power_distribution",
225
+ "hot_swappable",
226
+ "ios_license",
227
+ }
228
+
229
+ TYPE_NORMALIZATION = {
230
+ "pc": "PC",
231
+ "pc-pt": "PC",
232
+ "server": "Server",
233
+ "server-pt": "Server",
234
+ "router": "Router",
235
+ "switch": "Switch",
236
+ "multilayerswitch": "MultiLayerSwitch",
237
+ "layer3switch": "MultiLayerSwitch",
238
+ "wirelessrouter": "WirelessRouter",
239
+ "wirelessrouternewgeneration": "WirelessRouter",
240
+ "homegateway": "WirelessRouter",
241
+ # A controller is not an access point: it manages them, carries gigabit
242
+ # uplinks rather than a radio, and a prompt asking for one wants that
243
+ # device. Folding it here meant the planner's `WirelessLanController` kind
244
+ # matched nothing in any donor, and `1 wlc 2 access point qur` was refused
245
+ # by the very lab that carries a WLC.
246
+ "wirelesslancontroller": "WirelessLanController",
247
+ "lightweightaccesspoint": "LightWeightAccessPoint",
248
+ "accesspoint": "LightWeightAccessPoint",
249
+ "pda": "Tablet",
250
+ "tabletpc": "Tablet",
251
+ "tablet": "Tablet",
252
+ "laptop": "Laptop",
253
+ "printer": "Printer",
254
+ "smartphone": "Smartphone",
255
+ "mcucomponent": "IoT",
256
+ "networkcontroller": "NetworkController",
257
+ "centralofficeserver": "CentralOfficeServer",
258
+ "celltower": "CellTower",
259
+ }
260
+
261
+ LICENSE_FILENAMES = [
262
+ "LICENSE",
263
+ "LICENSE.txt",
264
+ "LICENSE.md",
265
+ "COPYING",
266
+ "COPYING.txt",
267
+ "NOTICE",
268
+ ]
269
+
270
+
271
+ @dataclass
272
+ class SampleDescriptor:
273
+ path: str
274
+ relative_path: str
275
+ version: str
276
+ device_count: int
277
+ link_count: int
278
+ devices: list[dict[str, Any]]
279
+ links: list[dict[str, Any]]
280
+ capability_tags: list[str]
281
+ topology_tags: list[str]
282
+ preferred_roles: list[str]
283
+ trust_level: str = "trusted"
284
+ origin: str = "cisco-local"
285
+ role: str = "primary"
286
+ prototype_eligible: bool = True
287
+ license_or_permission: str = "local-cisco"
288
+ promotion_status: str = "validated_primary"
289
+ validation_status: str = "validated"
290
+ packet_tracer_version: str = ""
291
+ wireless_mode_tags: list[str] = field(default_factory=list)
292
+ donor_eligible: bool = True
293
+ device_families: list[str] = field(default_factory=list)
294
+ model_families: list[str] = field(default_factory=list)
295
+ port_media_types: list[str] = field(default_factory=list)
296
+ service_support: list[str] = field(default_factory=list)
297
+ iot_roles: list[str] = field(default_factory=list)
298
+ runtime_features: list[str] = field(default_factory=list)
299
+ donor_graph_fingerprint: dict[str, Any] = field(default_factory=dict)
300
+ apply_safety_level: str = "reference-known"
301
+ promotion_evidence: list[str] = field(default_factory=list)
302
+ validated_edit_capabilities: list[str] = field(default_factory=list)
303
+ acceptance_notes: list[str] = field(default_factory=list)
304
+ archetype_tags: list[str] = field(default_factory=list)
305
+ acceptance_fixtures: list[str] = field(default_factory=list)
306
+ provenance: str = ""
307
+ workspace_validation_status: str = ""
308
+ evidence_source: str = "inferred"
309
+
310
+ def normalized_device_counts(self) -> dict[str, int]:
311
+ counts: dict[str, int] = {}
312
+ for device in self.devices:
313
+ normalized = normalize_device_type(device.get("type", ""))
314
+ if normalized:
315
+ counts[normalized] = counts.get(normalized, 0) + 1
316
+ return counts
317
+
318
+
319
+ @dataclass
320
+ class SampleCandidate:
321
+ sample: SampleDescriptor
322
+ capability_score: int
323
+ topology_score: int
324
+ total_score: int
325
+ reasons: list[str]
326
+
327
+
328
+ @dataclass
329
+ class ReferencePattern:
330
+ relative_path: str
331
+ origin: str
332
+ capability_tags: list[str]
333
+ topology_tags: list[str]
334
+ device_summary: dict[str, int]
335
+ wireless_mode_tags: list[str] = field(default_factory=list)
336
+
337
+
338
+ @dataclass
339
+ class DonorValidationResult:
340
+ packet_tracer_version: str
341
+ validation_status: str
342
+ promotion_status: str
343
+ donor_eligible: bool
344
+ issues: list[str]
345
+
346
+
347
+ @dataclass
348
+ class CuratedDonorRecord:
349
+ sample: SampleDescriptor
350
+ validation: DonorValidationResult
351
+
352
+
353
+ def _normalize_registry_key(value: str) -> str:
354
+ return value.strip().replace("\\", "/").lower()
355
+
356
+
357
+ @lru_cache(maxsize=4)
358
+ def _load_curated_donor_registry_cached(path_str: str) -> dict[str, dict[str, Any]]:
359
+ path = Path(path_str)
360
+ if not path.exists():
361
+ return {}
362
+ payload = json.loads(path.read_text(encoding="utf-8"))
363
+ donors = list(payload.get("donors", [])) if isinstance(payload, dict) else []
364
+ registry: dict[str, dict[str, Any]] = {}
365
+ for item in donors:
366
+ if not isinstance(item, dict):
367
+ continue
368
+ key = _normalize_registry_key(str(item.get("relative_path", "")))
369
+ if not key:
370
+ continue
371
+ registry[key] = dict(item)
372
+ return registry
373
+
374
+
375
+ def load_curated_donor_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
376
+ return dict(_load_curated_donor_registry_cached(str(path or DEFAULT_CURATED_DONOR_REGISTRY)))
377
+
378
+
379
+ def normalize_device_type(raw_type: str) -> str:
380
+ key = raw_type.strip().lower().replace(" ", "")
381
+ return TYPE_NORMALIZATION.get(key, raw_type.strip())
382
+
383
+
384
+ def _normalized_counts_for_item(item: dict[str, Any]) -> dict[str, int]:
385
+ counts: dict[str, int] = {}
386
+ for device in item.get("devices", []):
387
+ normalized = normalize_device_type(device.get("type", ""))
388
+ if normalized:
389
+ counts[normalized] = counts.get(normalized, 0) + 1
390
+ return counts
391
+
392
+
393
+ #
394
+ # Evidence read from device configuration rather than from the file name.
395
+ #
396
+ # Every keyword capability below `CAPABILITY_KEYWORDS` is matched against the
397
+ # path, so a lab called `telnet.pkt` counts as having telnet and a lab whose
398
+ # switches actually carry `line vty` with `transport input telnet` does not.
399
+ # That is how a campus donor with nineteen `line vty` blocks and nineteen
400
+ # `interface Vlan` blocks was credited with no capabilities at all, which in
401
+ # turn refused every management/telnet prompt as "missing critical capability
402
+ # coverage".
403
+ #
404
+ # Only capabilities whose evidence is unambiguous in config text belong here.
405
+ #
406
+ # Measured across all 292 bundled samples, filename credit against real config:
407
+ #
408
+ # capability name-only correct missed
409
+ # rip 5 1 36
410
+ # static_route 0 0 22
411
+ # acl 5 1 18
412
+ # nat 6 4 7
413
+ # eigrp 2 8 4
414
+ # hsrp 3 0 0
415
+ #
416
+ # Both directions are wrong. Every `hsrp` credit was a filename coincidence, and
417
+ # 36 labs that configure RIP were invisible. Config evidence is *added* to the
418
+ # filename tags rather than replacing them: withdrawing credit is a larger
419
+ # change and needs its own measurement, but a capability the config proves is
420
+ # never wrong to record.
421
+ CONFIG_EVIDENCE_PATTERNS: dict[str, tuple[str, ...]] = {
422
+ "telnet": (r"^\s*line vty\b", r"transport input (?:all|telnet)"),
423
+ "management_vlan": (r"^\s*interface vlan\s*\d+", r"^\s*ip default-gateway\b"),
424
+ "ospf": (r"^\s*router ospf\b",),
425
+ "ospfv2": (r"^\s*router ospf\b",),
426
+ "eigrp": (r"^\s*router eigrp\b",),
427
+ "eigrp_ipv4": (r"^\s*router eigrp\b",),
428
+ "rip": (r"^\s*router rip\b",),
429
+ "ripv2": (r"^\s*router rip\b.*|^\s*version 2\b",),
430
+ "bgp": (r"^\s*router bgp\b",),
431
+ "nat": (r"^\s*ip nat\b",),
432
+ "pat": (r"^\s*ip nat inside source list .*overload",),
433
+ "static_route": (r"^\s*ip route\b",),
434
+ "default_route": (r"^\s*ip route 0\.0\.0\.0 0\.0\.0\.0",),
435
+ "acl": (r"^\s*(?:ip )?access-list\b",),
436
+ "port_security": (r"switchport port-security",),
437
+ "etherchannel": (r"^\s*channel-group\b",),
438
+ "hsrp": (r"^\s*standby \d+ ip\b",),
439
+ "vtp": (r"^\s*vtp (?:mode|domain)\b",),
440
+ "stp": (r"^\s*spanning-tree mode\b",),
441
+ "dhcp_relay": (r"^\s*ip helper-address\b",),
442
+ "dhcp_pool": (r"^\s*ip dhcp pool\b",),
443
+ "router_dhcp": (r"^\s*ip dhcp pool\b",),
444
+ "trunk": (r"switchport mode trunk",),
445
+ "access_port": (r"switchport mode access",),
446
+ "router_on_a_stick": (r"^\s*encapsulation dot1q\b",),
447
+ "ssh_ios": (r"^\s*ip ssh\b", r"transport input ssh"),
448
+ "ntp_ios": (r"^\s*ntp server\b",),
449
+ "syslog_ios": (r"^\s*logging (?:host|\d+\.)",),
450
+ "snmp": (r"^\s*snmp-server\b",),
451
+ "lldp": (r"^\s*lldp run\b",),
452
+ "dhcp_snooping": (r"^\s*ip dhcp snooping\b",),
453
+ "dai": (r"^\s*ip arp inspection\b",),
454
+ "dot1x": (r"^\s*dot1x\b",),
455
+ "vlan": (r"^\s*interface vlan\s*\d+", r"switchport access vlan"),
456
+ }
457
+
458
+
459
+ def config_capability_tags(config_text: str) -> set[str]:
460
+ """Capabilities a lab demonstrably configures, not ones its name suggests."""
461
+ if not config_text:
462
+ return set()
463
+ lowered = config_text.lower()
464
+ found: set[str] = set()
465
+ for capability, patterns in CONFIG_EVIDENCE_PATTERNS.items():
466
+ if any(re.search(pattern, lowered, flags=re.MULTILINE) for pattern in patterns):
467
+ found.add(capability)
468
+ return found
469
+
470
+
471
+ def infer_capability_tags(item: dict[str, Any]) -> list[str]:
472
+ tags: set[str] = set(item.get("config_capability_tags") or ())
473
+ rel = item.get("relative_path", "").lower().replace("\\", "/")
474
+ rel_flat = rel.replace("/", " ")
475
+ devices = item.get("devices", [])
476
+ raw_types = {str(device.get("type", "")).strip() for device in devices if str(device.get("type", "")).strip()}
477
+ normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
478
+ router_count = normalized_types.count("Router")
479
+ switch_count = normalized_types.count("Switch")
480
+ host_count = sum(1 for dtype in normalized_types if dtype in {"PC", "Server"})
481
+
482
+ for tag, words in CAPABILITY_KEYWORDS.items():
483
+ if any(word in rel or word in rel_flat for word in words):
484
+ tags.add(tag)
485
+ if router_count >= 2:
486
+ tags.add("multi_router")
487
+ # Two routers with serial hardware is what a leased line needs: one has
488
+ # nothing to reach. Ranking had no way to tell these apart from any other
489
+ # multi-router lab, so a WAN prompt was answered with firewall samples that
490
+ # could only ever produce the link over copper.
491
+ if (
492
+ sum(
493
+ 1
494
+ for device, dtype in zip(devices, normalized_types)
495
+ if dtype == "Router" and int(device.get("serial_ports") or 0) > 0
496
+ )
497
+ >= 2
498
+ ):
499
+ tags.add("serial_wan")
500
+ tags.add("wan")
501
+ if switch_count >= 1:
502
+ tags.add("switching")
503
+ if host_count >= 1:
504
+ tags.add("host_server")
505
+ if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
506
+ tags.add("wireless")
507
+ tags.add("wireless_ap")
508
+ if "HomeGateway" in raw_types or any(dtype == "IoT" for dtype in normalized_types):
509
+ tags.add("iot")
510
+ if any(dtype in {"ASA", "Security Appliance"} for dtype in normalized_types) or raw_types & {"ASA", "Security Appliance"}:
511
+ tags.add("security_edge")
512
+ tags.add("acl")
513
+ if any(dtype in {"Cloud", "Cable Modem", "Dsl Modem"} for dtype in normalized_types):
514
+ tags.add("wan")
515
+ if any(dtype == "MultiLayerSwitch" for dtype in normalized_types):
516
+ tags.add("multilayer_switching")
517
+ if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
518
+ tags.add("wireless_client")
519
+ if any(dtype == "Tablet" for dtype in normalized_types):
520
+ tags.add("tablet")
521
+ if any(dtype == "Printer" for dtype in normalized_types):
522
+ tags.add("printer")
523
+ if "telnet" in rel or "terminal server" in rel_flat:
524
+ tags.add("telnet")
525
+ if "management" in rel_flat:
526
+ tags.add("management_vlan")
527
+ return sorted(tags or {"switching"})
528
+
529
+
530
+ def infer_topology_tags(item: dict[str, Any]) -> list[str]:
531
+ tags: set[str] = set()
532
+ rel = item.get("relative_path", "").lower().replace("\\", "/")
533
+ counts = _normalized_counts_for_item(item)
534
+ link_count = int(item.get("link_count", 0))
535
+ switch_count = counts.get("Switch", 0)
536
+ router_count = counts.get("Router", 0)
537
+ wireless_count = counts.get("LightWeightAccessPoint", 0) + counts.get("WirelessRouter", 0)
538
+ server_count = counts.get("Server", 0)
539
+
540
+ if "router_on_a_stick" in item.get("capability_tags", []):
541
+ tags.add("router_on_a_stick")
542
+ if "acl" in item.get("capability_tags", []):
543
+ tags.add("acl_policy")
544
+ if wireless_count:
545
+ tags.add("wireless_edge")
546
+ if server_count:
547
+ tags.add("server_services")
548
+ if switch_count >= 3 and router_count >= 1:
549
+ tags.add("department_lan")
550
+ if switch_count >= 2 and router_count >= 1:
551
+ tags.add("core_access")
552
+ if switch_count >= 2 and link_count >= switch_count:
553
+ tags.add("chain")
554
+ if router_count == 1 and switch_count <= 1 and wireless_count:
555
+ tags.add("small_office")
556
+ if "campus" in rel or "department" in rel or "vlan" in rel:
557
+ tags.add("department_lan")
558
+ return sorted(tags or {"general"})
559
+
560
+
561
+ def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
562
+ roles: list[str] = []
563
+ tags = set(item.get("capability_tags", []))
564
+ if "vlan" in tags:
565
+ roles.append("preferred_vlan")
566
+ if "dhcp_pool" in tags:
567
+ roles.append("preferred_dhcp")
568
+ if {"ospf", "eigrp", "rip", "bgp", "ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route"} & tags:
569
+ roles.append("preferred_routing")
570
+ if {"dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"} & tags:
571
+ roles.append("preferred_ipv4_management")
572
+ if {"stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"} & tags:
573
+ roles.append("preferred_l2_resiliency")
574
+ if {"nat", "acl", "vpn"} & tags:
575
+ roles.append("preferred_security")
576
+ if {"vpn", "ipsec", "gre", "ppp", "multilayer_switching"} & tags:
577
+ roles.append("preferred_wan")
578
+ if "wireless" in tags:
579
+ roles.append("preferred_wireless")
580
+ if "telnet" in tags or "management_vlan" in tags:
581
+ roles.append("preferred_management")
582
+ if "server_dns" in tags or "server_dhcp" in tags:
583
+ roles.append("preferred_server")
584
+ if "iot" in tags:
585
+ roles.append("preferred_iot")
586
+ if not roles:
587
+ roles.append("preferred_general")
588
+ return roles
589
+
590
+
591
+ def infer_wireless_mode_tags(item: dict[str, Any]) -> list[str]:
592
+ normalized_types = [normalize_device_type(device.get("type", "")) for device in item.get("devices", [])]
593
+ tags: set[str] = set()
594
+ if "LightWeightAccessPoint" in normalized_types:
595
+ tags.add("ap_bridge")
596
+ if "WirelessRouter" in normalized_types:
597
+ tags.add("home_router_edge")
598
+ if len(tags) > 1:
599
+ tags.add("mixed")
600
+ return sorted(tags)
601
+
602
+
603
+ def infer_iot_roles(item: dict[str, Any]) -> list[str]:
604
+ roles = {str(role).strip() for role in item.get("iot_roles", []) if str(role).strip()}
605
+ raw_types = {str(device.get("type", "")).strip() for device in item.get("devices", []) if str(device.get("type", "")).strip()}
606
+ normalized_types = {normalize_device_type(device.get("type", "")) for device in item.get("devices", []) if device.get("type")}
607
+ families = set(item.get("device_families", []) or infer_device_families(item))
608
+ has_thing = bool(raw_types & {"MCUComponent", "Board", "Sensor", "Actuator"}) or "iot devices" in families
609
+ has_gateway = "HomeGateway" in raw_types or ("home/wireless routers" in families and has_thing)
610
+ has_server = "Server" in normalized_types or "servers" in families
611
+ if has_thing:
612
+ roles.add("thing")
613
+ if has_gateway:
614
+ roles.add("gateway")
615
+ if has_server:
616
+ roles.add("server")
617
+ return sorted(roles)
618
+
619
+
620
+ def infer_device_families(item: dict[str, Any]) -> list[str]:
621
+ family_map = {
622
+ "Router": "routers",
623
+ "Switch": "switches",
624
+ "MultiLayerSwitch": "multilayer switches",
625
+ "Server": "servers",
626
+ "PC": "end devices",
627
+ "Laptop": "end devices",
628
+ "Tablet": "end devices",
629
+ "Smartphone": "end devices",
630
+ "Printer": "end devices",
631
+ "IpPhone": "end devices",
632
+ "HomeVoip": "end devices",
633
+ "AnalogPhone": "end devices",
634
+ "LightWeightAccessPoint": "access points",
635
+ "WirelessRouter": "home/wireless routers",
636
+ "WirelessRouterNewGeneration": "home/wireless routers",
637
+ "HomeGateway": "home/wireless routers",
638
+ "WirelessLanController": "access points",
639
+ "Power Distribution Device": "pt-specific edge/utility devices",
640
+ "Cloud": "wan/cloud/dsl/cable devices",
641
+ "Cable Modem": "wan/cloud/dsl/cable devices",
642
+ "Dsl Modem": "wan/cloud/dsl/cable devices",
643
+ "Security Appliance": "security devices",
644
+ "ASA": "security devices",
645
+ "NetworkController": "network controllers",
646
+ "CentralOfficeServer": "wan/cloud/dsl/cable devices",
647
+ "CellTower": "wan/cloud/dsl/cable devices",
648
+ "IoT": "iot devices",
649
+ "Sensor": "iot devices",
650
+ "Actuator": "iot devices",
651
+ "MCUComponent": "iot devices",
652
+ }
653
+ families = {
654
+ family_map.get(normalize_device_type(device.get("type", "")), "pt-specific edge/utility devices")
655
+ for device in item.get("devices", [])
656
+ if device.get("type")
657
+ }
658
+ return sorted(families)
659
+
660
+
661
+ def infer_model_families(item: dict[str, Any]) -> list[str]:
662
+ return sorted(
663
+ {
664
+ str(device.get("model", "")).strip()
665
+ for device in item.get("devices", [])
666
+ if str(device.get("model", "")).strip()
667
+ }
668
+ )
669
+
670
+
671
+ def infer_port_media_types(item: dict[str, Any]) -> list[str]:
672
+ media = {
673
+ str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
674
+ for link in item.get("links", [])
675
+ if str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
676
+ }
677
+ return sorted(media)
678
+
679
+
680
+ def infer_service_support(item: dict[str, Any]) -> list[str]:
681
+ tags = set(item.get("capability_tags", []))
682
+ services: set[str] = set()
683
+ service_map = {
684
+ "server_dhcp": "dhcp",
685
+ "dhcp_pool": "dhcp",
686
+ "dns": "dns",
687
+ "server_dns": "dns",
688
+ "server_http": "http",
689
+ "server_https": "https",
690
+ "server_ftp": "ftp",
691
+ "server_tftp": "tftp",
692
+ "ntp": "ntp",
693
+ "server_email": "email",
694
+ "server_syslog": "syslog",
695
+ "server_aaa": "aaa",
696
+ "ftp_http_https": "http",
697
+ "telnet": "telnet",
698
+ "management_vlan": "management",
699
+ }
700
+ for tag in tags:
701
+ service = service_map.get(tag)
702
+ if service:
703
+ services.add(service)
704
+ return sorted(services)
705
+
706
+
707
+ def infer_runtime_features(item: dict[str, Any]) -> list[str]:
708
+ features: set[str] = set()
709
+ tags = set(item.get("capability_tags", []))
710
+ families = set(item.get("device_families", []) or infer_device_families(item))
711
+ if item.get("wireless_mode_tags"):
712
+ features.add("wireless_runtime")
713
+ if item.get("service_support"):
714
+ features.add("service_runtime")
715
+ if infer_iot_roles(item):
716
+ features.add("iot_runtime")
717
+ if families & {"wan/cloud/dsl/cable devices"} or tags & {"wan", "ppp"}:
718
+ features.add("wan_runtime")
719
+ if families & {"security devices"} or tags & {"security_edge", "acl", "nat"}:
720
+ features.add("security_runtime")
721
+ if tags & {"vpn", "ipsec", "gre"}:
722
+ features.add("tunnel_runtime")
723
+ if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
724
+ features.add("ipv6_runtime")
725
+ if tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
726
+ features.add("l2_resiliency_runtime")
727
+ if tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
728
+ features.add("ipv4_routing_management_runtime")
729
+ if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
730
+ features.add("multilayer_runtime")
731
+ if item.get("workspace_validation"):
732
+ features.add("workspace_validated")
733
+ if any(str(link.get("from", "")) and str(link.get("to", "")) for link in item.get("links", [])):
734
+ features.add("named_link_graph")
735
+ return sorted(features)
736
+
737
+
738
+ def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
739
+ tags: set[str] = set()
740
+ topology = set(item.get("topology_tags", []))
741
+ families = set(item.get("device_families", []))
742
+ services = set(item.get("service_support", []))
743
+ wireless_modes = set(item.get("wireless_mode_tags", []))
744
+ iot_roles = set(infer_iot_roles(item))
745
+
746
+ if topology & {"chain", "core_access", "department_lan", "router_on_a_stick"} or (
747
+ families & {"routers", "switches", "multilayer switches"}
748
+ and set(item.get("capability_tags", [])) & {"vlan", "router_on_a_stick", "management_vlan", "telnet"}
749
+ ):
750
+ tags.add("campus/core")
751
+ if services or "servers" in families or "server_services" in topology:
752
+ tags.add("service-heavy")
753
+ if wireless_modes or families & {"access points", "home/wireless routers"}:
754
+ tags.add("wireless-heavy")
755
+ if iot_roles or "iot devices" in families or "HomeGateway" in set(item.get("model_families", [])):
756
+ tags.add("IoT/home gateway")
757
+ if families & {"wan/cloud/dsl/cable devices", "security devices"} or set(item.get("capability_tags", [])) & {
758
+ "vpn",
759
+ "ipsec",
760
+ "gre",
761
+ "ppp",
762
+ "wan",
763
+ "security_edge",
764
+ "multilayer_switching",
765
+ "nat",
766
+ "acl",
767
+ }:
768
+ tags.add("WAN/security edge")
769
+ capability_tags = set(item.get("capability_tags", []))
770
+ if capability_tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ipv6_prefix_delegation", "ipv6_dns_aaaa", "ipv6_tunneling", "isatap", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
771
+ tags.add("IPv6/routing")
772
+ if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
773
+ tags.add("L2 security/monitoring")
774
+ if capability_tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
775
+ tags.add("L2 resiliency/routing")
776
+ if capability_tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
777
+ tags.add("IPv4 routing/management")
778
+ if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
779
+ tags.add("advanced wireless")
780
+ if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families:
781
+ tags.add("automation/controller")
782
+ if capability_tags & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
783
+ tags.add("voice/collaboration")
784
+ if capability_tags & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
785
+ tags.add("industrial IoT")
786
+ return sorted(tags)
787
+
788
+
789
+ def infer_donor_graph_fingerprint(item: dict[str, Any]) -> dict[str, Any]:
790
+ pairs: list[str] = []
791
+ media_types = infer_port_media_types(item)
792
+ for link in item.get("links", []):
793
+ left = str(link.get("from", "")).strip()
794
+ right = str(link.get("to", "")).strip()
795
+ if left and right:
796
+ pairs.append(" <-> ".join(sorted((left, right))))
797
+ return {
798
+ "link_count": int(item.get("link_count", 0)),
799
+ "named_pairs": sorted(dict.fromkeys(pairs)),
800
+ "media_types": media_types,
801
+ }
802
+
803
+
804
+ def infer_apply_safety_level(item: dict[str, Any]) -> str:
805
+ if item.get("origin") == "cisco-local" and item.get("prototype_eligible", True) and str(item.get("version", "")).startswith("9."):
806
+ return "acceptance-verified"
807
+ if item.get("donor_eligible") and item.get("prototype_eligible", False):
808
+ return "safe-open-generate-supported"
809
+ if item.get("capability_tags"):
810
+ return "config-mutation-supported"
811
+ if item.get("devices"):
812
+ return "inventory-supported"
813
+ return "reference-known"
814
+
815
+
816
+ def infer_validated_edit_capabilities(item: dict[str, Any]) -> list[str]:
817
+ capabilities: set[str] = {str(tag) for tag in item.get("capability_tags", []) if str(tag).strip()}
818
+ inferred_iot_roles = set(infer_iot_roles(item))
819
+ families = set(item.get("device_families", []) or infer_device_families(item))
820
+ service_capability_map = {
821
+ "dhcp": "server_dhcp",
822
+ "dns": "server_dns",
823
+ "http": "server_http",
824
+ "https": "server_https",
825
+ "ftp": "server_ftp",
826
+ "tftp": "server_tftp",
827
+ "ntp": "server_ntp",
828
+ "email": "server_email",
829
+ "syslog": "server_syslog",
830
+ "aaa": "server_aaa",
831
+ }
832
+ for service in item.get("service_support", []):
833
+ mapped = service_capability_map.get(str(service))
834
+ if mapped:
835
+ capabilities.add(mapped)
836
+ if item.get("wireless_mode_tags"):
837
+ capabilities.add("wireless_mutation")
838
+ if any(device_family == "end devices" for device_family in item.get("device_families", [])):
839
+ capabilities.add("end_device_mutation")
840
+ if inferred_iot_roles:
841
+ capabilities.add("iot")
842
+ capabilities.add("iot_registration")
843
+ if {"server", "gateway"} & inferred_iot_roles:
844
+ capabilities.add("iot_control")
845
+ if capabilities & {"vpn", "ipsec", "gre"}:
846
+ capabilities.update({"vpn", "ipsec", "gre"})
847
+ if capabilities & {"ppp", "wan"} or families & {"wan/cloud/dsl/cable devices"}:
848
+ capabilities.add("wan")
849
+ if "ppp" in capabilities:
850
+ capabilities.add("ppp")
851
+ if capabilities & {"acl", "nat", "security_edge"} or families & {"security devices"}:
852
+ capabilities.add("security_edge")
853
+ if "multilayer switches" in families or "multilayer_switching" in capabilities:
854
+ capabilities.add("multilayer_switching")
855
+ if item.get("apply_safety_level") == "inventory-supported":
856
+ return []
857
+ capabilities -= REPORT_ONLY_CAPABILITIES
858
+ return sorted(capabilities)
859
+
860
+
861
+ def infer_promotion_evidence(item: dict[str, Any]) -> list[str]:
862
+ evidence: list[str] = []
863
+ version = str(item.get("packet_tracer_version") or item.get("version") or "").strip()
864
+ target_version = get_packet_tracer_target_version()
865
+ if version and version == target_version:
866
+ evidence.append(f"version:{version}")
867
+ validation_status = str(item.get("validation_status", "")).strip()
868
+ if validation_status:
869
+ evidence.append(f"validation:{validation_status}")
870
+ promotion_status = str(item.get("promotion_status", "")).strip()
871
+ if promotion_status:
872
+ evidence.append(f"promotion:{promotion_status}")
873
+ if item.get("donor_eligible"):
874
+ evidence.append("donor_eligible")
875
+ runtime_features = set(item.get("runtime_features", []))
876
+ if "workspace_validated" in runtime_features:
877
+ evidence.append("workspace_validated")
878
+ workspace_validation_status = str(item.get("workspace_validation_status", "")).strip()
879
+ if workspace_validation_status:
880
+ evidence.append(f"workspace_validation:{workspace_validation_status}")
881
+ apply_safety_level = str(item.get("apply_safety_level", "")).strip()
882
+ if apply_safety_level:
883
+ evidence.append(f"apply_safety:{apply_safety_level}")
884
+ validated_edit_capabilities = list(item.get("validated_edit_capabilities", []))
885
+ if validated_edit_capabilities:
886
+ evidence.append(f"edit_capabilities:{len(validated_edit_capabilities)}")
887
+ explicit_fixtures = [str(fixture) for fixture in item.get("acceptance_fixtures", []) if str(fixture).strip()]
888
+ for fixture in explicit_fixtures:
889
+ evidence.append(f"acceptance_fixture:{fixture}")
890
+ archetype_to_fixture = {
891
+ "campus/core": "campus",
892
+ "service-heavy": "service_heavy",
893
+ "IoT/home gateway": "home_iot",
894
+ "WAN/security edge": "wan_security_edge",
895
+ }
896
+ if not explicit_fixtures and promotion_status in {"validated_primary", "validated_compat", "acceptance_verified_curated"}:
897
+ for archetype in item.get("archetype_tags", []):
898
+ fixture = archetype_to_fixture.get(str(archetype))
899
+ if fixture:
900
+ evidence.append(f"acceptance_fixture:{fixture}")
901
+ provenance = str(item.get("provenance", "")).strip()
902
+ if provenance:
903
+ evidence.append(f"provenance:{provenance}")
904
+ evidence_source = str(item.get("evidence_source", "")).strip()
905
+ if evidence_source:
906
+ evidence.append(f"evidence_source:{evidence_source}")
907
+ return evidence
908
+
909
+
910
+ def infer_acceptance_notes(item: dict[str, Any]) -> list[str]:
911
+ notes: list[str] = []
912
+ workspace_validation = item.get("workspace_validation", {})
913
+ if isinstance(workspace_validation, dict):
914
+ logical_status = str(workspace_validation.get("logical_status", "")).strip()
915
+ physical_status = str(workspace_validation.get("physical_status", "")).strip()
916
+ if logical_status or physical_status:
917
+ notes.append(f"workspace logical={logical_status or 'unknown'} physical={physical_status or 'unknown'}")
918
+ wireless_modes = [str(item) for item in item.get("wireless_mode_tags", []) if str(item).strip()]
919
+ if wireless_modes:
920
+ notes.append(f"wireless modes: {', '.join(wireless_modes)}")
921
+ archetypes = [str(item) for item in item.get("archetype_tags", []) if str(item).strip()]
922
+ if archetypes:
923
+ notes.append(f"archetypes: {', '.join(archetypes)}")
924
+ acceptance_fixtures = [str(item) for item in item.get("acceptance_fixtures", []) if str(item).strip()]
925
+ if acceptance_fixtures:
926
+ notes.append(f"acceptance fixtures: {', '.join(acceptance_fixtures)}")
927
+ provenance = str(item.get("provenance", "")).strip()
928
+ if provenance:
929
+ notes.append(f"provenance: {provenance}")
930
+ return notes
931
+
932
+
933
+ def _detect_license_or_permission(root: Path) -> str:
934
+ for filename in LICENSE_FILENAMES:
935
+ candidate = root / filename
936
+ if not candidate.exists():
937
+ continue
938
+ try:
939
+ text = candidate.read_text(encoding="utf-8", errors="ignore").lower()
940
+ except Exception:
941
+ return f"present:{candidate.name}"
942
+ if "mit license" in text:
943
+ return "MIT"
944
+ if "apache license" in text and "version 2.0" in text:
945
+ return "Apache-2.0"
946
+ if "bsd" in text:
947
+ return "BSD"
948
+ if "creative commons" in text and "noncommercial" in text:
949
+ return "CC-BY-NC"
950
+ if "creative commons" in text:
951
+ return "CC"
952
+ return f"present:{candidate.name}"
953
+ return "unknown"
954
+
955
+
956
+ def validate_external_sample_summary(item: dict[str, Any], curated: bool) -> DonorValidationResult:
957
+ target_version = get_packet_tracer_target_version()
958
+ packet_tracer_version = str(item.get("version", ""))
959
+ issues: list[str] = []
960
+ # Use the shared compatibility ladder rather than string equality: the build
961
+ # field of a `<VERSION>` is not a schema identifier, and the target may be a
962
+ # three-field version detected from the install directory.
963
+ if not donor_tier_is_accepted(donor_compatibility(packet_tracer_version, target_version)):
964
+ issues.append(f"version_mismatch:{packet_tracer_version or 'unknown'}")
965
+ if int(item.get("device_count", 0)) < 2:
966
+ issues.append("insufficient_devices")
967
+ if int(item.get("link_count", 0)) < 1:
968
+ issues.append("insufficient_links")
969
+ counts = _normalized_counts_for_item(item)
970
+ if curated and not any(counts.get(kind, 0) for kind in ("Switch", "Router")):
971
+ issues.append("missing_network_core")
972
+ workspace_validation = item.get("workspace_validation", {})
973
+ if curated and isinstance(workspace_validation, dict):
974
+ workspace_mode = str(workspace_validation.get("workspace_mode", ""))
975
+ logical_status = str(workspace_validation.get("logical_status", ""))
976
+ physical_status = str(workspace_validation.get("physical_status", ""))
977
+ blocking_issues = [str(issue) for issue in workspace_validation.get("blocking_issues", []) if str(issue)]
978
+ legacy_memaddr_only = (
979
+ workspace_mode == "legacy_uuid_physical"
980
+ and physical_status == "ok"
981
+ and logical_status == "invalid"
982
+ and bool(blocking_issues)
983
+ and all(issue == "Link device MEM_ADDR references do not match device workspace records" for issue in blocking_issues)
984
+ )
985
+ if physical_status != "ok":
986
+ issues.append("workspace_physical_invalid")
987
+ elif logical_status != "ok" and not legacy_memaddr_only:
988
+ issues.append("workspace_logical_invalid")
989
+ donor_eligible = curated and not issues
990
+ validation_status = "validated" if not issues else "blocked"
991
+ promotion_status = "validated_curated" if donor_eligible else "reference_only"
992
+ return DonorValidationResult(
993
+ packet_tracer_version=packet_tracer_version,
994
+ validation_status=validation_status,
995
+ promotion_status=promotion_status,
996
+ donor_eligible=donor_eligible,
997
+ issues=issues,
998
+ )
999
+
1000
+
1001
+ def _merge_curated_registry_entry(
1002
+ item: dict[str, Any],
1003
+ registry_entry: dict[str, Any] | None,
1004
+ validation: DonorValidationResult,
1005
+ ) -> dict[str, Any]:
1006
+ merged = dict(item)
1007
+ if not registry_entry:
1008
+ return merged
1009
+ for field in [
1010
+ "packet_tracer_version",
1011
+ "apply_safety_level",
1012
+ "archetype_tags",
1013
+ "validated_edit_capabilities",
1014
+ "acceptance_fixtures",
1015
+ "acceptance_notes",
1016
+ "provenance",
1017
+ ]:
1018
+ if field in registry_entry:
1019
+ merged[field] = registry_entry[field]
1020
+ requested_promotion = str(registry_entry.get("promotion_status", "")).strip()
1021
+ if requested_promotion and validation.donor_eligible:
1022
+ merged["promotion_status"] = requested_promotion
1023
+ if not validation.donor_eligible:
1024
+ merged["promotion_status"] = "reference_only"
1025
+ merged["donor_eligible"] = False
1026
+ if registry_entry:
1027
+ merged["evidence_source"] = "registry+inferred"
1028
+ workspace_validation_status = str(registry_entry.get("workspace_validation", "")).strip()
1029
+ if workspace_validation_status:
1030
+ merged["workspace_validation_status"] = workspace_validation_status
1031
+ return merged
1032
+
1033
+
1034
+ def _descriptor_from_item(item: dict[str, Any]) -> SampleDescriptor:
1035
+ return SampleDescriptor(
1036
+ path=item["path"],
1037
+ relative_path=item["relative_path"],
1038
+ version=item.get("version", ""),
1039
+ device_count=item.get("device_count", 0),
1040
+ link_count=item.get("link_count", 0),
1041
+ devices=item.get("devices", []),
1042
+ links=item.get("links", []),
1043
+ capability_tags=item.get("capability_tags", []),
1044
+ topology_tags=item.get("topology_tags", []),
1045
+ preferred_roles=item.get("preferred_roles", []),
1046
+ trust_level=item.get("trust_level", "trusted"),
1047
+ origin=item.get("origin", "cisco-local"),
1048
+ role=item.get("role", "primary"),
1049
+ prototype_eligible=bool(item.get("prototype_eligible", True)),
1050
+ license_or_permission=item.get("license_or_permission", "local-cisco"),
1051
+ promotion_status=item.get("promotion_status", "validated_primary"),
1052
+ validation_status=item.get("validation_status", "validated"),
1053
+ packet_tracer_version=item.get("packet_tracer_version", item.get("version", "")),
1054
+ wireless_mode_tags=item.get("wireless_mode_tags", []),
1055
+ donor_eligible=bool(item.get("donor_eligible", item.get("prototype_eligible", True))),
1056
+ device_families=item.get("device_families", []),
1057
+ model_families=item.get("model_families", []),
1058
+ port_media_types=item.get("port_media_types", []),
1059
+ service_support=item.get("service_support", []),
1060
+ iot_roles=item.get("iot_roles", []),
1061
+ runtime_features=item.get("runtime_features", []),
1062
+ donor_graph_fingerprint=item.get("donor_graph_fingerprint", {}),
1063
+ apply_safety_level=item.get("apply_safety_level", "reference-known"),
1064
+ promotion_evidence=item.get("promotion_evidence", []),
1065
+ validated_edit_capabilities=item.get("validated_edit_capabilities", []),
1066
+ acceptance_notes=item.get("acceptance_notes", []),
1067
+ archetype_tags=item.get("archetype_tags", []),
1068
+ acceptance_fixtures=item.get("acceptance_fixtures", []),
1069
+ provenance=item.get("provenance", ""),
1070
+ workspace_validation_status=item.get("workspace_validation_status", ""),
1071
+ evidence_source=item.get("evidence_source", "inferred"),
1072
+ )
1073
+
1074
+
1075
+ def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
1076
+ enriched: list[dict[str, Any]] = []
1077
+ for item in items:
1078
+ if "error" in item:
1079
+ enriched.append(item)
1080
+ continue
1081
+ new_item = dict(item)
1082
+ new_item["capability_tags"] = infer_capability_tags(new_item)
1083
+ new_item["topology_tags"] = infer_topology_tags(new_item)
1084
+ new_item["preferred_roles"] = infer_preferred_roles(new_item)
1085
+ new_item["wireless_mode_tags"] = infer_wireless_mode_tags(new_item)
1086
+ new_item.setdefault("trust_level", "trusted")
1087
+ new_item.setdefault("origin", "cisco-local")
1088
+ new_item.setdefault("role", "primary")
1089
+ new_item.setdefault("prototype_eligible", True)
1090
+ new_item.setdefault("license_or_permission", "local-cisco")
1091
+ new_item.setdefault("promotion_status", "validated_primary")
1092
+ new_item.setdefault("validation_status", "validated")
1093
+ new_item.setdefault("packet_tracer_version", new_item.get("version", ""))
1094
+ new_item.setdefault("donor_eligible", bool(new_item.get("prototype_eligible", True)))
1095
+ new_item.setdefault("device_families", infer_device_families(new_item))
1096
+ new_item.setdefault("model_families", infer_model_families(new_item))
1097
+ new_item.setdefault("port_media_types", infer_port_media_types(new_item))
1098
+ new_item.setdefault("service_support", infer_service_support(new_item))
1099
+ new_item.setdefault("iot_roles", infer_iot_roles(new_item))
1100
+ new_item["runtime_features"] = sorted(set(new_item.get("runtime_features", [])) | set(infer_runtime_features(new_item)))
1101
+ new_item.setdefault("donor_graph_fingerprint", infer_donor_graph_fingerprint(new_item))
1102
+ new_item.setdefault("apply_safety_level", infer_apply_safety_level(new_item))
1103
+ new_item.setdefault("archetype_tags", infer_archetype_tags(new_item))
1104
+ new_item.setdefault("validated_edit_capabilities", infer_validated_edit_capabilities(new_item))
1105
+ new_item.setdefault("acceptance_fixtures", [])
1106
+ new_item.setdefault("provenance", "")
1107
+ new_item.setdefault("promotion_evidence", infer_promotion_evidence(new_item))
1108
+ new_item.setdefault("acceptance_notes", infer_acceptance_notes(new_item))
1109
+ new_item.setdefault("workspace_validation_status", "")
1110
+ new_item.setdefault("evidence_source", "inferred")
1111
+ enriched.append(new_item)
1112
+ return enriched
1113
+
1114
+
1115
+ @lru_cache(maxsize=8)
1116
+ def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
1117
+ raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
1118
+ items = enrich_catalog_items(raw_items)
1119
+ saves_root = get_packet_tracer_saves_root()
1120
+ return tuple(
1121
+ _descriptor_from_item(
1122
+ {
1123
+ **item,
1124
+ "path": str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
1125
+ }
1126
+ )
1127
+ for item in items
1128
+ if "error" not in item
1129
+ )
1130
+
1131
+
1132
+ def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
1133
+ return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
1134
+
1135
+
1136
+ def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
1137
+ # Catalogued labs are parsed immediately below, so a bad file shows up as
1138
+ # unparseable XML without paying for the authentication pass.
1139
+ xml, _container = decode_pkt_auto(path.read_bytes(), verify=False)
1140
+ root = parse_pkt_xml(xml)
1141
+ config_text = "\n".join(
1142
+ line.text or "" for line in root.findall(".//ENGINE/RUNNINGCONFIG/LINE")
1143
+ )
1144
+ from pkt_editor import inventory_root
1145
+
1146
+ inventory = inventory_root(root)
1147
+ workspace_result = inspect_workspace_integrity(root)
1148
+ devices = []
1149
+ indexed_devices = root.findall(".//DEVICES/DEVICE")
1150
+ index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(indexed_devices)}
1151
+ save_ref_to_name = {
1152
+ device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
1153
+ for device in indexed_devices
1154
+ if device.findtext("./ENGINE/SAVE_REF_ID", default="")
1155
+ }
1156
+ # Imported here rather than at module scope: `pkt_transformer` imports this
1157
+ # module, so a top-level import would close the circle.
1158
+ from pkt_transformer import port_capacity
1159
+
1160
+ for device in root.findall(".//DEVICES/DEVICE"):
1161
+ type_node = device.find("./ENGINE/TYPE")
1162
+ devices.append(
1163
+ {
1164
+ "name": device.findtext("./ENGINE/NAME", default=""),
1165
+ "type": device.findtext("./ENGINE/TYPE", default=""),
1166
+ "model": type_node.get("model", "") if type_node is not None else "",
1167
+ # Whether a device can carry a serial WAN is not visible from
1168
+ # its type or model: the ports live on an installed module. A
1169
+ # prompt asking for a leased line was ranked against samples
1170
+ # that had no serial hardware at all, because nothing in the
1171
+ # catalogue recorded it.
1172
+ "serial_ports": port_capacity(device).get("Serial", 0),
1173
+ }
1174
+ )
1175
+ links = []
1176
+ for link in root.findall(".//LINKS/LINK"):
1177
+ cable = link.find("./CABLE")
1178
+ ports = cable.findall("PORT") if cable is not None else []
1179
+ from_ref = cable.findtext("FROM", default="") if cable is not None else ""
1180
+ to_ref = cable.findtext("TO", default="") if cable is not None else ""
1181
+ links.append(
1182
+ {
1183
+ "from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
1184
+ "to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
1185
+ "type": link.findtext("./TYPE", default=""),
1186
+ "cable_type": cable.findtext("./TYPE", default="") if cable is not None else "",
1187
+ "media": cable.findtext("./TYPE", default="") if cable is not None else "",
1188
+ "ports": [port.text or "" for port in ports[:2]],
1189
+ }
1190
+ )
1191
+ service_aliases = {
1192
+ "dhcp_server": "dhcp",
1193
+ "dns_server": "dns",
1194
+ "http_server": "http",
1195
+ "https_server": "https",
1196
+ "ftp_server": "ftp",
1197
+ "tftp_server": "tftp",
1198
+ "ntp_server": "ntp",
1199
+ "email_server": "email",
1200
+ "syslog_server": "syslog",
1201
+ "acs_server": "aaa",
1202
+ }
1203
+ service_support = sorted(
1204
+ {
1205
+ service_aliases[service]
1206
+ for services in inventory.get("services", {}).values()
1207
+ for service in services
1208
+ if service in service_aliases
1209
+ }
1210
+ )
1211
+ iot_roles = sorted({str(entry.get("role", "")).strip() for entry in inventory.get("iot", {}).values() if str(entry.get("role", "")).strip()})
1212
+ return {
1213
+ "path": str(path),
1214
+ "relative_path": relative_path,
1215
+ "config_capability_tags": sorted(config_capability_tags(config_text)),
1216
+ "version": root.findtext("./VERSION", default=""),
1217
+ "device_count": len(devices),
1218
+ "link_count": len(links),
1219
+ "devices": devices,
1220
+ "links": links,
1221
+ "origin": origin,
1222
+ "trust_level": "reference-only" if origin != "cisco-local" else "trusted",
1223
+ "role": "reference" if origin != "cisco-local" else "primary",
1224
+ "prototype_eligible": prototype_eligible,
1225
+ "service_support": service_support,
1226
+ "iot_roles": iot_roles,
1227
+ "workspace_validation": {
1228
+ "workspace_mode": workspace_result.workspace_mode,
1229
+ "logical_status": workspace_result.logical_status,
1230
+ "physical_status": workspace_result.physical_status,
1231
+ "blocking_issue_count": len(workspace_result.blocking_issues),
1232
+ "blocking_issues": workspace_result.blocking_issues,
1233
+ },
1234
+ }
1235
+
1236
+
1237
+ def summarize_pkt_descriptor(
1238
+ path: Path,
1239
+ relative_path: str | None = None,
1240
+ *,
1241
+ origin: str = "external-reference",
1242
+ prototype_eligible: bool = False,
1243
+ trust_level: str | None = None,
1244
+ role: str | None = None,
1245
+ license_or_permission: str | None = None,
1246
+ promotion_status: str | None = None,
1247
+ validation_status: str | None = None,
1248
+ donor_eligible: bool | None = None,
1249
+ ) -> SampleDescriptor:
1250
+ item = _summarize_pkt(path, relative_path or path.name, origin, prototype_eligible)
1251
+ if trust_level is not None:
1252
+ item["trust_level"] = trust_level
1253
+ if role is not None:
1254
+ item["role"] = role
1255
+ if license_or_permission is not None:
1256
+ item["license_or_permission"] = license_or_permission
1257
+ if promotion_status is not None:
1258
+ item["promotion_status"] = promotion_status
1259
+ if validation_status is not None:
1260
+ item["validation_status"] = validation_status
1261
+ if donor_eligible is not None:
1262
+ item["donor_eligible"] = donor_eligible
1263
+ enriched = enrich_catalog_items([item])[0]
1264
+ return _descriptor_from_item(enriched)
1265
+
1266
+
1267
+ @lru_cache(maxsize=4)
1268
+ def _load_reference_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1269
+ items: list[dict[str, Any]] = []
1270
+ for root_str in roots_key:
1271
+ root = Path(root_str)
1272
+ if not root.exists():
1273
+ continue
1274
+ for pkt_path in sorted(root.rglob("*.pkt")):
1275
+ try:
1276
+ rel = str(pkt_path.relative_to(root))
1277
+ item = _summarize_pkt(pkt_path, rel, "external-reference", False)
1278
+ validation = validate_external_sample_summary(item, curated=False)
1279
+ item["license_or_permission"] = _detect_license_or_permission(root)
1280
+ item["validation_status"] = validation.validation_status
1281
+ item["promotion_status"] = validation.promotion_status
1282
+ item["donor_eligible"] = validation.donor_eligible
1283
+ items.append(item)
1284
+ except Exception:
1285
+ continue
1286
+ enriched = enrich_catalog_items(items)
1287
+ return tuple(_descriptor_from_item(item) for item in enriched)
1288
+
1289
+
1290
+ def load_reference_catalog(reference_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1291
+ if not reference_roots:
1292
+ return []
1293
+ roots_key = tuple(sorted(str(path) for path in reference_roots))
1294
+ return list(_load_reference_catalog_cached(roots_key))
1295
+
1296
+
1297
+ @lru_cache(maxsize=4)
1298
+ def _load_curated_donor_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1299
+ items: list[dict[str, Any]] = []
1300
+ registry = load_curated_donor_registry()
1301
+ for root_str in roots_key:
1302
+ root = Path(root_str)
1303
+ if not root.exists():
1304
+ continue
1305
+ license_or_permission = _detect_license_or_permission(root)
1306
+ for pkt_path in sorted(root.rglob("*.pkt")):
1307
+ try:
1308
+ rel = str(pkt_path.relative_to(root))
1309
+ item = _summarize_pkt(pkt_path, rel, "external-curated", True)
1310
+ validation = validate_external_sample_summary(item, curated=True)
1311
+ item["trust_level"] = "curated" if validation.donor_eligible else "reference-only"
1312
+ item["role"] = "secondary" if validation.donor_eligible else "reference"
1313
+ item["prototype_eligible"] = validation.donor_eligible
1314
+ item["license_or_permission"] = license_or_permission
1315
+ item["validation_status"] = validation.validation_status
1316
+ item["promotion_status"] = validation.promotion_status
1317
+ item["packet_tracer_version"] = validation.packet_tracer_version
1318
+ item["donor_eligible"] = validation.donor_eligible
1319
+ registry_entry = registry.get(_normalize_registry_key(rel)) or registry.get(_normalize_registry_key(pkt_path.name))
1320
+ item = _merge_curated_registry_entry(item, registry_entry, validation)
1321
+ items.append(item)
1322
+ except Exception:
1323
+ continue
1324
+ enriched = enrich_catalog_items(items)
1325
+ return tuple(_descriptor_from_item(item) for item in enriched)
1326
+
1327
+
1328
+ def load_curated_donor_catalog(donor_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1329
+ if not donor_roots:
1330
+ return []
1331
+ roots_key = tuple(sorted(str(path) for path in donor_roots))
1332
+ return list(_load_curated_donor_catalog_cached(roots_key))
1333
+
1334
+
1335
+ def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[ReferencePattern]:
1336
+ patterns: list[ReferencePattern] = []
1337
+ for sample in samples:
1338
+ patterns.append(
1339
+ ReferencePattern(
1340
+ relative_path=sample.relative_path,
1341
+ origin=sample.origin,
1342
+ capability_tags=sample.capability_tags,
1343
+ topology_tags=sample.topology_tags,
1344
+ device_summary=sample.normalized_device_counts(),
1345
+ wireless_mode_tags=sample.wireless_mode_tags,
1346
+ )
1347
+ )
1348
+ return patterns
1349
+
1350
+
1351
+ def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
1352
+ enriched = enrich_catalog_items(items)
1353
+ compact_items: list[dict[str, Any]] = []
1354
+ for item in enriched:
1355
+ saved = dict(item)
1356
+ saved.pop("path", None)
1357
+ compact_items.append(saved)
1358
+ (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
1359
+ root_label = "<PACKET_TRACER_SAVES_ROOT>"
1360
+ lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
1361
+ for item in enriched:
1362
+ if "error" in item:
1363
+ lines.append(f"- `{item['relative_path']}`")
1364
+ lines.append(f" decode error: `{item['error']}`")
1365
+ continue
1366
+ labels = ", ".join(f"{device['name']} ({device['type']}/{device['model']})" for device in item["devices"])
1367
+ lines.append(f"- `{item['relative_path']}`")
1368
+ lines.append(f" version: `{item['version']}`, devices: `{item['device_count']}`, links: `{item['link_count']}`")
1369
+ lines.append(f" tags: {', '.join(item.get('capability_tags', []))}")
1370
+ lines.append(f" topology: {', '.join(item.get('topology_tags', []))}")
1371
+ lines.append(f" devices: {labels}")
1372
+ (md_path or DEFAULT_CATALOG_MD).write_text("\n".join(lines) + "\n", encoding="utf-8")