@matt82198/aesop 0.2.0 → 0.3.2
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.
- package/CHANGELOG.md +39 -1
- package/README.md +60 -263
- package/bin/cli.js +7 -3
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +48 -3
- package/docs/PORTING.md +166 -0
- package/docs/TEAM-STATE.md +16 -184
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +50 -48
- package/driver/codex_driver.py +59 -12
- package/driver/openai_transport.py +33 -0
- package/driver/wave_bridge.py +9 -1
- package/driver/wave_loop.py +437 -70
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +22 -5
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +6 -4
- package/state_store/__init__.py +2 -1
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/write_api.py +462 -0
- package/tools/cost_ceiling.py +106 -15
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/eod_sweep.py +137 -33
- package/tools/mutation_test.py +130 -8
- package/tools/proposals.mjs +47 -2
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +73 -18
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/wave_preflight.py +296 -29
- package/tools/wave_templates.py +170 -45
- package/ui/collectors.py +81 -0
- package/ui/handler.py +230 -85
- package/ui/sse.py +3 -3
- package/ui/wave_telemetry.py +24 -18
- package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/docs/QUICKSTART.md +0 -80
- package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-OS drift measurement tool.
|
|
3
|
+
|
|
4
|
+
Quantifies Windows-vs-Linux CI outcome drift from GitHub Actions history via gh CLI.
|
|
5
|
+
|
|
6
|
+
For the last N completed ci.yml runs on main, collects per-job conclusions (ubuntu ci shards vs
|
|
7
|
+
windows job) and reports:
|
|
8
|
+
- Windows pass rate vs ubuntu pass rate
|
|
9
|
+
- Set of runs where they diverged (ubuntu green + windows red)
|
|
10
|
+
- Failing windows test names aggregated by frequency (from gh run view --job <id> --log-failed)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python crossos_drift.py [--runs N=10] [--json]
|
|
14
|
+
|
|
15
|
+
Exit codes:
|
|
16
|
+
0 - Success
|
|
17
|
+
1 - Error (e.g., parsing failure, logic error)
|
|
18
|
+
2 - Execution error (e.g., gh call failed)
|
|
19
|
+
3 - Authentication error (gh is not authenticated)
|
|
20
|
+
|
|
21
|
+
Honesty:
|
|
22
|
+
- If windows job doesn't exist (pre-#317), counts as NOT-PRESENT in per-run analysis
|
|
23
|
+
- If gh is unauthenticated, exits 3 with a clear message
|
|
24
|
+
- Parsing bounds test names; full logs never dumped
|
|
25
|
+
"""
|
|
26
|
+
import sys
|
|
27
|
+
import subprocess
|
|
28
|
+
import json
|
|
29
|
+
import argparse
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from typing import Dict, List, Tuple, Optional
|
|
32
|
+
|
|
33
|
+
DEFAULT_RUNS = 10
|
|
34
|
+
|
|
35
|
+
# GitHub Actions workflow context
|
|
36
|
+
WORKFLOW_FILE = "ci.yml"
|
|
37
|
+
GH_REPO = None # Auto-detect current repo
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GhError(Exception):
|
|
41
|
+
"""gh CLI error."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def gh_run_list(limit: int = 10, branch: str = "main") -> List[Dict]:
|
|
46
|
+
"""Fetch the last N completed workflow runs for ci.yml on main.
|
|
47
|
+
|
|
48
|
+
Returns list of run objects with: databaseId, number, name, status, conclusion, createdAt.
|
|
49
|
+
Raises GhError if gh is not authenticated.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
cmd = [
|
|
53
|
+
"gh", "run", "list",
|
|
54
|
+
"--workflow", WORKFLOW_FILE,
|
|
55
|
+
"--branch", branch,
|
|
56
|
+
"--limit", str(limit),
|
|
57
|
+
"--json", "databaseId,number,name,status,conclusion,createdAt",
|
|
58
|
+
]
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
cmd,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=30,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
stderr = result.stderr.lower()
|
|
68
|
+
if "authentication" in stderr or "not authenticated" in stderr:
|
|
69
|
+
raise GhError("gh is not authenticated")
|
|
70
|
+
raise GhError(f"gh run list failed: {result.stderr}")
|
|
71
|
+
|
|
72
|
+
return json.loads(result.stdout)
|
|
73
|
+
except subprocess.TimeoutExpired:
|
|
74
|
+
raise GhError("gh run list timed out")
|
|
75
|
+
except json.JSONDecodeError as e:
|
|
76
|
+
raise GhError(f"Failed to parse gh response: {e}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gh_jobs_for_run(run_id: str) -> List[Dict]:
|
|
80
|
+
"""Fetch all jobs for a given run.
|
|
81
|
+
|
|
82
|
+
Returns list of job objects with: id, name, status, conclusion.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
cmd = [
|
|
86
|
+
"gh", "run", "view", str(run_id),
|
|
87
|
+
"--json", "jobs",
|
|
88
|
+
]
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
cmd,
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
timeout=30,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
raise GhError(f"gh run view failed: {result.stderr}")
|
|
98
|
+
|
|
99
|
+
data = json.loads(result.stdout)
|
|
100
|
+
return data.get("jobs", [])
|
|
101
|
+
except subprocess.TimeoutExpired:
|
|
102
|
+
raise GhError(f"gh run view timed out for run {run_id}")
|
|
103
|
+
except json.JSONDecodeError as e:
|
|
104
|
+
raise GhError(f"Failed to parse gh jobs response: {e}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def gh_job_logs(job_id: str) -> str:
|
|
108
|
+
"""Fetch logs for a job. Return stdout only (not stderr).
|
|
109
|
+
|
|
110
|
+
Returns the full log text as a string.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
cmd = [
|
|
114
|
+
"gh", "run", "view", "--job", str(job_id), "--log",
|
|
115
|
+
]
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
cmd,
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
timeout=60,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if result.returncode != 0:
|
|
124
|
+
# Log fetch failure is not fatal; continue with divergence analysis
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
return result.stdout
|
|
128
|
+
except subprocess.TimeoutExpired:
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_failing_tests(log_text: str) -> Dict[str, int]:
|
|
133
|
+
"""Parse failing test names from job log output.
|
|
134
|
+
|
|
135
|
+
Searches for lines matching "FAIL:" or "not ok" patterns.
|
|
136
|
+
Returns dict of {test_name: frequency} sorted by frequency descending.
|
|
137
|
+
"""
|
|
138
|
+
failures = defaultdict(int)
|
|
139
|
+
|
|
140
|
+
for line in log_text.split("\n"):
|
|
141
|
+
line = line.strip()
|
|
142
|
+
test_name = None
|
|
143
|
+
|
|
144
|
+
if line.startswith("FAIL:"):
|
|
145
|
+
test_name = line.replace("FAIL:", "", 1).strip()
|
|
146
|
+
elif line.startswith("not ok"):
|
|
147
|
+
# TAP format: "not ok N - test_name" or "not ok test_name"
|
|
148
|
+
test_name = line.replace("not ok", "", 1).strip()
|
|
149
|
+
# Remove test number prefix if present
|
|
150
|
+
if test_name and test_name[0].isdigit():
|
|
151
|
+
test_name = " ".join(test_name.split()[1:])
|
|
152
|
+
|
|
153
|
+
if test_name:
|
|
154
|
+
failures[test_name] += 1
|
|
155
|
+
|
|
156
|
+
return dict(sorted(failures.items(), key=lambda x: x[1], reverse=True))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def classify_job(job: Dict) -> Tuple[str, Optional[str]]:
|
|
160
|
+
"""Classify a job as ubuntu/windows and return (category, specific_name).
|
|
161
|
+
|
|
162
|
+
Returns: ("ubuntu", "shard-X") | ("windows", None) | (None, None)
|
|
163
|
+
"""
|
|
164
|
+
name = job.get("name", "").lower()
|
|
165
|
+
|
|
166
|
+
# Windows job: named "windows"
|
|
167
|
+
if name == "windows":
|
|
168
|
+
return ("windows", job.get("name"))
|
|
169
|
+
|
|
170
|
+
# Ubuntu jobs: named "ci (N)" or contain "ubuntu"/"linux"
|
|
171
|
+
if name.startswith("ci (") or "ubuntu" in name or "linux" in name:
|
|
172
|
+
return ("ubuntu", job.get("name"))
|
|
173
|
+
|
|
174
|
+
return (None, None)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def job_conclusion(job: Dict) -> str:
|
|
178
|
+
"""Extract job conclusion as PASS or FAIL or PENDING.
|
|
179
|
+
|
|
180
|
+
Return "PASS" if status=COMPLETED and conclusion=SUCCESS.
|
|
181
|
+
Return "FAIL" if any failure-like conclusion.
|
|
182
|
+
Return "PENDING" if not yet complete.
|
|
183
|
+
"""
|
|
184
|
+
status = job.get("status", "").upper()
|
|
185
|
+
conclusion = job.get("conclusion", "").upper()
|
|
186
|
+
|
|
187
|
+
if status != "COMPLETED":
|
|
188
|
+
return "PENDING"
|
|
189
|
+
|
|
190
|
+
if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
|
191
|
+
return "PASS"
|
|
192
|
+
elif conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STALE"):
|
|
193
|
+
return "FAIL"
|
|
194
|
+
|
|
195
|
+
return "PENDING"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def analyze_run(run_id: str) -> Tuple[Optional[str], Optional[str]]:
|
|
199
|
+
"""Analyze a single run.
|
|
200
|
+
|
|
201
|
+
Returns (ubuntu_status, windows_status) where each is:
|
|
202
|
+
"PASS" | "FAIL" | "PENDING" | "NOT-PRESENT"
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
jobs = gh_jobs_for_run(run_id)
|
|
206
|
+
|
|
207
|
+
ubuntu_results = []
|
|
208
|
+
windows_result = None
|
|
209
|
+
|
|
210
|
+
for job in jobs:
|
|
211
|
+
category, _ = classify_job(job)
|
|
212
|
+
conclusion = job_conclusion(job)
|
|
213
|
+
|
|
214
|
+
if category == "ubuntu":
|
|
215
|
+
ubuntu_results.append(conclusion)
|
|
216
|
+
elif category == "windows":
|
|
217
|
+
windows_result = conclusion
|
|
218
|
+
|
|
219
|
+
# Ubuntu: all shards must pass for "PASS"
|
|
220
|
+
ubuntu_status = "PASS" if ubuntu_results and all(r == "PASS" for r in ubuntu_results) else (
|
|
221
|
+
"FAIL" if ubuntu_results and any(r == "FAIL" for r in ubuntu_results) else "PENDING"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Windows: not present if no windows job found
|
|
225
|
+
windows_status = windows_result if windows_result else "NOT-PRESENT"
|
|
226
|
+
|
|
227
|
+
return (ubuntu_status, windows_status)
|
|
228
|
+
except GhError:
|
|
229
|
+
# Run analysis failed; skip this run
|
|
230
|
+
return (None, None)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def analyze_windows_failures(run_id: str) -> Dict[str, int]:
|
|
234
|
+
"""Fetch logs for windows job in this run and parse failing tests.
|
|
235
|
+
|
|
236
|
+
Returns dict of {test_name: frequency}.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
jobs = gh_jobs_for_run(run_id)
|
|
240
|
+
|
|
241
|
+
for job in jobs:
|
|
242
|
+
category, _ = classify_job(job)
|
|
243
|
+
if category == "windows":
|
|
244
|
+
job_id = job.get("id")
|
|
245
|
+
if job_id:
|
|
246
|
+
log_text = gh_job_logs(job_id)
|
|
247
|
+
if log_text:
|
|
248
|
+
return parse_failing_tests(log_text)
|
|
249
|
+
except GhError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
return {}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main():
|
|
256
|
+
"""Main entry point."""
|
|
257
|
+
parser = argparse.ArgumentParser(
|
|
258
|
+
description="Measure Windows-vs-Linux CI outcome drift from GitHub Actions history.",
|
|
259
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
260
|
+
epilog="""\
|
|
261
|
+
Examples:
|
|
262
|
+
python crossos_drift.py
|
|
263
|
+
Analyze last 10 completed ci.yml runs on main (default).
|
|
264
|
+
|
|
265
|
+
python crossos_drift.py --runs 8
|
|
266
|
+
Analyze last 8 runs.
|
|
267
|
+
|
|
268
|
+
python crossos_drift.py --runs 10 --json
|
|
269
|
+
Output results as JSON.
|
|
270
|
+
|
|
271
|
+
Exit codes:
|
|
272
|
+
0 - Success
|
|
273
|
+
1 - Error (parsing, logic, etc.)
|
|
274
|
+
2 - Execution error (gh call failed)
|
|
275
|
+
3 - Authentication error (gh not authenticated)
|
|
276
|
+
""",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
parser.add_argument(
|
|
280
|
+
"--runs",
|
|
281
|
+
type=int,
|
|
282
|
+
default=DEFAULT_RUNS,
|
|
283
|
+
help=f"Number of completed runs to analyze (default: {DEFAULT_RUNS})",
|
|
284
|
+
)
|
|
285
|
+
parser.add_argument(
|
|
286
|
+
"--json",
|
|
287
|
+
action="store_true",
|
|
288
|
+
help="Output results as JSON",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
args = parser.parse_args()
|
|
292
|
+
|
|
293
|
+
if args.runs <= 0:
|
|
294
|
+
print("ERROR: --runs must be > 0", file=sys.stderr)
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
|
|
297
|
+
# Fetch run list
|
|
298
|
+
try:
|
|
299
|
+
runs = gh_run_list(limit=args.runs)
|
|
300
|
+
except GhError as e:
|
|
301
|
+
if "not authenticated" in str(e).lower():
|
|
302
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
303
|
+
sys.exit(3)
|
|
304
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
305
|
+
sys.exit(2)
|
|
306
|
+
|
|
307
|
+
if not runs:
|
|
308
|
+
print("ERROR: No completed runs found", file=sys.stderr)
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
|
|
311
|
+
# Analyze each run
|
|
312
|
+
ubuntu_results = []
|
|
313
|
+
windows_results = []
|
|
314
|
+
divergences = []
|
|
315
|
+
all_windows_failures = defaultdict(int)
|
|
316
|
+
|
|
317
|
+
for run in runs:
|
|
318
|
+
run_id = run.get("databaseId") or run.get("number")
|
|
319
|
+
ubuntu_status, windows_status = analyze_run(str(run_id))
|
|
320
|
+
|
|
321
|
+
if ubuntu_status is None or windows_status is None:
|
|
322
|
+
# Run analysis failed
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
ubuntu_results.append(ubuntu_status)
|
|
326
|
+
windows_results.append(windows_status)
|
|
327
|
+
|
|
328
|
+
# Detect divergence: ubuntu PASS + windows FAIL (or NOT-PRESENT doesn't count as divergence)
|
|
329
|
+
if ubuntu_status == "PASS" and windows_status == "FAIL":
|
|
330
|
+
divergences.append(run_id)
|
|
331
|
+
|
|
332
|
+
# Collect windows failures
|
|
333
|
+
if windows_status == "FAIL":
|
|
334
|
+
failures = analyze_windows_failures(run_id)
|
|
335
|
+
for test_name, freq in failures.items():
|
|
336
|
+
all_windows_failures[test_name] += freq
|
|
337
|
+
|
|
338
|
+
# Calculate pass rates
|
|
339
|
+
ubuntu_completed = [r for r in ubuntu_results if r != "PENDING"]
|
|
340
|
+
windows_completed = [r for r in windows_results if r not in ("PENDING", "NOT-PRESENT")]
|
|
341
|
+
|
|
342
|
+
ubuntu_pass_rate = (sum(1 for r in ubuntu_completed if r == "PASS") / len(ubuntu_completed)) if ubuntu_completed else 0.0
|
|
343
|
+
windows_pass_rate = (sum(1 for r in windows_completed if r == "PASS") / len(windows_completed)) if windows_completed else 0.0
|
|
344
|
+
|
|
345
|
+
# Top windows failures (bound output)
|
|
346
|
+
top_failures = dict(sorted(all_windows_failures.items(), key=lambda x: x[1], reverse=True)[:20])
|
|
347
|
+
|
|
348
|
+
# Output
|
|
349
|
+
if args.json:
|
|
350
|
+
output = {
|
|
351
|
+
"ubuntu_pass_rate": round(ubuntu_pass_rate, 4),
|
|
352
|
+
"windows_pass_rate": round(windows_pass_rate, 4),
|
|
353
|
+
"total_runs_analyzed": len(ubuntu_results),
|
|
354
|
+
"ubuntu_total": len(ubuntu_results),
|
|
355
|
+
"windows_total": len(windows_results),
|
|
356
|
+
"windows_not_present": sum(1 for r in windows_results if r == "NOT-PRESENT"),
|
|
357
|
+
"divergences": divergences,
|
|
358
|
+
"top_windows_failures": top_failures,
|
|
359
|
+
}
|
|
360
|
+
print(json.dumps(output, indent=2))
|
|
361
|
+
else:
|
|
362
|
+
print("Cross-OS CI Drift Report")
|
|
363
|
+
print("=" * 60)
|
|
364
|
+
print(f"Runs analyzed: {len(ubuntu_results)}")
|
|
365
|
+
print()
|
|
366
|
+
print("Pass Rates:")
|
|
367
|
+
print(f" Ubuntu: {ubuntu_pass_rate*100:6.2f}% ({sum(1 for r in ubuntu_completed if r == 'PASS')}/{len(ubuntu_completed)} runs)")
|
|
368
|
+
print(f" Windows: {windows_pass_rate*100:6.2f}% ({sum(1 for r in windows_completed if r == 'PASS')}/{len(windows_completed)} runs)")
|
|
369
|
+
print()
|
|
370
|
+
|
|
371
|
+
windows_not_present = sum(1 for r in windows_results if r == "NOT-PRESENT")
|
|
372
|
+
if windows_not_present > 0:
|
|
373
|
+
print(f"Windows job not present in {windows_not_present} runs (pre-#317)")
|
|
374
|
+
print()
|
|
375
|
+
|
|
376
|
+
if divergences:
|
|
377
|
+
print(f"Divergences (ubuntu PASS + windows FAIL): {len(divergences)}")
|
|
378
|
+
for run_id in divergences[:10]: # Show first 10
|
|
379
|
+
print(f" {run_id}")
|
|
380
|
+
if len(divergences) > 10:
|
|
381
|
+
print(f" ... and {len(divergences) - 10} more")
|
|
382
|
+
print()
|
|
383
|
+
|
|
384
|
+
if top_failures:
|
|
385
|
+
print("Top Windows Failures:")
|
|
386
|
+
for test_name, freq in sorted(top_failures.items(), key=lambda x: x[1], reverse=True)[:10]:
|
|
387
|
+
print(f" {freq:2d}x {test_name}")
|
|
388
|
+
print()
|
|
389
|
+
|
|
390
|
+
sys.exit(0)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
main()
|
package/tools/eod_sweep.py
CHANGED
|
@@ -24,6 +24,7 @@ Usage: eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
import json
|
|
27
|
+
import os
|
|
27
28
|
import subprocess
|
|
28
29
|
import sys
|
|
29
30
|
import os
|
|
@@ -48,13 +49,27 @@ class Finding:
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def get_git_status(repo_path):
|
|
51
|
-
"""Return (is_clean, dirty_files_list) for a repo.
|
|
52
|
+
"""Return (is_clean, dirty_files_list) for a repo.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
(True, []): Repo is clean
|
|
56
|
+
(False, list): Repo is dirty with file list
|
|
57
|
+
(None, error_msg): Git command failed (FAIL-CLOSED: treat as AT-RISK)
|
|
58
|
+
"""
|
|
52
59
|
try:
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
61
|
+
resolved_path = Path(repo_path).resolve()
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
['git', '-C', str(resolved_path), 'status', '--porcelain'],
|
|
55
64
|
capture_output=True, text=True, timeout=5
|
|
56
|
-
)
|
|
65
|
+
)
|
|
57
66
|
|
|
67
|
+
# FAIL-CLOSED: Check return code before processing output
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
error_msg = result.stderr.strip() if result.stderr.strip() else f"exit code {result.returncode}"
|
|
70
|
+
return (None, f"git status check failed: {error_msg}")
|
|
71
|
+
|
|
72
|
+
output = result.stdout.strip()
|
|
58
73
|
if not output:
|
|
59
74
|
return (True, [])
|
|
60
75
|
else:
|
|
@@ -65,21 +80,56 @@ def get_git_status(repo_path):
|
|
|
65
80
|
|
|
66
81
|
|
|
67
82
|
def get_ahead_count(repo_path):
|
|
68
|
-
"""Return count of commits ahead of origin/HEAD (or None on error).
|
|
83
|
+
"""Return count of commits ahead of origin/HEAD (or None on error).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
int >= 0: Number of commits ahead (0 = all pushed or no remote)
|
|
87
|
+
None: Git command failed unexpectedly (FAIL-CLOSED: treat as AT-RISK)
|
|
88
|
+
"""
|
|
69
89
|
try:
|
|
90
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
91
|
+
resolved_path = Path(repo_path).resolve()
|
|
70
92
|
# First check if there's a tracking branch
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
output =
|
|
79
|
-
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
|
|
95
|
+
capture_output=True, text=True, timeout=5
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# If upstream tracking exists, use that result
|
|
99
|
+
if result.returncode == 0:
|
|
100
|
+
output = result.stdout.strip()
|
|
101
|
+
try:
|
|
102
|
+
return int(output) if output else 0
|
|
103
|
+
except:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# If no upstream tracking, try origin/HEAD (fallback for single-branch or no-remote repos)
|
|
107
|
+
# Expected error for local-only repos: "fatal: no upstream configured"
|
|
108
|
+
stderr_lower = result.stderr.lower()
|
|
109
|
+
if 'no upstream configured' in stderr_lower:
|
|
110
|
+
# Local-only repo (no tracking branch), try origin/HEAD as fallback
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
|
|
80
113
|
capture_output=True, text=True, timeout=5
|
|
81
|
-
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check return code; if fails, determine if it's an expected "no remote" error or a real error
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
stderr_lower = result.stderr.lower()
|
|
119
|
+
# Expected errors for local-only repos (no remote configured)
|
|
120
|
+
expected_errors = [
|
|
121
|
+
'no upstream configured',
|
|
122
|
+
'ambiguous argument',
|
|
123
|
+
'unknown revision',
|
|
124
|
+
'not a valid object name',
|
|
125
|
+
'no such ref'
|
|
126
|
+
]
|
|
127
|
+
if any(err in stderr_lower for err in expected_errors):
|
|
128
|
+
return 0 # No remote/tracking, treat as "all pushed"
|
|
129
|
+
else:
|
|
130
|
+
return None # Real error, fail-closed
|
|
82
131
|
|
|
132
|
+
output = result.stdout.strip()
|
|
83
133
|
try:
|
|
84
134
|
return int(output) if output else 0
|
|
85
135
|
except:
|
|
@@ -89,13 +139,26 @@ def get_ahead_count(repo_path):
|
|
|
89
139
|
|
|
90
140
|
|
|
91
141
|
def check_untracked_files(repo_path):
|
|
92
|
-
"""Return list of untracked files not in .gitignore.
|
|
142
|
+
"""Return list of untracked files not in .gitignore.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
[]: No untracked files
|
|
146
|
+
[list]: Untracked files found
|
|
147
|
+
None: Git command failed (FAIL-CLOSED: treat as AT-RISK)
|
|
148
|
+
"""
|
|
93
149
|
try:
|
|
94
|
-
|
|
95
|
-
|
|
150
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
151
|
+
resolved_path = Path(repo_path).resolve()
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
['git', '-C', str(resolved_path), 'ls-files', '--others', '--exclude-standard'],
|
|
96
154
|
capture_output=True, text=True, timeout=5
|
|
97
|
-
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# FAIL-CLOSED: Check return code before processing output
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return None
|
|
98
160
|
|
|
161
|
+
output = result.stdout.strip()
|
|
99
162
|
if output:
|
|
100
163
|
return output.split('\n')
|
|
101
164
|
return []
|
|
@@ -104,12 +167,18 @@ def check_untracked_files(repo_path):
|
|
|
104
167
|
|
|
105
168
|
|
|
106
169
|
def check_repo(repo_path):
|
|
107
|
-
"""Check a single repo;
|
|
170
|
+
"""Check a single repo; returns a list of Finding objects.
|
|
171
|
+
|
|
172
|
+
FAIL-CLOSED (runner incident): a repo the caller ASKED to check that does
|
|
173
|
+
not exist or is not a git repo is an AT-RISK finding, never a silent skip
|
|
174
|
+
— the silent-None path made the sweep report a vacuous SAFE (exit 0) when
|
|
175
|
+
fixture/real repos failed to initialize.
|
|
176
|
+
"""
|
|
108
177
|
if not repo_path.exists():
|
|
109
|
-
return
|
|
178
|
+
return [Finding(repo_path, "repo path does not exist")]
|
|
110
179
|
|
|
111
180
|
if not (repo_path / '.git').exists():
|
|
112
|
-
return
|
|
181
|
+
return [Finding(repo_path, "not a git repository (.git missing)")]
|
|
113
182
|
|
|
114
183
|
findings = []
|
|
115
184
|
|
|
@@ -140,8 +209,10 @@ def check_repo(repo_path):
|
|
|
140
209
|
def push_repo(repo_path):
|
|
141
210
|
"""Push commits for a repo (return True if successful)."""
|
|
142
211
|
try:
|
|
212
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
213
|
+
resolved_path = Path(repo_path).resolve()
|
|
143
214
|
result = subprocess.run(
|
|
144
|
-
['git', '-C', str(
|
|
215
|
+
['git', '-C', str(resolved_path), 'push'],
|
|
145
216
|
capture_output=True, text=True, timeout=30
|
|
146
217
|
)
|
|
147
218
|
return result.returncode == 0
|
|
@@ -171,12 +242,23 @@ def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
|
|
|
171
242
|
verdict_line: The verdict line to append (e.g., "EOD-SWEEP: SAFE").
|
|
172
243
|
timestamp_str: Optional timestamp string (format: YYYY-MM-DD HH:MM).
|
|
173
244
|
If None, timestamp is omitted from the entry.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
OSError: If file operations fail (permission denied, locked file, etc.).
|
|
174
248
|
"""
|
|
175
|
-
|
|
249
|
+
try:
|
|
250
|
+
buildlog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
except OSError as e:
|
|
252
|
+
# Parent directory creation failed; re-raise with context
|
|
253
|
+
raise OSError(f"Failed to create parent directory for BUILDLOG: {e}") from e
|
|
176
254
|
|
|
177
255
|
# Create header if file doesn't exist
|
|
178
|
-
|
|
179
|
-
buildlog_path.
|
|
256
|
+
try:
|
|
257
|
+
if not buildlog_path.exists():
|
|
258
|
+
buildlog_path.write_text("# Build Log (append-only)\n")
|
|
259
|
+
except OSError as e:
|
|
260
|
+
# File creation failed; re-raise with context
|
|
261
|
+
raise OSError(f"Failed to create BUILDLOG file: {e}") from e
|
|
180
262
|
|
|
181
263
|
# Build entry line with optional timestamp
|
|
182
264
|
if timestamp_str:
|
|
@@ -184,9 +266,23 @@ def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
|
|
|
184
266
|
else:
|
|
185
267
|
entry_line = f"### {verdict_line}"
|
|
186
268
|
|
|
187
|
-
# Append to BUILDLOG
|
|
188
|
-
|
|
189
|
-
|
|
269
|
+
# Append to BUILDLOG with retry logic for Windows file locking
|
|
270
|
+
max_retries = 3
|
|
271
|
+
retry_delay = 0.1 # seconds
|
|
272
|
+
last_error = None
|
|
273
|
+
|
|
274
|
+
for attempt in range(max_retries):
|
|
275
|
+
try:
|
|
276
|
+
with open(buildlog_path, "a", encoding="utf-8") as f:
|
|
277
|
+
f.write(entry_line + "\n")
|
|
278
|
+
return
|
|
279
|
+
except OSError as e:
|
|
280
|
+
last_error = e
|
|
281
|
+
if attempt < max_retries - 1:
|
|
282
|
+
time.sleep(retry_delay)
|
|
283
|
+
|
|
284
|
+
# All retries failed
|
|
285
|
+
raise OSError(f"Failed to append to BUILDLOG after {max_retries} attempts: {last_error}") from last_error
|
|
190
286
|
|
|
191
287
|
|
|
192
288
|
def main():
|
|
@@ -195,7 +291,7 @@ def main():
|
|
|
195
291
|
parser.add_argument(
|
|
196
292
|
'--repos',
|
|
197
293
|
default='',
|
|
198
|
-
help=
|
|
294
|
+
help="os.pathsep-separated paths (; on Windows, : on POSIX)"
|
|
199
295
|
)
|
|
200
296
|
parser.add_argument(
|
|
201
297
|
'--readonly-repos',
|
|
@@ -220,14 +316,18 @@ def main():
|
|
|
220
316
|
args = parser.parse_args()
|
|
221
317
|
|
|
222
318
|
# Parse repos
|
|
319
|
+
# Split on os.pathsep (';' on Windows, ':' on POSIX): a ':' delimiter
|
|
320
|
+
# eats Windows drive letters (the runner's vacuous-SAFE incident: tmp on
|
|
321
|
+
# C:, checkout on D:, drive-relative remainder resolved to nothing and
|
|
322
|
+
# the old silent-skip reported SAFE with zero repos scanned).
|
|
223
323
|
repos_to_check = []
|
|
224
324
|
if args.repos:
|
|
225
|
-
repos_to_check = [Path(p) for p in args.repos.split(
|
|
325
|
+
repos_to_check = [Path(p) for p in args.repos.split(os.pathsep) if p]
|
|
226
326
|
|
|
227
327
|
# Parse readonly repos
|
|
228
328
|
readonly_repos = set()
|
|
229
329
|
if args.readonly_repos:
|
|
230
|
-
readonly_repos = {Path(p) for p in args.readonly_repos.split(
|
|
330
|
+
readonly_repos = {Path(p) for p in args.readonly_repos.split(os.pathsep) if p}
|
|
231
331
|
|
|
232
332
|
findings = []
|
|
233
333
|
|
|
@@ -288,7 +388,11 @@ def main():
|
|
|
288
388
|
buildlog_path = state_dir / "BUILDLOG.md"
|
|
289
389
|
|
|
290
390
|
if buildlog_path:
|
|
291
|
-
|
|
391
|
+
try:
|
|
392
|
+
append_to_buildlog(buildlog_path, verdict_line, args.timestamp)
|
|
393
|
+
except OSError as e:
|
|
394
|
+
# Log error but don't fail the verdict — exit code depends on findings, not BUILDLOG
|
|
395
|
+
print(f"WARNING: Failed to append to BUILDLOG: {e}", file=sys.stderr)
|
|
292
396
|
|
|
293
397
|
sys.exit(exit_code)
|
|
294
398
|
|