flashgate 0.5.3__tar.gz → 0.5.4__tar.gz

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 (31) hide show
  1. {flashgate-0.5.3 → flashgate-0.5.4}/PKG-INFO +10 -7
  2. {flashgate-0.5.3 → flashgate-0.5.4}/README.md +9 -6
  3. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/board.py +7 -0
  4. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/flasher.py +17 -5
  5. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/gatestate.py +32 -5
  6. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate.egg-info/PKG-INFO +10 -7
  7. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate.egg-info/SOURCES.txt +2 -0
  8. {flashgate-0.5.3 → flashgate-0.5.4}/pyproject.toml +1 -1
  9. {flashgate-0.5.3 → flashgate-0.5.4}/tests/test_board.py +12 -0
  10. flashgate-0.5.4/tests/test_flasher.py +48 -0
  11. {flashgate-0.5.3 → flashgate-0.5.4}/tests/test_gatestate.py +46 -0
  12. flashgate-0.5.4/tests/test_hook_stop.py +85 -0
  13. {flashgate-0.5.3 → flashgate-0.5.4}/LICENSE +0 -0
  14. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/__init__.py +0 -0
  15. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/__main__.py +0 -0
  16. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/cli.py +0 -0
  17. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/mcp_server.py +0 -0
  18. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/probes.py +0 -0
  19. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/results.py +0 -0
  20. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/serialmon.py +0 -0
  21. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/sttools.py +0 -0
  22. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate/swdsig.py +0 -0
  23. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate.egg-info/dependency_links.txt +0 -0
  24. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate.egg-info/entry_points.txt +0 -0
  25. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate.egg-info/requires.txt +0 -0
  26. {flashgate-0.5.3 → flashgate-0.5.4}/flashgate.egg-info/top_level.txt +0 -0
  27. {flashgate-0.5.3 → flashgate-0.5.4}/setup.cfg +0 -0
  28. {flashgate-0.5.3 → flashgate-0.5.4}/tests/test_cli.py +0 -0
  29. {flashgate-0.5.3 → flashgate-0.5.4}/tests/test_mcp.py +0 -0
  30. {flashgate-0.5.3 → flashgate-0.5.4}/tests/test_probes.py +0 -0
  31. {flashgate-0.5.3 → flashgate-0.5.4}/tests/test_swdsig.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashgate
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/Lion-1209/flashgate
@@ -50,8 +50,9 @@ Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
50
50
  passing verify proves the board is running a build of your current HEAD
51
51
  (`-dirty` marks uncommitted changes; it does not fingerprint their
52
52
  content — the Stop hook's tree fingerprint covers that side: tracked diffs
53
- in full, untracked files by path + size + first 4 MiB. Files ignored by
54
- git and nested git repositories are outside the gate). Functional probes then send real commands and assert on the answers,
53
+ in full, untracked files and the board profile by path + size + first
54
+ 4 MiB, so tightening probe expectations invalidates a cached PASS. Files
55
+ ignored by git and nested git repositories are outside the gate). Functional probes then send real commands and assert on the answers,
55
56
  including register readbacks (TIM3 CCR), not firmware self-reports:
56
57
 
57
58
  ```yaml
@@ -98,10 +99,12 @@ Board profiles, probes, fixes and docs are welcome — see
98
99
 
99
100
  `hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
100
101
  implementing the same hook contract works). When the agent tries to finish after
101
- touching watched firmware files, the hook fingerprints the tree and allows
102
- instantly if that exact state already passed hardware verify (~0.7 s
103
- cached). Otherwise it runs the full verify on the real board and blocks
104
- the stop on failure, feeding the agent the board's testimony:
102
+ touching watched firmware files, the hook fingerprints the tree (plus the
103
+ board profile — editing verification semantics must not reuse a cached
104
+ green) and allows instantly if that exact state already passed hardware
105
+ verify (~0.7 s cached). Otherwise it runs the full verify on the real
106
+ board and blocks the stop on failure, feeding the agent the board's
107
+ testimony:
105
108
 
106
109
  ```
107
110
  [flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
@@ -31,8 +31,9 @@ Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
31
31
  passing verify proves the board is running a build of your current HEAD
32
32
  (`-dirty` marks uncommitted changes; it does not fingerprint their
33
33
  content — the Stop hook's tree fingerprint covers that side: tracked diffs
34
- in full, untracked files by path + size + first 4 MiB. Files ignored by
35
- git and nested git repositories are outside the gate). Functional probes then send real commands and assert on the answers,
34
+ in full, untracked files and the board profile by path + size + first
35
+ 4 MiB, so tightening probe expectations invalidates a cached PASS. Files
36
+ ignored by git and nested git repositories are outside the gate). Functional probes then send real commands and assert on the answers,
36
37
  including register readbacks (TIM3 CCR), not firmware self-reports:
37
38
 
38
39
  ```yaml
@@ -79,10 +80,12 @@ Board profiles, probes, fixes and docs are welcome — see
79
80
 
80
81
  `hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
81
82
  implementing the same hook contract works). When the agent tries to finish after
82
- touching watched firmware files, the hook fingerprints the tree and allows
83
- instantly if that exact state already passed hardware verify (~0.7 s
84
- cached). Otherwise it runs the full verify on the real board and blocks
85
- the stop on failure, feeding the agent the board's testimony:
83
+ touching watched firmware files, the hook fingerprints the tree (plus the
84
+ board profile — editing verification semantics must not reuse a cached
85
+ green) and allows instantly if that exact state already passed hardware
86
+ verify (~0.7 s cached). Otherwise it runs the full verify on the real
87
+ board and blocks the stop on failure, feeding the agent the board's
88
+ testimony:
86
89
 
87
90
  ```
88
91
  [flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
@@ -70,6 +70,13 @@ def load_board(yaml_path: Path) -> Board:
70
70
  raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
71
71
  except (OSError, yaml.YAMLError) as exc:
72
72
  raise BoardError(f"cannot load board profile {yaml_path}: {exc}") from exc
73
+ if not isinstance(raw, dict):
74
+ # Empty file parses to None; a list/scalar document is equally
75
+ # malformed — both must surface as BoardError so the Stop hook's
76
+ # "gate misconfigured" branch (not a traceback) handles them.
77
+ raise BoardError(
78
+ f"board profile {yaml_path} is not a mapping "
79
+ f"(got {type(raw).__name__})")
73
80
 
74
81
  fw = raw.get("firmware") or {}
75
82
  flash = raw.get("flash") or {}
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import subprocess
6
+ import sys
6
7
  import time
7
8
  from dataclasses import dataclass
8
9
  from pathlib import Path
@@ -22,11 +23,22 @@ class FlashResult:
22
23
 
23
24
  def _kill_stlinkserver() -> None:
24
25
  """A stale stlink-server session can wedge the probe; it restarts on
25
- demand, so killing it between attempts is safe."""
26
- subprocess.run(
27
- ["taskkill", "/F", "/IM", "stlinkserver.exe"],
28
- capture_output=True, timeout=15,
29
- )
26
+ demand, so killing it between attempts is safe. Windows-only: the
27
+ helper process only exists there and so does taskkill — invoking it
28
+ on Linux/macOS used to raise FileNotFoundError straight out of the
29
+ flash-retry path. Best-effort by design: a failed kill must never
30
+ abort the retry it precedes."""
31
+ if sys.platform != "win32":
32
+ return
33
+ try:
34
+ subprocess.run(
35
+ ["taskkill", "/F", "/IM", "stlinkserver.exe"],
36
+ capture_output=True, timeout=15,
37
+ )
38
+ except (OSError, subprocess.SubprocessError):
39
+ # OSError: taskkill missing/blocked; SubprocessError covers
40
+ # TimeoutExpired — a kill that hangs 15 s must die quietly too.
41
+ pass
30
42
 
31
43
 
32
44
  def _attempt_flash(cli: Path, bin_path: Path, connect: str, address: str, start: bool) -> tuple[bool, int, str]:
@@ -93,15 +93,42 @@ def _untracked_content_digest(fw_dir: Path) -> str:
93
93
  return h.hexdigest()
94
94
 
95
95
 
96
- def tree_fingerprint(fw_dir: Path) -> str:
97
- """Content-identity of the working tree (not just HEAD: dirty trees with
98
- different edits must NOT share a fingerprint — including edits to
99
- untracked files, whose contents are hashed in explicitly)."""
96
+ def _profile_digest(profile: Path) -> str:
97
+ """sha256 over the board profile's bytes (same 4 MiB cap as untracked
98
+ files). Identity is content-only: two profiles with identical bytes
99
+ define identical verification semantics; any location-dependent field
100
+ resolves through firmware_dir, which is fingerprinted separately."""
101
+ try:
102
+ h = hashlib.sha256()
103
+ with profile.open("rb") as fh:
104
+ remaining = _HASH_CAP_BYTES
105
+ while remaining > 0:
106
+ chunk = fh.read(min(1 << 20, remaining))
107
+ if not chunk:
108
+ break
109
+ h.update(chunk)
110
+ remaining -= len(chunk)
111
+ return h.hexdigest()
112
+ except OSError:
113
+ return "<unreadable>"
114
+
115
+
116
+ def tree_fingerprint(fw_dir: Path, profile: Path | None = None) -> str:
117
+ """Content-identity of the working tree AND the verification semantics.
118
+
119
+ Not just HEAD: dirty trees with different edits must NOT share a
120
+ fingerprint — including edits to untracked files (hashed explicitly)
121
+ and edits to the board profile. The profile defines what "PASS"
122
+ means (probe expectations, build command), so tightening or loosening
123
+ it after a cached PASS must invalidate that green, not reuse it —
124
+ same stale-PASS class as the untracked-content hole (2026-09-13
125
+ external review)."""
100
126
  head = _git(["rev-parse", "HEAD"], fw_dir)
101
127
  diff = _git(["diff", "HEAD"], fw_dir)
102
128
  status = _git(["status", "--porcelain"], fw_dir)
103
129
  untracked = _untracked_content_digest(fw_dir)
104
- blob = "\x00".join((head, diff, status, untracked))
130
+ profile_part = _profile_digest(profile) if profile is not None else "<no-profile>"
131
+ blob = "\x00".join((head, diff, status, untracked, profile_part))
105
132
  return hashlib.sha256(blob.encode("utf-8", errors="replace")).hexdigest()
106
133
 
107
134
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashgate
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/Lion-1209/flashgate
@@ -50,8 +50,9 @@ Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
50
50
  passing verify proves the board is running a build of your current HEAD
51
51
  (`-dirty` marks uncommitted changes; it does not fingerprint their
52
52
  content — the Stop hook's tree fingerprint covers that side: tracked diffs
53
- in full, untracked files by path + size + first 4 MiB. Files ignored by
54
- git and nested git repositories are outside the gate). Functional probes then send real commands and assert on the answers,
53
+ in full, untracked files and the board profile by path + size + first
54
+ 4 MiB, so tightening probe expectations invalidates a cached PASS. Files
55
+ ignored by git and nested git repositories are outside the gate). Functional probes then send real commands and assert on the answers,
55
56
  including register readbacks (TIM3 CCR), not firmware self-reports:
56
57
 
57
58
  ```yaml
@@ -98,10 +99,12 @@ Board profiles, probes, fixes and docs are welcome — see
98
99
 
99
100
  `hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
100
101
  implementing the same hook contract works). When the agent tries to finish after
101
- touching watched firmware files, the hook fingerprints the tree and allows
102
- instantly if that exact state already passed hardware verify (~0.7 s
103
- cached). Otherwise it runs the full verify on the real board and blocks
104
- the stop on failure, feeding the agent the board's testimony:
102
+ touching watched firmware files, the hook fingerprints the tree (plus the
103
+ board profile — editing verification semantics must not reuse a cached
104
+ green) and allows instantly if that exact state already passed hardware
105
+ verify (~0.7 s cached). Otherwise it runs the full verify on the real
106
+ board and blocks the stop on failure, feeding the agent the board's
107
+ testimony:
105
108
 
106
109
  ```
107
110
  [flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
@@ -21,7 +21,9 @@ flashgate.egg-info/requires.txt
21
21
  flashgate.egg-info/top_level.txt
22
22
  tests/test_board.py
23
23
  tests/test_cli.py
24
+ tests/test_flasher.py
24
25
  tests/test_gatestate.py
26
+ tests/test_hook_stop.py
25
27
  tests/test_mcp.py
26
28
  tests/test_probes.py
27
29
  tests/test_swdsig.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flashgate"
7
- version = "0.5.3"
7
+ version = "0.5.4"
8
8
  description = "Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -63,6 +63,18 @@ class TestLoadBoard:
63
63
  b = load_board(make_profile(tmp_path, body))
64
64
  assert b.banner_regex == "BOOT git={git}"
65
65
 
66
+ def test_malformed_document_rejected_not_crash(self, tmp_path):
67
+ # Adversarial review F4: an empty file parses to None and a
68
+ # list/scalar document is equally malformed — both must raise
69
+ # BoardError (the Stop hook's "gate misconfigured" branch), never
70
+ # a bare AttributeError out of raw.get().
71
+ import pytest
72
+ for bad in ("", "- a\n- b\n", "just a scalar\n"):
73
+ p = tmp_path / "bad.yaml"
74
+ p.write_text(bad, encoding="utf-8")
75
+ with pytest.raises(BoardError):
76
+ load_board(p)
77
+
66
78
 
67
79
  class TestHeadSha:
68
80
  def test_clean_then_dirty(self, tmp_path, git_repo):
@@ -0,0 +1,48 @@
1
+ """_kill_stlinkserver guards the flash-retry path across platforms.
2
+
3
+ External review (2026-09-13): the bare `taskkill` invocation raised
4
+ FileNotFoundError on Linux/macOS, crashing the retry that follows a failed
5
+ flash attempt — the retry loop must survive cleanup failures on any OS.
6
+ """
7
+
8
+ from flashgate import flasher
9
+
10
+
11
+ class TestKillStlinkserver:
12
+ def test_noop_off_windows(self, monkeypatch):
13
+ monkeypatch.setattr(flasher.sys, "platform", "linux")
14
+
15
+ def boom(*args, **kwargs):
16
+ raise AssertionError("no process may be spawned off Windows")
17
+
18
+ monkeypatch.setattr(flasher.subprocess, "run", boom)
19
+ flasher._kill_stlinkserver() # must neither raise nor spawn
20
+
21
+ def test_windows_invokes_taskkill(self, monkeypatch):
22
+ monkeypatch.setattr(flasher.sys, "platform", "win32")
23
+ calls = []
24
+ monkeypatch.setattr(flasher.subprocess, "run",
25
+ lambda *a, **kw: calls.append(a) or None)
26
+ flasher._kill_stlinkserver()
27
+ assert calls, "on Windows the stale stlink-server must be killed"
28
+ assert calls[0][0][:2] == ["taskkill", "/F"]
29
+
30
+ def test_taskkill_failure_never_aborts_retry(self, monkeypatch):
31
+ monkeypatch.setattr(flasher.sys, "platform", "win32")
32
+
33
+ def boom(*args, **kwargs):
34
+ raise OSError("blocked by policy")
35
+
36
+ monkeypatch.setattr(flasher.subprocess, "run", boom)
37
+ flasher._kill_stlinkserver() # best-effort cleanup: swallow
38
+
39
+ def test_taskkill_timeout_never_aborts_retry(self, monkeypatch):
40
+ # Adversarial review F1: TimeoutExpired is a SubprocessError, not an
41
+ # OSError — the retry loop must survive a kill that hangs 15 s too.
42
+ monkeypatch.setattr(flasher.sys, "platform", "win32")
43
+
44
+ def hang(*args, **kwargs):
45
+ raise flasher.subprocess.TimeoutExpired(cmd="taskkill", timeout=15)
46
+
47
+ monkeypatch.setattr(flasher.subprocess, "run", hang)
48
+ flasher._kill_stlinkserver()
@@ -110,3 +110,49 @@ class TestUntrackedFingerprintCompleteness:
110
110
  (git_repo / "NewModule").mkdir()
111
111
  (git_repo / "NewModule" / "mod.c").write_text("int x;\n")
112
112
  assert watched_paths(git_repo, DEFAULT_WATCH) == ["NewModule/mod.c"]
113
+
114
+
115
+ class TestProfileFingerprint:
116
+ """The board profile defines what PASS means (probe expectations, build
117
+ command). Its content must be part of the fingerprint, or a cached
118
+ green survives edits to the verification semantics themselves."""
119
+
120
+ def test_profile_edit_changes_fingerprint(self, git_repo, tmp_path):
121
+ from flashgate.gatestate import tree_fingerprint
122
+ profile = tmp_path / "board.yaml"
123
+ profile.write_text("probes: [strict]\n")
124
+ fp1 = tree_fingerprint(git_repo, profile)
125
+ profile.write_text("probes: [loose]\n") # semantics changed
126
+ assert tree_fingerprint(git_repo, profile) != fp1
127
+
128
+ def test_tree_untouched_by_profile_only_change(self, git_repo, tmp_path):
129
+ # A profile edit alone must flip the combined fingerprint while the
130
+ # tree-only view stays identical — the delta comes from the profile.
131
+ from flashgate.gatestate import tree_fingerprint
132
+ profile = tmp_path / "board.yaml"
133
+ profile.write_text("probes: [a]\n")
134
+ before_tree = tree_fingerprint(git_repo)
135
+ combined1 = tree_fingerprint(git_repo, profile)
136
+ profile.write_text("probes: [b]\n")
137
+ assert tree_fingerprint(git_repo) == before_tree
138
+ assert tree_fingerprint(git_repo, profile) != combined1
139
+
140
+ def test_same_content_same_fingerprint(self, git_repo, tmp_path):
141
+ # Content-only identity: identical bytes, different name → same digest.
142
+ from flashgate.gatestate import tree_fingerprint
143
+ a = tmp_path / "a.yaml"
144
+ b = tmp_path / "b.yaml"
145
+ a.write_text("same: true\n")
146
+ b.write_text("same: true\n")
147
+ assert tree_fingerprint(git_repo, a) == tree_fingerprint(git_repo, b)
148
+
149
+ def test_missing_profile_is_stable_not_crashing(self, git_repo, tmp_path):
150
+ from flashgate.gatestate import tree_fingerprint
151
+ ghost = tmp_path / "ghost.yaml"
152
+ assert tree_fingerprint(git_repo, ghost) == tree_fingerprint(git_repo, ghost)
153
+
154
+ def test_profile_none_differs_from_empty_profile(self, git_repo, tmp_path):
155
+ from flashgate.gatestate import tree_fingerprint
156
+ empty = tmp_path / "empty.yaml"
157
+ empty.write_text("")
158
+ assert tree_fingerprint(git_repo) != tree_fingerprint(git_repo, empty)
@@ -0,0 +1,85 @@
1
+ """Stop hook wiring: the board profile must reach the fingerprint.
2
+
3
+ Gatestate-level tests prove profile edits change the fingerprint; this
4
+ file guards the CALLER. If hooks/flashgate_stop.py ever stops passing
5
+ board.yaml_path into tree_fingerprint, a cached PASS would survive edits
6
+ to the verification semantics (probe expectations, build command) and no
7
+ gatestate test would notice — this is the test that goes red.
8
+ """
9
+
10
+ import importlib.util
11
+ import io
12
+ import sys
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+
16
+ import pytest
17
+
18
+ HOOK_PATH = Path(__file__).resolve().parent.parent / "hooks" / "flashgate_stop.py"
19
+
20
+
21
+ def load_hook():
22
+ spec = importlib.util.spec_from_file_location("flashgate_stop_under_test", HOOK_PATH)
23
+ mod = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(mod)
25
+ return mod
26
+
27
+
28
+ @pytest.fixture()
29
+ def hook(git_repo, tmp_path, monkeypatch):
30
+ mod = load_hook()
31
+ # A watched change must exist, or the hook allows the stop before it
32
+ # ever reaches the fingerprint comparison.
33
+ (git_repo / "App" / "extra.c").write_text("int x;\n")
34
+ profile = tmp_path / "board.yaml"
35
+ profile.write_text("board: t\nprobes: [strict]\n")
36
+ board = SimpleNamespace(
37
+ firmware_dir=git_repo, watch_globs=("*",), yaml_path=profile,
38
+ )
39
+ monkeypatch.setattr(mod, "load_board", lambda p: board)
40
+ monkeypatch.delenv("FLASHGATE_BOARD", raising=False)
41
+ monkeypatch.setattr(sys, "argv", ["flashgate_stop.py"])
42
+ return mod, board, profile
43
+
44
+
45
+ def run_stop(mod, monkeypatch, state):
46
+ monkeypatch.setattr(sys, "stdin", io.StringIO("{}"))
47
+ monkeypatch.setattr(mod.gatestate, "load_state", lambda d: dict(state))
48
+ return mod.main()
49
+
50
+
51
+ class TestProfileInvalidatesCache:
52
+ def test_profile_edit_after_cached_pass_reruns_verify(
53
+ self, hook, git_repo, monkeypatch):
54
+ mod, board, profile = hook
55
+ calls = []
56
+
57
+ def fake_verify(board_arg):
58
+ calls.append(board_arg)
59
+ return 0, "PASS"
60
+
61
+ monkeypatch.setattr(mod, "run_verify", fake_verify)
62
+ saved = {}
63
+
64
+ def fake_save(d, **fields):
65
+ saved.update(fields)
66
+
67
+ monkeypatch.setattr(mod.gatestate, "save_state", fake_save)
68
+
69
+ # 1st stop: no cache → real verify → PASS cached (tree + profile).
70
+ assert run_stop(mod, monkeypatch, {}) == 0
71
+ assert len(calls) == 1
72
+ cached = saved["verified_fingerprint"]
73
+
74
+ # 2nd stop: identical tree AND profile → cache hit, no verify.
75
+ assert run_stop(mod, monkeypatch,
76
+ {"verified_fingerprint": cached}) == 0
77
+ assert len(calls) == 1
78
+
79
+ # 3rd stop: firmware tree untouched, but the verification SEMANTICS
80
+ # changed (profile edit) → the cached green must NOT be reused.
81
+ profile.write_text("board: t\nprobes: [loose]\n")
82
+ assert run_stop(mod, monkeypatch,
83
+ {"verified_fingerprint": cached}) == 0
84
+ assert len(calls) == 2, "a profile edit must force a re-verify"
85
+ assert saved["verified_fingerprint"] != cached
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes