flashgate 0.5.2__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.
- {flashgate-0.5.2 → flashgate-0.5.4}/PKG-INFO +13 -7
- {flashgate-0.5.2 → flashgate-0.5.4}/README.md +12 -6
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/board.py +9 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/flasher.py +17 -5
- flashgate-0.5.4/flashgate/gatestate.py +171 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate.egg-info/PKG-INFO +13 -7
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate.egg-info/SOURCES.txt +2 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/pyproject.toml +1 -1
- {flashgate-0.5.2 → flashgate-0.5.4}/tests/test_board.py +12 -0
- flashgate-0.5.4/tests/test_flasher.py +48 -0
- flashgate-0.5.4/tests/test_gatestate.py +158 -0
- flashgate-0.5.4/tests/test_hook_stop.py +85 -0
- flashgate-0.5.2/flashgate/gatestate.py +0 -81
- flashgate-0.5.2/tests/test_gatestate.py +0 -42
- {flashgate-0.5.2 → flashgate-0.5.4}/LICENSE +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/__init__.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/__main__.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/cli.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/mcp_server.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/probes.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/results.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/serialmon.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/sttools.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate/swdsig.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate.egg-info/dependency_links.txt +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate.egg-info/entry_points.txt +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate.egg-info/requires.txt +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/flashgate.egg-info/top_level.txt +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/setup.cfg +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/tests/test_cli.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/tests/test_mcp.py +0 -0
- {flashgate-0.5.2 → flashgate-0.5.4}/tests/test_probes.py +0 -0
- {flashgate-0.5.2 → 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
|
+
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
|
|
@@ -47,8 +47,12 @@ FLASHGATE-BOOT board=apollo-h743 git=2c58bd3 build=2026-08-28T07:26:35Z rtos=Fre
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
|
|
50
|
-
passing verify proves the board is running
|
|
51
|
-
|
|
50
|
+
passing verify proves the board is running a build of your current HEAD
|
|
51
|
+
(`-dirty` marks uncommitted changes; it does not fingerprint their
|
|
52
|
+
content — the Stop hook's tree fingerprint covers that side: tracked diffs
|
|
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,
|
|
52
56
|
including register readbacks (TIM3 CCR), not firmware self-reports:
|
|
53
57
|
|
|
54
58
|
```yaml
|
|
@@ -95,10 +99,12 @@ Board profiles, probes, fixes and docs are welcome — see
|
|
|
95
99
|
|
|
96
100
|
`hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
|
|
97
101
|
implementing the same hook contract works). When the agent tries to finish after
|
|
98
|
-
touching watched firmware files, the hook fingerprints the tree
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
102
108
|
|
|
103
109
|
```
|
|
104
110
|
[flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
|
|
@@ -28,8 +28,12 @@ FLASHGATE-BOOT board=apollo-h743 git=2c58bd3 build=2026-08-28T07:26:35Z rtos=Fre
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
|
|
31
|
-
passing verify proves the board is running
|
|
32
|
-
|
|
31
|
+
passing verify proves the board is running a build of your current HEAD
|
|
32
|
+
(`-dirty` marks uncommitted changes; it does not fingerprint their
|
|
33
|
+
content — the Stop hook's tree fingerprint covers that side: tracked diffs
|
|
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,
|
|
33
37
|
including register readbacks (TIM3 CCR), not firmware self-reports:
|
|
34
38
|
|
|
35
39
|
```yaml
|
|
@@ -76,10 +80,12 @@ Board profiles, probes, fixes and docs are welcome — see
|
|
|
76
80
|
|
|
77
81
|
`hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
|
|
78
82
|
implementing the same hook contract works). When the agent tries to finish after
|
|
79
|
-
touching watched firmware files, the hook fingerprints the tree
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
83
89
|
|
|
84
90
|
```
|
|
85
91
|
[flashgate] BLOCKED (attempt 1/2): firmware changes are not verified on hardware (verify rc=7).
|
|
@@ -48,11 +48,13 @@ class Board:
|
|
|
48
48
|
sha = subprocess.run(
|
|
49
49
|
["git", "rev-parse", "--short=7", "HEAD"],
|
|
50
50
|
cwd=self.firmware_dir, capture_output=True, text=True,
|
|
51
|
+
encoding="utf-8", errors="replace",
|
|
51
52
|
timeout=15, check=True,
|
|
52
53
|
).stdout.strip()
|
|
53
54
|
status = subprocess.run(
|
|
54
55
|
["git", "status", "--porcelain"],
|
|
55
56
|
cwd=self.firmware_dir, capture_output=True, text=True,
|
|
57
|
+
encoding="utf-8", errors="replace",
|
|
56
58
|
timeout=15, check=True,
|
|
57
59
|
).stdout.strip()
|
|
58
60
|
if status:
|
|
@@ -68,6 +70,13 @@ def load_board(yaml_path: Path) -> Board:
|
|
|
68
70
|
raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
|
|
69
71
|
except (OSError, yaml.YAMLError) as exc:
|
|
70
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__})")
|
|
71
80
|
|
|
72
81
|
fw = raw.get("firmware") or {}
|
|
73
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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]:
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Gate state: fingerprint the firmware tree, remember the last PASS.
|
|
2
|
+
|
|
3
|
+
The Stop hook only re-runs the (slow) hardware verify when the firmware tree
|
|
4
|
+
actually changed since the last passing run. The fingerprint covers HEAD,
|
|
5
|
+
the full diff against HEAD, and the untracked-file list — so any content
|
|
6
|
+
change to a watched file produces a new fingerprint.
|
|
7
|
+
|
|
8
|
+
Escalation: after MAX_CONSECUTIVE_BLOCKS failed blocks for the SAME
|
|
9
|
+
fingerprint, the gate releases with a warning instead of blocking forever
|
|
10
|
+
(coderio VerifyGate semantics: never wedge the session, never silently
|
|
11
|
+
give up).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from fnmatch import fnmatch
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
STATE_DIR = ".flashgate"
|
|
24
|
+
STATE_FILE = "state.json"
|
|
25
|
+
MAX_CONSECUTIVE_BLOCKS = 2
|
|
26
|
+
|
|
27
|
+
DEFAULT_WATCH = [
|
|
28
|
+
"*.c", "*.h", "*.s", "*.ld", "*.ioc",
|
|
29
|
+
"CMakeLists.txt", "*.cmake", "cmake/*",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git(args: list[str], cwd: Path) -> str:
|
|
34
|
+
# errors="surrogateescape": `ls-files -z` emits raw UTF-8 path bytes and
|
|
35
|
+
# text=True alone would decode with the ANSI code page (cp936 on a
|
|
36
|
+
# Chinese Windows) — a non-ASCII path then kills the reader thread,
|
|
37
|
+
# stdout becomes None, and the Stop hook crashes to exit 1, which the
|
|
38
|
+
# hook contract treats as non-blocking: the gate would fail OPEN.
|
|
39
|
+
try:
|
|
40
|
+
proc = subprocess.run(
|
|
41
|
+
["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
42
|
+
encoding="utf-8", errors="surrogateescape",
|
|
43
|
+
timeout=20, check=True,
|
|
44
|
+
)
|
|
45
|
+
return proc.stdout or ""
|
|
46
|
+
except (subprocess.SubprocessError, OSError):
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_HASH_CAP_BYTES = 4 * 1024 * 1024 # per file: length + first 4 MiB
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _untracked_files(fw_dir: Path) -> list[str]:
|
|
54
|
+
"""Untracked files, recursively, gitignore-respecting, no quoting.
|
|
55
|
+
|
|
56
|
+
`git status --porcelain` collapses a new directory to '?? dir/' (hiding
|
|
57
|
+
every file inside) and C-escapes non-ASCII paths under core.quotepath —
|
|
58
|
+
both let content edits slip past a content digest. `ls-files
|
|
59
|
+
--others --exclude-standard -z` lists real paths, NUL-separated."""
|
|
60
|
+
out = _git(["ls-files", "--others", "--exclude-standard", "-z"], fw_dir)
|
|
61
|
+
return sorted(p for p in out.split("\x00") if p)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _untracked_content_digest(fw_dir: Path) -> str:
|
|
65
|
+
"""Digest over the CONTENT of untracked files.
|
|
66
|
+
|
|
67
|
+
Found by the 2026-09-13 external review: git diff ignores untracked
|
|
68
|
+
files and git status lists only their paths, so editing an untracked
|
|
69
|
+
source file after a cached PASS kept the fingerprint unchanged — the
|
|
70
|
+
Stop hook reused the stale green. Hashing path + size + content closes
|
|
71
|
+
it. The gate's own state dir is excluded: save_state() rewrites a
|
|
72
|
+
timestamp there, and letting it feed the fingerprint would invalidate
|
|
73
|
+
every cached PASS."""
|
|
74
|
+
h = hashlib.sha256()
|
|
75
|
+
for path in _untracked_files(fw_dir):
|
|
76
|
+
if path == ".flashgate" or path.startswith(".flashgate/"):
|
|
77
|
+
continue
|
|
78
|
+
f = fw_dir / path
|
|
79
|
+
try:
|
|
80
|
+
size = f.stat().st_size
|
|
81
|
+
h.update(f"{path}\x00{size}\x00".encode("utf-8", errors="replace"))
|
|
82
|
+
with f.open("rb") as fh: # chunked: never OOM on a
|
|
83
|
+
remaining = min(size, _HASH_CAP_BYTES) # stray huge file
|
|
84
|
+
while remaining > 0:
|
|
85
|
+
chunk = fh.read(min(1 << 20, remaining))
|
|
86
|
+
if not chunk:
|
|
87
|
+
break
|
|
88
|
+
h.update(chunk)
|
|
89
|
+
remaining -= len(chunk)
|
|
90
|
+
except OSError:
|
|
91
|
+
h.update(f"{path}\x00<unreadable>\x00".encode("utf-8", errors="replace"))
|
|
92
|
+
h.update(b"\x00")
|
|
93
|
+
return h.hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
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)."""
|
|
126
|
+
head = _git(["rev-parse", "HEAD"], fw_dir)
|
|
127
|
+
diff = _git(["diff", "HEAD"], fw_dir)
|
|
128
|
+
status = _git(["status", "--porcelain"], fw_dir)
|
|
129
|
+
untracked = _untracked_content_digest(fw_dir)
|
|
130
|
+
profile_part = _profile_digest(profile) if profile is not None else "<no-profile>"
|
|
131
|
+
blob = "\x00".join((head, diff, status, untracked, profile_part))
|
|
132
|
+
return hashlib.sha256(blob.encode("utf-8", errors="replace")).hexdigest()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def watched_paths(fw_dir: Path, patterns: list[str]) -> list[str]:
|
|
136
|
+
"""Working-tree changes that match the watch globs (gate triggers)."""
|
|
137
|
+
status = _git(["status", "--porcelain"], fw_dir)
|
|
138
|
+
watched: list[str] = []
|
|
139
|
+
for line in status.splitlines():
|
|
140
|
+
if line.startswith("?? "):
|
|
141
|
+
continue # untracked: handled below, dir-collapsed in status
|
|
142
|
+
path = line[3:].strip().strip('"')
|
|
143
|
+
if " -> " in path: # rename: judge by the new name
|
|
144
|
+
path = path.split(" -> ")[1]
|
|
145
|
+
if path and any(fnmatch(path, pat) for pat in patterns):
|
|
146
|
+
watched.append(path)
|
|
147
|
+
# Untracked files come from ls-files: a new directory collapses to a
|
|
148
|
+
# single '?? dir/' status line that no *.c glob can match, which used
|
|
149
|
+
# to mean new files in new dirs never even triggered the gate.
|
|
150
|
+
for path in _untracked_files(fw_dir):
|
|
151
|
+
if any(fnmatch(path, pat) for pat in patterns):
|
|
152
|
+
watched.append(path)
|
|
153
|
+
return watched
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def state_path(fw_dir: Path) -> Path:
|
|
157
|
+
return fw_dir / STATE_DIR / STATE_FILE
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_state(fw_dir: Path) -> dict:
|
|
161
|
+
try:
|
|
162
|
+
return json.loads(state_path(fw_dir).read_text(encoding="utf-8"))
|
|
163
|
+
except (OSError, ValueError):
|
|
164
|
+
return {}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def save_state(fw_dir: Path, **fields) -> None:
|
|
168
|
+
path = state_path(fw_dir)
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
payload = {"updated": datetime.now(timezone.utc).isoformat(), **fields}
|
|
171
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.5.
|
|
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
|
|
@@ -47,8 +47,12 @@ FLASHGATE-BOOT board=apollo-h743 git=2c58bd3 build=2026-08-28T07:26:35Z rtos=Fre
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Both carry the git sha plus `-dirty` when the tree differs from HEAD, so a
|
|
50
|
-
passing verify proves the board is running
|
|
51
|
-
|
|
50
|
+
passing verify proves the board is running a build of your current HEAD
|
|
51
|
+
(`-dirty` marks uncommitted changes; it does not fingerprint their
|
|
52
|
+
content — the Stop hook's tree fingerprint covers that side: tracked diffs
|
|
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,
|
|
52
56
|
including register readbacks (TIM3 CCR), not firmware self-reports:
|
|
53
57
|
|
|
54
58
|
```yaml
|
|
@@ -95,10 +99,12 @@ Board profiles, probes, fixes and docs are welcome — see
|
|
|
95
99
|
|
|
96
100
|
`hooks/flashgate_stop.py` is a Claude Code Stop hook (any harness
|
|
97
101
|
implementing the same hook contract works). When the agent tries to finish after
|
|
98
|
-
touching watched firmware files, the hook fingerprints the tree
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
102
108
|
|
|
103
109
|
```
|
|
104
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.
|
|
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()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tree fingerprint and watched-path filtering over a real git repo."""
|
|
2
|
+
|
|
3
|
+
from flashgate import gatestate
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestWatchedPaths:
|
|
7
|
+
def test_watch_filter(self, git_repo):
|
|
8
|
+
(git_repo / "App" / "extra.c").write_text("int x;\n")
|
|
9
|
+
(git_repo / "readme2.md").write_text("docs\n")
|
|
10
|
+
watched = gatestate.watched_paths(git_repo, ["*.c", "*.h"])
|
|
11
|
+
assert "App/extra.c" in watched
|
|
12
|
+
assert all(w.endswith((".c", ".h")) for w in watched)
|
|
13
|
+
|
|
14
|
+
def test_no_changes(self, git_repo):
|
|
15
|
+
assert gatestate.watched_paths(git_repo, ["*.c"]) == []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestTreeFingerprint:
|
|
19
|
+
def test_stable_when_unchanged(self, git_repo):
|
|
20
|
+
a = gatestate.tree_fingerprint(git_repo)
|
|
21
|
+
b = gatestate.tree_fingerprint(git_repo)
|
|
22
|
+
assert a == b
|
|
23
|
+
|
|
24
|
+
def test_changes_with_content(self, git_repo):
|
|
25
|
+
before = gatestate.tree_fingerprint(git_repo)
|
|
26
|
+
(git_repo / "App" / "main.c").write_text("int main(void) { return 2; }\n")
|
|
27
|
+
assert gatestate.tree_fingerprint(git_repo) != before
|
|
28
|
+
|
|
29
|
+
def test_two_dirty_states_differ(self, git_repo):
|
|
30
|
+
(git_repo / "App" / "main.c").write_text("/* a */\n")
|
|
31
|
+
a = gatestate.tree_fingerprint(git_repo)
|
|
32
|
+
(git_repo / "App" / "main.c").write_text("/* b */\n")
|
|
33
|
+
assert gatestate.tree_fingerprint(git_repo) != a
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestState:
|
|
37
|
+
def test_roundtrip_and_missing(self, tmp_path, git_repo):
|
|
38
|
+
assert gatestate.load_state(git_repo) == {}
|
|
39
|
+
gatestate.save_state(git_repo, verified_fingerprint="abc", consecutive_blocks=0)
|
|
40
|
+
s = gatestate.load_state(git_repo)
|
|
41
|
+
assert s["verified_fingerprint"] == "abc"
|
|
42
|
+
assert "updated" in s
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestUntrackedContentFingerprint:
|
|
46
|
+
def test_untracked_content_edit_changes_fingerprint(self, git_repo):
|
|
47
|
+
# 2026-09-13 external-review finding: git status lists untracked
|
|
48
|
+
# paths only, so editing an untracked file's CONTENT kept the tree
|
|
49
|
+
# fingerprint unchanged — the Stop hook would reuse a cached PASS
|
|
50
|
+
# over silently-modified code. Contents must be hashed in.
|
|
51
|
+
from flashgate.gatestate import tree_fingerprint
|
|
52
|
+
untracked = git_repo / "App" / "new.c"
|
|
53
|
+
untracked.write_text("int x = 1;\n")
|
|
54
|
+
fp1 = tree_fingerprint(git_repo)
|
|
55
|
+
untracked.write_text("int x = 999; /* silently changed */\n")
|
|
56
|
+
fp2 = tree_fingerprint(git_repo)
|
|
57
|
+
assert fp1 != fp2, "untracked content edit MUST change the fingerprint"
|
|
58
|
+
|
|
59
|
+
def test_untracked_appearance_changes_fingerprint(self, git_repo):
|
|
60
|
+
from flashgate.gatestate import tree_fingerprint
|
|
61
|
+
fp1 = tree_fingerprint(git_repo)
|
|
62
|
+
(git_repo / "App" / "added.c").write_text("int y;\n")
|
|
63
|
+
assert tree_fingerprint(git_repo) != fp1
|
|
64
|
+
|
|
65
|
+
def test_tracked_edit_still_changes_fingerprint(self, git_repo):
|
|
66
|
+
from flashgate.gatestate import tree_fingerprint
|
|
67
|
+
fp1 = tree_fingerprint(git_repo)
|
|
68
|
+
(git_repo / "App" / "main.c").write_text("int main(void){return 1;}\n")
|
|
69
|
+
assert tree_fingerprint(git_repo) != fp1
|
|
70
|
+
|
|
71
|
+
def test_clean_tree_fingerprint_stable(self, git_repo):
|
|
72
|
+
from flashgate.gatestate import tree_fingerprint
|
|
73
|
+
assert tree_fingerprint(git_repo) == tree_fingerprint(git_repo)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestUntrackedFingerprintCompleteness:
|
|
77
|
+
"""Residual holes found by the adversarial re-review of the first fix:
|
|
78
|
+
untracked NEW directories (git status collapses to '?? dir/') and
|
|
79
|
+
non-ASCII paths (core.quotepath escaping) must not hide content edits,
|
|
80
|
+
and the gate's own state writes must not invalidate the PASS cache."""
|
|
81
|
+
|
|
82
|
+
def test_new_directory_content_edit_changes_fingerprint(self, git_repo):
|
|
83
|
+
from flashgate.gatestate import tree_fingerprint
|
|
84
|
+
mod = git_repo / "NewModule"
|
|
85
|
+
mod.mkdir()
|
|
86
|
+
(mod / "mod.c").write_text("int f(void){return 1;}\n")
|
|
87
|
+
fp1 = tree_fingerprint(git_repo)
|
|
88
|
+
(mod / "mod.c").write_text("int f(void){return 999;}\n")
|
|
89
|
+
fp2 = tree_fingerprint(git_repo)
|
|
90
|
+
assert fp1 != fp2, "content edit inside an untracked NEW dir must be visible"
|
|
91
|
+
|
|
92
|
+
def test_non_ascii_path_content_edit_changes_fingerprint(self, git_repo):
|
|
93
|
+
from flashgate.gatestate import tree_fingerprint
|
|
94
|
+
src = git_repo / "App" / "新模块.c"
|
|
95
|
+
src.write_text("int a = 1;\n")
|
|
96
|
+
fp1 = tree_fingerprint(git_repo)
|
|
97
|
+
src.write_text("int a = 999; /* silently changed */\n")
|
|
98
|
+
fp2 = tree_fingerprint(git_repo)
|
|
99
|
+
assert fp1 != fp2, "non-ASCII path (quotepath escaping) must not hide edits"
|
|
100
|
+
|
|
101
|
+
def test_state_dir_writes_do_not_change_fingerprint(self, git_repo):
|
|
102
|
+
from flashgate.gatestate import save_state, tree_fingerprint
|
|
103
|
+
save_state(git_repo, status="pass", fingerprint="x") # state dir exists
|
|
104
|
+
fp1 = tree_fingerprint(git_repo)
|
|
105
|
+
save_state(git_repo, status="pass", fingerprint="y") # timestamp moves
|
|
106
|
+
assert tree_fingerprint(git_repo) == fp1, ".flashgate/ must stay out of the digest"
|
|
107
|
+
|
|
108
|
+
def test_new_dir_source_triggers_watch(self, git_repo):
|
|
109
|
+
from flashgate.gatestate import DEFAULT_WATCH, watched_paths
|
|
110
|
+
(git_repo / "NewModule").mkdir()
|
|
111
|
+
(git_repo / "NewModule" / "mod.c").write_text("int x;\n")
|
|
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
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Gate state: fingerprint the firmware tree, remember the last PASS.
|
|
2
|
-
|
|
3
|
-
The Stop hook only re-runs the (slow) hardware verify when the firmware tree
|
|
4
|
-
actually changed since the last passing run. The fingerprint covers HEAD,
|
|
5
|
-
the full diff against HEAD, and the untracked-file list — so any content
|
|
6
|
-
change to a watched file produces a new fingerprint.
|
|
7
|
-
|
|
8
|
-
Escalation: after MAX_CONSECUTIVE_BLOCKS failed blocks for the SAME
|
|
9
|
-
fingerprint, the gate releases with a warning instead of blocking forever
|
|
10
|
-
(coderio VerifyGate semantics: never wedge the session, never silently
|
|
11
|
-
give up).
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import hashlib
|
|
17
|
-
import json
|
|
18
|
-
import subprocess
|
|
19
|
-
from datetime import datetime, timezone
|
|
20
|
-
from fnmatch import fnmatch
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
STATE_DIR = ".flashgate"
|
|
24
|
-
STATE_FILE = "state.json"
|
|
25
|
-
MAX_CONSECUTIVE_BLOCKS = 2
|
|
26
|
-
|
|
27
|
-
DEFAULT_WATCH = [
|
|
28
|
-
"*.c", "*.h", "*.s", "*.ld", "*.ioc",
|
|
29
|
-
"CMakeLists.txt", "*.cmake", "cmake/*",
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _git(args: list[str], cwd: Path) -> str:
|
|
34
|
-
try:
|
|
35
|
-
return subprocess.run(
|
|
36
|
-
["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
37
|
-
timeout=20, check=True,
|
|
38
|
-
).stdout
|
|
39
|
-
except (subprocess.SubprocessError, OSError):
|
|
40
|
-
return ""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def tree_fingerprint(fw_dir: Path) -> str:
|
|
44
|
-
"""Content-identity of the working tree (not just HEAD: dirty trees with
|
|
45
|
-
different edits must NOT share a fingerprint)."""
|
|
46
|
-
head = _git(["rev-parse", "HEAD"], fw_dir)
|
|
47
|
-
diff = _git(["diff", "HEAD"], fw_dir)
|
|
48
|
-
status = _git(["status", "--porcelain"], fw_dir)
|
|
49
|
-
blob = "\x00".join((head, diff, status))
|
|
50
|
-
return hashlib.sha256(blob.encode("utf-8", errors="replace")).hexdigest()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def watched_paths(fw_dir: Path, patterns: list[str]) -> list[str]:
|
|
54
|
-
"""Working-tree changes that match the watch globs (gate triggers)."""
|
|
55
|
-
status = _git(["status", "--porcelain"], fw_dir)
|
|
56
|
-
watched: list[str] = []
|
|
57
|
-
for line in status.splitlines():
|
|
58
|
-
path = line[3:].strip().strip('"')
|
|
59
|
-
if " -> " in path: # rename: judge by the new name
|
|
60
|
-
path = path.split(" -> ")[1]
|
|
61
|
-
if path and any(fnmatch(path, pat) for pat in patterns):
|
|
62
|
-
watched.append(path)
|
|
63
|
-
return watched
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def state_path(fw_dir: Path) -> Path:
|
|
67
|
-
return fw_dir / STATE_DIR / STATE_FILE
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def load_state(fw_dir: Path) -> dict:
|
|
71
|
-
try:
|
|
72
|
-
return json.loads(state_path(fw_dir).read_text(encoding="utf-8"))
|
|
73
|
-
except (OSError, ValueError):
|
|
74
|
-
return {}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def save_state(fw_dir: Path, **fields) -> None:
|
|
78
|
-
path = state_path(fw_dir)
|
|
79
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
payload = {"updated": datetime.now(timezone.utc).isoformat(), **fields}
|
|
81
|
-
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
"""Tree fingerprint and watched-path filtering over a real git repo."""
|
|
2
|
-
|
|
3
|
-
from flashgate import gatestate
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class TestWatchedPaths:
|
|
7
|
-
def test_watch_filter(self, git_repo):
|
|
8
|
-
(git_repo / "App" / "extra.c").write_text("int x;\n")
|
|
9
|
-
(git_repo / "readme2.md").write_text("docs\n")
|
|
10
|
-
watched = gatestate.watched_paths(git_repo, ["*.c", "*.h"])
|
|
11
|
-
assert "App/extra.c" in watched
|
|
12
|
-
assert all(w.endswith((".c", ".h")) for w in watched)
|
|
13
|
-
|
|
14
|
-
def test_no_changes(self, git_repo):
|
|
15
|
-
assert gatestate.watched_paths(git_repo, ["*.c"]) == []
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class TestTreeFingerprint:
|
|
19
|
-
def test_stable_when_unchanged(self, git_repo):
|
|
20
|
-
a = gatestate.tree_fingerprint(git_repo)
|
|
21
|
-
b = gatestate.tree_fingerprint(git_repo)
|
|
22
|
-
assert a == b
|
|
23
|
-
|
|
24
|
-
def test_changes_with_content(self, git_repo):
|
|
25
|
-
before = gatestate.tree_fingerprint(git_repo)
|
|
26
|
-
(git_repo / "App" / "main.c").write_text("int main(void) { return 2; }\n")
|
|
27
|
-
assert gatestate.tree_fingerprint(git_repo) != before
|
|
28
|
-
|
|
29
|
-
def test_two_dirty_states_differ(self, git_repo):
|
|
30
|
-
(git_repo / "App" / "main.c").write_text("/* a */\n")
|
|
31
|
-
a = gatestate.tree_fingerprint(git_repo)
|
|
32
|
-
(git_repo / "App" / "main.c").write_text("/* b */\n")
|
|
33
|
-
assert gatestate.tree_fingerprint(git_repo) != a
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class TestState:
|
|
37
|
-
def test_roundtrip_and_missing(self, tmp_path, git_repo):
|
|
38
|
-
assert gatestate.load_state(git_repo) == {}
|
|
39
|
-
gatestate.save_state(git_repo, verified_fingerprint="abc", consecutive_blocks=0)
|
|
40
|
-
s = gatestate.load_state(git_repo)
|
|
41
|
-
assert s["verified_fingerprint"] == "abc"
|
|
42
|
-
assert "updated" in s
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|