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
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env python3
2
+ """Verification for generated `.pkt` files, in two tiers.
3
+
4
+ Structural checks say the bytes are well-formed and internally consistent.
5
+ Only Packet Tracer itself can say the file actually opens. The two are kept
6
+ apart deliberately, because conflating them is how a repo ends up claiming
7
+ runtime proof it never had.
8
+
9
+ Tier 1 `structural_check` is headless, deterministic and safe for CI.
10
+ Tier 2 `open_check` launches Packet Tracer and watches for the file's window.
11
+ It replaces the previous `validate_open`, which called `subprocess.Popen` and
12
+ immediately printed `{"status": "launched"}` without observing anything at all.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import itertools
18
+ import json
19
+ import os
20
+ import platform
21
+ import shutil
22
+ import subprocess
23
+ import time
24
+ import xml.etree.ElementTree as ET
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from packet_tracer_env import (
29
+ donor_compatibility,
30
+ donor_tier_is_accepted,
31
+ get_packet_tracer_exe,
32
+ get_packet_tracer_target_version,
33
+ )
34
+ from pkt_codec import decode_pkt_auto, parse_pkt_xml
35
+
36
+ DEFAULT_OPEN_TIMEOUT_SECONDS = 150
37
+ POLL_INTERVAL_SECONDS = 3
38
+
39
+
40
+ @dataclass
41
+ class StructuralReport:
42
+ passed: bool
43
+ pkt_path: str
44
+ version: str = ""
45
+ compatibility_tier: str = ""
46
+ container: str = ""
47
+ device_count: int = 0
48
+ link_count: int = 0
49
+ failures: list[str] = field(default_factory=list)
50
+ warnings: list[str] = field(default_factory=list)
51
+
52
+ def to_json(self) -> dict[str, object]:
53
+ return {
54
+ "tier": "structural",
55
+ "passed": self.passed,
56
+ "pkt": self.pkt_path,
57
+ "version": self.version,
58
+ "compatibility_tier": self.compatibility_tier,
59
+ "container": self.container,
60
+ "device_count": self.device_count,
61
+ "link_count": self.link_count,
62
+ "failures": self.failures,
63
+ "warnings": self.warnings,
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class OpenReport:
69
+ # opened | refused | timeout | process_exited | packet_tracer_missing.
70
+ # `refused` is Packet Tracer's own answer -- the incompatible-file dialog --
71
+ # and is worth distinguishing from `timeout`, which only means nothing was
72
+ # observed in the time allowed.
73
+ status: str
74
+ pkt_path: str
75
+ elapsed_seconds: float = 0.0
76
+ observed_title: str = ""
77
+ detail: str = ""
78
+
79
+ @property
80
+ def opened(self) -> bool:
81
+ return self.status == "opened"
82
+
83
+ def to_json(self) -> dict[str, object]:
84
+ return {
85
+ "tier": "open",
86
+ "status": self.status,
87
+ "opened": self.opened,
88
+ "pkt": self.pkt_path,
89
+ "elapsed_seconds": round(self.elapsed_seconds, 1),
90
+ "observed_title": self.observed_title,
91
+ "detail": self.detail,
92
+ }
93
+
94
+
95
+ def structural_check(pkt_path: str | Path, expected_devices: int | None = None) -> StructuralReport:
96
+ path = Path(pkt_path)
97
+ report = StructuralReport(passed=False, pkt_path=str(path))
98
+
99
+ if not path.exists():
100
+ report.failures.append(f"file does not exist: {path}")
101
+ return report
102
+ if path.stat().st_size == 0:
103
+ report.failures.append("file is empty")
104
+ return report
105
+
106
+ try:
107
+ xml_bytes, container = decode_pkt_auto(path.read_bytes())
108
+ report.container = container
109
+ except Exception as exc:
110
+ report.failures.append(f"decode failed: {exc}")
111
+ return report
112
+
113
+ try:
114
+ root = parse_pkt_xml(xml_bytes)
115
+ except ET.ParseError as exc:
116
+ report.failures.append(f"XML does not parse: {exc}")
117
+ return report
118
+
119
+ if root.tag != "PACKETTRACER5":
120
+ report.failures.append(f"unexpected root element: {root.tag}")
121
+
122
+ report.version = root.findtext("./VERSION") or ""
123
+ if not report.version:
124
+ report.failures.append("no <VERSION> element")
125
+ else:
126
+ target = get_packet_tracer_target_version()
127
+ report.compatibility_tier = donor_compatibility(report.version, target)
128
+ if not donor_tier_is_accepted(report.compatibility_tier):
129
+ report.failures.append(
130
+ f"version {report.version} is tier '{report.compatibility_tier}' against target {target}"
131
+ )
132
+
133
+ devices = root.findall(".//DEVICES/DEVICE")
134
+ links = root.findall(".//LINKS/LINK")
135
+ report.device_count = len(devices)
136
+ report.link_count = len(links)
137
+
138
+ if not devices:
139
+ report.failures.append("no devices")
140
+
141
+ names: list[str] = []
142
+ ref_to_name: dict[str, str] = {}
143
+ for device in devices:
144
+ name = device.findtext("./ENGINE/NAME") or ""
145
+ if not name:
146
+ report.failures.append("a device has no name")
147
+ continue
148
+ names.append(name)
149
+ save_ref = device.findtext("./ENGINE/SAVE_REF_ID") or ""
150
+ if save_ref:
151
+ ref_to_name[save_ref] = name
152
+
153
+ duplicates = {name for name in names if names.count(name) > 1}
154
+ if duplicates:
155
+ report.failures.append(f"duplicate device names: {sorted(duplicates)}")
156
+
157
+ # Every link must land on a device that exists. A dangling endpoint is the
158
+ # classic way a pruned donor stops opening.
159
+ #
160
+ # Ports must also be used at most once: one physical interface cannot carry
161
+ # two cables. A generated link that reuses an occupied port produces a lab
162
+ # that looks plausible and is wired wrongly.
163
+ occupied_ports: dict[tuple[str, str], int] = {}
164
+ for index, link in enumerate(links):
165
+ cable = link.find("./CABLE")
166
+ if cable is None:
167
+ report.failures.append(f"link {index} has no CABLE element")
168
+ continue
169
+ endpoints: list[str] = []
170
+ for end in ("FROM", "TO"):
171
+ ref = cable.findtext(end) or ""
172
+ if not ref:
173
+ report.failures.append(f"link {index} has an empty {end} reference")
174
+ elif ref not in ref_to_name:
175
+ report.failures.append(f"link {index} {end} references unknown device {ref!r}")
176
+ else:
177
+ endpoints.append(ref_to_name[ref])
178
+
179
+ ports = [(port.text or "").strip() for port in cable.findall("PORT")][:2]
180
+ for device_name, port_name in zip(endpoints, ports):
181
+ if not port_name:
182
+ continue
183
+ key = (device_name, port_name)
184
+ previous = occupied_ports.get(key)
185
+ if previous is not None:
186
+ report.failures.append(
187
+ f"port {device_name} {port_name} is used by both link {previous} and link {index}"
188
+ )
189
+ else:
190
+ occupied_ports[key] = index
191
+
192
+ if expected_devices is not None and report.device_count != expected_devices:
193
+ report.warnings.append(
194
+ f"device count is {report.device_count}, plan expected {expected_devices}; "
195
+ "donor spares are parked rather than deleted"
196
+ )
197
+
198
+ report.passed = not report.failures
199
+ return report
200
+
201
+
202
+ # Packet Tracer's own title for the dialog it shows when it will not load a
203
+ # file. Detecting it turns a 150-second timeout into an immediate, correctly
204
+ # named answer, which is the difference between a corpus run that takes eighty
205
+ # minutes to say nothing and one that says which labs are refused.
206
+ REFUSAL_WINDOW_TITLE = "Incompatible File"
207
+
208
+
209
+ def _top_level_window_titles() -> list[str]:
210
+ """Every visible top-level window title on the desktop.
211
+
212
+ `MainWindowTitle` reports one window per process, and Packet Tracer moves
213
+ which of its windows holds that role: the same open showed only the
214
+ extension's log window in one run and the loaded document in the next. It
215
+ also never shows the modal refusal dialog, so a refused file was
216
+ indistinguishable from a slow one. Enumerating every window is what let
217
+ "opened" and "refused" be told apart at all.
218
+ """
219
+ if platform.system() != "Windows":
220
+ return []
221
+ import ctypes
222
+ from ctypes import wintypes
223
+
224
+ user32 = ctypes.windll.user32
225
+ callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
226
+ titles: list[str] = []
227
+
228
+ def collect(hwnd, _lparam): # pragma: no cover - GUI enumeration
229
+ if not user32.IsWindowVisible(hwnd):
230
+ return True
231
+ length = user32.GetWindowTextLengthW(hwnd)
232
+ if length <= 0:
233
+ return True
234
+ buffer = ctypes.create_unicode_buffer(length + 1)
235
+ user32.GetWindowTextW(hwnd, buffer, length + 1)
236
+ text = buffer.value.strip()
237
+ if text:
238
+ titles.append(text)
239
+ return True
240
+
241
+ try:
242
+ user32.EnumWindows(callback_type(collect), 0)
243
+ except OSError: # pragma: no cover - defensive
244
+ return []
245
+ return titles
246
+
247
+
248
+ def _dismiss_refusal_dialogs() -> int:
249
+ """Close any incompatible-file dialog left on screen, returning how many.
250
+
251
+ The dialog is modal: while one is up Packet Tracer will not load anything
252
+ else, so a batch run would report every lab after the first refusal as
253
+ refused too -- and instantly, since the dialog is already there. Clearing it
254
+ before each launch is what makes a sequence of checks mean anything.
255
+ """
256
+ if platform.system() != "Windows":
257
+ return 0
258
+ import ctypes
259
+ from ctypes import wintypes
260
+
261
+ user32 = ctypes.windll.user32
262
+ callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
263
+ handles: list[int] = []
264
+
265
+ def collect(hwnd, _lparam): # pragma: no cover - GUI enumeration
266
+ length = user32.GetWindowTextLengthW(hwnd)
267
+ if length <= 0:
268
+ return True
269
+ buffer = ctypes.create_unicode_buffer(length + 1)
270
+ user32.GetWindowTextW(hwnd, buffer, length + 1)
271
+ if REFUSAL_WINDOW_TITLE in buffer.value:
272
+ handles.append(hwnd)
273
+ return True
274
+
275
+ try:
276
+ user32.EnumWindows(callback_type(collect), 0)
277
+ for hwnd in handles:
278
+ user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
279
+ except OSError: # pragma: no cover - defensive
280
+ return 0
281
+ if handles:
282
+ time.sleep(1.5)
283
+ return len(handles)
284
+
285
+
286
+ def _windows_titles_matching(stem: str) -> str:
287
+ for title in _top_level_window_titles():
288
+ if stem.lower() in title.lower():
289
+ return title
290
+ return ""
291
+
292
+
293
+ _PROBE_COUNTER = itertools.count(1)
294
+
295
+
296
+ def _unique_probe_copy(path: Path) -> Path:
297
+ """A copy of the lab under a name no window can already be showing.
298
+
299
+ Kept beside the original on purpose: labs reference their artwork with
300
+ relative paths such as `../art/Background/grid_100x100.png`, so a copy in a
301
+ temp directory would be a different file in a way that matters.
302
+ """
303
+ probe = path.with_name(f"{path.stem}__probe{os.getpid()}_{next(_PROBE_COUNTER)}{path.suffix}")
304
+ try:
305
+ shutil.copyfile(path, probe)
306
+ except OSError: # pragma: no cover - falls back to checking the file itself
307
+ return path
308
+ return probe
309
+
310
+
311
+ def _discard_probe_copy(probe: Path, original: Path) -> None:
312
+ if probe == original:
313
+ return
314
+ try:
315
+ probe.unlink()
316
+ except OSError: # pragma: no cover - Packet Tracer may still hold it open
317
+ pass
318
+
319
+
320
+ def open_check(
321
+ pkt_path: str | Path,
322
+ timeout_seconds: int = DEFAULT_OPEN_TIMEOUT_SECONDS,
323
+ attempts: int = 2,
324
+ ) -> OpenReport:
325
+ """Launch Packet Tracer and wait until the file's window appears.
326
+
327
+ A negative verdict has to reproduce before it is reported. Measured: a
328
+ bisect over 57 plan operations called one step `refused`, and the same file
329
+ re-checked five times afterwards opened five times out of five. The dialog
330
+ Packet Tracer raises for the *previous* probe is a window that did not
331
+ exist when this check started, so it was counted against the wrong file.
332
+ An open is believed on the first try; anything else is tried again, because
333
+ a false refusal sends the search after a defect that is not there.
334
+ """
335
+ attempts = max(1, attempts)
336
+ report = _open_check_once(pkt_path, timeout_seconds)
337
+ for _ in range(attempts - 1):
338
+ if report.status == "opened":
339
+ return report
340
+ report = _open_check_once(pkt_path, timeout_seconds)
341
+ return report
342
+
343
+
344
+ def _open_check_once(
345
+ pkt_path: str | Path,
346
+ timeout_seconds: int = DEFAULT_OPEN_TIMEOUT_SECONDS,
347
+ ) -> OpenReport:
348
+ """One launch and one verdict.
349
+
350
+ Window-title observation is currently implemented for Windows. On other
351
+ hosts the launch still happens and process liveness is reported, which is
352
+ weaker evidence and is labelled as such rather than claimed as an open.
353
+ """
354
+ path = Path(pkt_path).resolve()
355
+ report = OpenReport(status="packet_tracer_missing", pkt_path=str(path))
356
+
357
+ executable = get_packet_tracer_exe()
358
+ if executable is None:
359
+ report.detail = "no Packet Tracer executable resolved; set PACKET_TRACER_ROOT"
360
+ return report
361
+ if not path.exists():
362
+ report.status = "process_exited"
363
+ report.detail = f"file does not exist: {path}"
364
+ return report
365
+
366
+ _dismiss_refusal_dialogs()
367
+ # A window already showing this file is not evidence that this check opened
368
+ # it. Re-checking a lab Packet Tracer still had on screen reported `opened`
369
+ # in 0.0 seconds without loading anything -- the same shape of false
370
+ # positive that let unopenable labs pass for months. Anything present before
371
+ # the launch is excluded from counting as a result.
372
+ #
373
+ # Excluding it costs the opposite error, though: with the old window still
374
+ # up, the title this check is waiting for is already excluded, so the check
375
+ # goes blind to its own success and times out. Measured on one lab checked
376
+ # five times in a row: opened, timeout, timeout, opened, opened. Opening a
377
+ # uniquely named copy sidesteps both -- the title cannot collide with
378
+ # anything already on screen, so no window has to be closed or guessed at.
379
+ probe = _unique_probe_copy(path)
380
+ titles_before = set(_top_level_window_titles())
381
+ started = time.monotonic()
382
+ try:
383
+ process = subprocess.Popen([str(executable), str(probe)])
384
+ except OSError as exc:
385
+ report.status = "process_exited"
386
+ report.detail = f"could not launch Packet Tracer: {exc}"
387
+ return report
388
+
389
+ is_windows = platform.system() == "Windows"
390
+ try:
391
+ while time.monotonic() - started < timeout_seconds:
392
+ if is_windows:
393
+ titles = [
394
+ entry for entry in _top_level_window_titles() if entry not in titles_before
395
+ ]
396
+ title = next((entry for entry in titles if probe.stem.lower() in entry.lower()), "")
397
+ if title:
398
+ report.status = "opened"
399
+ report.observed_title = title
400
+ report.elapsed_seconds = time.monotonic() - started
401
+ report.detail = "Packet Tracer window title contains the file name"
402
+ return report
403
+ refusal = next((entry for entry in titles if REFUSAL_WINDOW_TITLE in entry), "")
404
+ if refusal:
405
+ report.status = "refused"
406
+ report.observed_title = refusal
407
+ report.elapsed_seconds = time.monotonic() - started
408
+ report.detail = "Packet Tracer put up its incompatible-file dialog"
409
+ return report
410
+
411
+ # Checked after the windows, not before. Packet Tracer runs one
412
+ # instance: launching it again while a copy is open hands the file
413
+ # over and the new process exits at once, which this read as a
414
+ # failure while the file was loading perfectly well behind it.
415
+ if process.poll() is not None and not is_windows:
416
+ report.status = "process_exited"
417
+ report.elapsed_seconds = time.monotonic() - started
418
+ report.detail = f"Packet Tracer exited with code {process.returncode} before the file opened"
419
+ return report
420
+
421
+ time.sleep(POLL_INTERVAL_SECONDS)
422
+
423
+ report.elapsed_seconds = time.monotonic() - started
424
+ if is_windows:
425
+ report.status = "timeout"
426
+ report.detail = f"no Packet Tracer window titled with {probe.stem!r} within {timeout_seconds}s"
427
+ else:
428
+ report.status = "timeout"
429
+ report.detail = (
430
+ "window-title observation is Windows-only; the process stayed alive but the open "
431
+ "was not confirmed. Check manually on this host."
432
+ )
433
+ return report
434
+ finally:
435
+ if process.poll() is None:
436
+ process.terminate()
437
+ _discard_probe_copy(probe, path)
438
+
439
+
440
+ def main() -> int:
441
+ import argparse
442
+
443
+ parser = argparse.ArgumentParser(description="Verify a generated Packet Tracer file.")
444
+ parser.add_argument("pkt", help="path to the .pkt file")
445
+ parser.add_argument("--open", action="store_true", help="also launch Packet Tracer and watch for the window")
446
+ parser.add_argument("--timeout", type=int, default=DEFAULT_OPEN_TIMEOUT_SECONDS)
447
+ parser.add_argument("--expect-devices", type=int, default=None)
448
+ args = parser.parse_args()
449
+
450
+ structural = structural_check(args.pkt, args.expect_devices)
451
+ payload: dict[str, object] = {"structural": structural.to_json()}
452
+
453
+ if args.open:
454
+ payload["open"] = open_check(args.pkt, args.timeout).to_json()
455
+
456
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
457
+ return 0 if structural.passed else 1
458
+
459
+
460
+ if __name__ == "__main__":
461
+ raise SystemExit(main())