-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathvalidate.py
More file actions
444 lines (395 loc) · 26.9 KB
/
Copy pathvalidate.py
File metadata and controls
444 lines (395 loc) · 26.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# -*- coding: utf-8 -*-
"""
validate.py — دروازهٔ اعتبارسنجی خروجیها با **کلاینت واقعی**.
مسئلهٔ بنیادینی که این ماژول حل میکند
────────────────────────────────────────
یک کانفیگ نامعتبر در وسط `clash.yaml` یا `singbox.json` باعث میشود کلاینت
**کل فایل** را رد کند، نه فقط همان یک نود. یعنی یک خط خراب = صفر کانفیگ برای
کاربر. تولید فایل «تقریباً درست» بیمعنی است.
پس پیش از انتشار، همان باینریهایی که کاربر استفاده میکند خروجی را
اعتبارسنجی میکنند:
• `sing-box check -c <file>`
• `mihomo -t -f <file>`
اگر باینری در دسترس نباشد (توسعهٔ محلی)، اعتبارسنجی ساختاری انجام میشود
(JSON/YAML قابل تجزیه + وجود کلیدهای الزامی) و نتیجه «skipped» علامت
میخورد — هرگز بهدروغ «pass» گزارش نمیشود.
دو گونهٔ دسته، با دو قاعدهٔ متفاوت
──────────────────────────────────
`all/ heavy/ light/` همیشه تولید میشوند، پس نبودنشان خطاست.
`verified/ fast/ secure/` (فاز B) تنها وقتی تولید میشوند که آزمونِ واقعیِ
پروکسی در آن اجرا فعال باشد. قاعدهٔ آنها «الزامی بهشرطِ حضور» است: اگر
دایرکتوری نبود، از بررسی رد میشود؛ ولی اگر بود و فایلش خراب یا ناقص بود،
**شکست** است — نه «skipped» و نه نادیدهگرفتن.
چرا این تفکیک لازم بود؟ `report["ok"]` شرطِ `missing == 0` دارد. افزودنِ
سادهٔ سه دستهٔ تازه به همان تاپل، دروازه را بیدرنگ میشکست: سنجیده شد که
پیش از این تغییر ۶ بررسی و rc=0 بود، و با افزودنِ ساده ۶ موردِ `missing`
و rc=1 میشد — یعنی انتشار میایستاد پیش از آنکه اصلاً کدِ تولیدکنندهٔ
آن دستهها نوشته شود.
اجرا بهصورت مستقل:
python scripts/validate.py --out . # اعتبارسنجی خروجیهای موجود
python scripts/validate.py --out . --strict # کد خروج ≠۰ در صورت شکست
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from typing import Any, Dict, List, Optional, Tuple
#: دستههایی که خطلوله **همیشه** تولید میکند. نبودنشان خطاست.
CORE_CATEGORIES = ("all", "heavy", "light")
#: دستههای فاز B (آبشارِ اعتبارسنجی). اینها تنها وقتی تولید میشوند که
#: لایهٔ L3 در آن اجرا فعال باشد، و «فعالبودن» به محیطِ اجرا بسته است.
#:
#: چرا جدا؟ چون `report["ok"]` شرطِ `missing == 0` دارد. اگر این سه به
#: `CORE_CATEGORIES` اضافه میشدند، همان لحظه دروازه با `--strict` کدِ ۱
#: میداد و انتشار را میبست — پیش از آنکه اصلاً کدِ تولیدکنندهشان نوشته
#: شود. سنجیده شد: پیش از این تغییر ۶ بررسی و rc=0؛ با افزودنِ ساده به
#: همان تاپل، ۶ موردِ `missing` و rc=1.
#:
#: قاعدهٔ درست «الزامی بهشرطِ حضور» است: اگر دایرکتوری نباشد، رد میشود؛
#: ولی اگر باشد و فایلش خراب یا ناقص باشد، **شکست** است. یعنی حذفِ خودکارِ
#: یک دسته هرگز بهشکلِ «موفق» ظاهر نمیشود.
OPTIONAL_CATEGORIES = ("verified", "fast", "secure")
#: سازگاریِ عقبرو: هر مصرفکنندهٔ بیرونیِ `CATEGORIES` باید کار کند.
CATEGORIES = CORE_CATEGORIES + OPTIONAL_CATEGORIES
# نرمشدنِ قاعده هرگز نباید به دستههای اصلی سرایت کند. اگر روزی کسی نامی را
# جابهجا کند، `all/` میتوانست بیصدا «تولیدنشده» به حساب بیاید و دروازه با
# صفر کانفیگ سبز بماند — بدترین شکستِ خاموشِ ممکن برای این پروژه. پس اینجا
# در زمانِ import میشکند، نه در زمانِ انتشار.
assert not (set(CORE_CATEGORIES) & set(OPTIONAL_CATEGORIES)), \
"a core category must never be optional"
assert len(set(CATEGORIES)) == len(CATEGORIES), "duplicate category name"
#: زمان بیشینهٔ اجرای هر باینری اعتبارسنج (ثانیه).
CHECK_TIMEOUT = 180
#: ★ فهرستِ سفیدِ دروازه (F-5). تنها وضعیتهایی که اجازهٔ عبور دارند؛ هر
#: وضعیتِ دیگر — از جمله هر وضعیتِ **ناشناختهٔ** آینده — دروازه را میبندد.
#:
#: ⚠️ چرا این نام در **سطحِ ماژول** است و نه داخلِ `validate_outputs`:
#: تا ۲۰۲۶-۰۸-۰۴ این تاپل یک متغیرِ محلی بود. با اجرا (نه با خواندنِ کد)
#: اندازهگیری شد که آزمونِ ناوردا
#: (`test_zzz_f5_the_fix_changes_no_verdict_for_real_statuses`) با
#: `hasattr(validate, "ACCEPTABLE_STATUSES")` سراغش میآمد و چون نامِ
#: محلی از بیرون دیده نمیشود، آن شرط **همیشه** False بود و آزمون به
#: نسخهٔ رونویسیشدهٔ `{"pass", "skipped"}` داخلِ خودش تکیه میکرد.
#: نتیجهٔ سنجیدهشدهٔ آن جداافتادگی: با گشادکردنِ همین تاپل به
#: `("pass", "skipped", "fail", "missing")` — یعنی دروازهٔ کاملاً
#: fail-open — هر ۵ آزمونِ F-5 **سبز ماندند**. پس فهرستِ سفید باید تنها
#: یک منبعِ حقیقت داشته باشد و آزمون همان را بخواند، نه رونوشتش را.
ACCEPTABLE_STATUSES = ("pass", "skipped")
#: sing-box/mihomo پیامها را رنگی چاپ میکنند؛ در لاگ CI به بایتهای زائد
#: تبدیل میشود و مقایسهٔ رشتهای را میشکند، پس پاک میشود.
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def _clean(text: str) -> str:
return _ANSI_RE.sub("", text)
def _find_binary(*names: str) -> Optional[str]:
"""مسیر باینری در PATH یا محلهای متعارف نصب در CI."""
for n in names:
p = shutil.which(n)
if p:
return p
for n in names:
for cand in (f"/usr/local/bin/{n}", f"/usr/bin/{n}", f"./{n}"):
if os.path.isfile(cand) and os.access(cand, os.X_OK):
return cand
return None
def _run(cmd: List[str]) -> Tuple[int, str]:
try:
pr = subprocess.run(cmd, capture_output=True, text=True, timeout=CHECK_TIMEOUT)
return pr.returncode, _clean((pr.stdout or "") + (pr.stderr or "")).strip()
except subprocess.TimeoutExpired:
return 124, f"timeout after {CHECK_TIMEOUT}s"
except Exception as e: # noqa: BLE001
return 125, f"{type(e).__name__}: {e}"
# ──────────────────────────────────────────────────────────────────────────────
# اعتبارسنجی ساختاری (وقتی باینری نیست)
# ──────────────────────────────────────────────────────────────────────────────
def _total_check(fn):
"""یک بررسیِ ساختاری را به تابعی **کل** بدل میکند: هرگز استثنا نمیدهد.
── چرا این لایه لازم است (F-4) ───────────────────────────────────────────
دو بررسیِ زیر شکلِ سندِ خواندهشده را «مفروض» میگرفتند. با اجرا (نه با
خواندنِ کد) روی سندهای بدشکل، **۲۳** شکلِ متمایز پیدا شد که استثنا
میدادند — گزارشِ اولیه فقط ۱۲ موردِ «سطحِ بالا dict نیست» را دیده بود.
نمونههای تازهکشفشده:
route یک رشته باشد → AttributeError ('str' has no 'get')
tag یک لیست باشد → TypeError: unhashable type: 'list'
selector.outbounds عدد → TypeError: 'int' object is not iterable
proxy-groups رشته/dict → AttributeError
group.proxies عدد → TypeError
و این استثناها گزارش نمیشدند، بلکه **میکشتند**: هر دو فراخوانی
(`check_singbox:170` و `check_clash:183`) هیچ `try` ندارند، پس با اجرا
ثابت شد که `check_singbox(path, None)` خودش استثنا بیرون میدهد و
`validate.py` میمیرد — یعنی بهجای «یک فایلِ خراب = یک `fail`» کلِ
دروازهٔ اعتبارسنجی از کار میافتاد.
── چرا یک لفافِ کل، و نه فقط شمردنِ شکلها ────────────────────────────────
گاردهای دقیق (پایینتر) پیامِ خوانا میدهند و باید باشند. ولی شمارشِ
دستیِ شکلها شمارشی است که هر بار یک شکلِ نو جا میماند — همانطور که
گزارشِ اولیه ۱۱ شکل را جا انداخت. پس این لفاف پشتوانهٔ نهایی است:
• این توابع **اعتبارسنج** اند؛ کارشان همین است که بگویند «این فایل
قابلقبول است یا نه». هر شکلِ غیرمنتظره، به تعریف، «قابلقبول نیست».
پس تبدیلِ استثنا به `(False, …)` نه پنهانکاری است، نه نرمکردنِ
دروازه — دقیقاً همان معناست.
• fail-closed میماند: `check_*` مقدارِ `False` را به وضعیتِ `"fail"`
نگاشت میکند، `fail` در `summary` شمرده میشود و `report["ok"]` را
`False` میکند (سطرِ ~۲۶۲)، پس با `--strict` انتشار میایستد.
یعنی خروجیِ خراب هرگز بیصدا منتشر نمیشود.
• نامِ استثنا در پیام میآید تا عیبیابی کور نشود.
"""
def wrapper(path: str) -> Tuple[bool, str]:
try:
return fn(path)
except Exception as e: # noqa: BLE001
# یک شکلِ پیشبینینشده. «نمیدانم» در یک اعتبارسنج یعنی «رد».
return False, (f"unexpected document shape "
f"({type(e).__name__}: {str(e)[:120]})")
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
wrapper.__wrapped__ = fn
return wrapper
@_total_check
def _structural_singbox(path: str) -> Tuple[bool, str]:
try:
with open(path, encoding="utf-8") as f:
doc = json.load(f)
except Exception as e: # noqa: BLE001
return False, f"JSON parse error: {e}"
# JSONِ معتبر لازم نیست یک شیء باشد: `[]`، `"x"`، `42`، `null`، `true`
# همه سندِ معتبرند و همه پیش از این `AttributeError` میدادند.
if not isinstance(doc, dict):
return False, (f"top-level JSON must be an object, "
f"got {type(doc).__name__}")
if not isinstance(doc.get("outbounds"), list) or not doc["outbounds"]:
return False, "missing/empty outbounds"
tags = set()
for o in doc["outbounds"]:
if not isinstance(o, dict):
return False, "non-object outbound"
tag = o.get("tag")
# `tag` باید هششدنی باشد وگرنه ساختنِ همین مجموعه میشکست
# (`TypeError: unhashable type: 'list'`).
if isinstance(tag, (dict, list, set)):
return False, f"outbound tag must be a scalar, got {type(tag).__name__}"
tags.add(tag)
# هر ارجاعی در selector/urltest باید به یک tag موجود اشاره کند،
# وگرنه sing-box با «outbound not found» کل فایل را رد میکند.
for o in doc["outbounds"]:
if o.get("type") in ("selector", "urltest"):
refs = o.get("outbounds", [])
# فقط لیست پیمایش میشود: عدد `TypeError` میداد، و رشته/dict
# کاراکتربهکاراکتر یا کلیدبهکلید پیمایش میشد و پیامِ
# گمراهکنندهٔ «dangling reference: 'a'» میساخت.
if not isinstance(refs, list):
return False, (f"{o.get('tag')!r}: selector/urltest outbounds "
f"must be a list, got {type(refs).__name__}")
for ref in refs:
if ref not in tags:
return False, f"dangling reference: {ref!r}"
route = doc.get("route")
# `route` تُنُک ولی غیرشیء (`"oops"`, `[…]`, `7`) → پیش از این
# `(doc.get("route") or {}).get(...)` استثنا میداد.
if route is not None and not isinstance(route, dict):
return False, f"route must be an object, got {type(route).__name__}"
final = (route or {}).get("final")
if final and final not in tags:
return False, f"route.final points to unknown tag: {final!r}"
return True, f"structural ok ({len(doc['outbounds'])} outbounds)"
@_total_check
def _structural_clash(path: str) -> Tuple[bool, str]:
try:
import yaml
with open(path, encoding="utf-8") as f:
doc = yaml.safe_load(f)
except Exception as e: # noqa: BLE001
return False, f"YAML parse error: {e}"
# `safe_load` روی فایلِ خالی یا فقط-توضیح `None` میدهد، و روی سندِ
# لیستی/اسکالر یک non-dict. همه پیش از این `AttributeError` میدادند.
if not isinstance(doc, dict):
got = "empty document" if doc is None else type(doc).__name__
return False, f"top-level YAML must be a mapping, got {got}"
proxies = doc.get("proxies")
if not isinstance(proxies, list) or not proxies:
return False, "missing/empty proxies"
names = set()
for p in proxies:
# پیش از این، ورودیِ غیر-dict فقط از `names` میافتاد و بعد با
# پیامِ گمراهکنندهٔ «duplicate proxy names» گزارش میشد. آینهٔ
# همان قاعدهٔ «non-object outbound» در sing-box است.
if not isinstance(p, dict):
return False, "non-object proxy entry"
nm = p.get("name")
if isinstance(nm, (dict, list, set)):
return False, f"proxy name must be a scalar, got {type(nm).__name__}"
names.add(nm)
if len(names) != len(proxies):
return False, "duplicate proxy names (mihomo rejects the file)"
groups = doc.get("proxy-groups") or []
if not isinstance(groups, list):
return False, f"proxy-groups must be a list, got {type(groups).__name__}"
group_names = {g.get("name") for g in groups if isinstance(g, dict)}
for g in groups:
if not isinstance(g, dict):
return False, "non-object proxy-group entry"
refs = g.get("proxies", [])
if not isinstance(refs, list):
return False, (f"group {g.get('name')!r}: proxies must be a list, "
f"got {type(refs).__name__}")
for ref in refs:
if ref not in names and ref not in group_names:
return False, f"group {g.get('name')!r} references unknown proxy {ref!r}"
return True, f"structural ok ({len(proxies)} proxies)"
# ──────────────────────────────────────────────────────────────────────────────
# اعتبارسنجی با کلاینت واقعی
# ──────────────────────────────────────────────────────────────────────────────
def check_singbox(path: str, binary: Optional[str]) -> Dict[str, Any]:
if not os.path.isfile(path):
return {"status": "missing", "detail": "file not found"}
if not binary:
ok, detail = _structural_singbox(path)
return {"status": "skipped" if ok else "fail", "detail": detail,
"note": "sing-box binary unavailable; structural check only"}
code, out = _run([binary, "check", "-c", path])
if code == 0:
return {"status": "pass", "detail": "sing-box check OK"}
return {"status": "fail", "detail": out.splitlines()[0][:300] if out else f"exit {code}"}
def check_clash(path: str, binary: Optional[str]) -> Dict[str, Any]:
if not os.path.isfile(path):
return {"status": "missing", "detail": "file not found"}
if not binary:
ok, detail = _structural_clash(path)
return {"status": "skipped" if ok else "fail", "detail": detail,
"note": "mihomo binary unavailable; structural check only"}
# mihomo برای -t به یک دایرکتوری کاری قابلنوشتن نیاز دارد.
with tempfile.TemporaryDirectory() as d:
code, out = _run([binary, "-t", "-d", d, "-f", path])
bad = [ln for ln in out.splitlines() if "level=error" in ln or "level=fatal" in ln]
if code == 0 and not bad:
return {"status": "pass", "detail": "mihomo -t OK"}
detail = (bad[0] if bad else out.splitlines()[0] if out else f"exit {code}")
return {"status": "fail", "detail": detail[:300]}
def validate_outputs(out_dir: str) -> Dict[str, Any]:
"""همهٔ فایلهای Clash/Sing-box را اعتبارسنجی میکند و گزارش برمیگرداند."""
sb = _find_binary("sing-box")
mh = _find_binary("mihomo", "clash-meta", "clash")
report: Dict[str, Any] = {
"tools": {
"sing_box": sb or None,
"mihomo": mh or None,
},
"results": {},
"summary": {"pass": 0, "fail": 0, "skipped": 0, "missing": 0},
}
absent: List[str] = []
for cat in CATEGORIES:
cat_dir = os.path.join(out_dir, cat)
# دستهٔ اختیاری که کلاً وجود ندارد: هنوز تولید نمیشود، پس بررسیای
# هم ندارد. ولی اگر دایرکتوری *باشد*، دقیقاً مثل دستههای اصلی
# سنجیده میشود — نه نرمتر.
if cat in OPTIONAL_CATEGORIES and not os.path.isdir(cat_dir):
absent.append(cat)
continue
report["results"][cat] = {
"singbox": check_singbox(os.path.join(cat_dir, "singbox.json"), sb),
"clash": check_clash(os.path.join(cat_dir, "clash.yaml"), mh),
}
report["absent_optional"] = absent
# ناوردا: دستهٔ اصلی هرگز از بررسی رد نمیشود، حتی اگر دایرکتوریاش نباشد
# (در آن حالت فایلها `missing` میشوند و دروازه همانجا میشکند).
assert all(c in report["results"] for c in CORE_CATEGORIES), \
"every core category must be checked"
for cat_res in report["results"].values():
for res in cat_res.values():
report["summary"][res["status"]] = report["summary"].get(res["status"], 0) + 1
# ── دروازه: فهرستِ سفید، نه فهرستِ سیاه (F-5) ─────────────────────────────
#
# شکلِ پیشین `fail == 0 and missing == 0` بود؛ یعنی یک **فهرستِ سیاه**.
# با اجرا (نه با خواندنِ کد) ثابت شد که این fail-open است: اگر روزی یک
# `check_*` وضعیتی تازه برگرداند — مثلاً `"error"` یا `"timeout"` — آن
# وضعیت در `summary` شمرده میشود ولی در هیچیک از دو شرط نمیآید، پس:
#
# summary = {'pass': 0, 'fail': 0, 'skipped': 3, 'missing': 0, 'error': 3}
# ok = True ← دروازه سبز، با ۳ موردِ خطا!
#
# این «باگِ خاموش» است: امروز بیاثر است (هر چهار وضعیتِ تولیدشدنی
# پوشش داده شدهاند) ولی افزودنِ یک وضعیتِ تازه در آینده، دروازه را
# بیصدا باز میکند. `_run` هم دو کدِ اختصاصیِ ۱۲۴/۱۲۵ دارد که وسوسهٔ
# افزودنِ وضعیتِ `"timeout"` را واقعی میکند.
#
# درمان: فقط وضعیتهایی که **صریحاً** قابلقبول شمرده شدهاند اجازهٔ
# عبور دارند؛ هر چیزِ دیگر، از جمله وضعیتِ ناشناخته، دروازه را میبندد.
#
# ثابت شد که این تغییر **صفر رگرسیون** دارد: روی هر ۸۱ ترکیبِ شمارشِ
# چهار وضعیتِ واقعی (`pass`/`fail`/`skipped`/`missing`، هر یک ۰..۲)،
# فهرستِ سفید و شکلِ پیشین **در همهٔ موارد همنظرند** (صفر اختلاف).
# تفاوت تنها آنجا ظاهر میشود که وضعیتی ناشناخته وجود داشته باشد.
#
# چرا `skipped` قابلقبول است و «نرمکردن» نیست: طبقِ سندِ بالای همین
# فایل، در توسعهٔ محلی که باینریِ کلاینت نیست، اعتبارسنجیِ ساختاری
# جایگزین میشود و نتیجه `skipped` علامت میخورد — و همان بررسیِ
# ساختاری هرچه خراب باشد `fail` میدهد نه `skipped`. در CI هم گامِ
# نصبِ باینریها fail-closed است (`set -euo pipefail` + چهار checksum و
# بدونِ `continue-on-error`)، پس رسیدن به `skipped` در CI یعنی آن گام
# پیشتر کلِ job را شکسته است.
# فهرستِ سفید در سطحِ ماژول تعریف شده (بالای فایل) تا آزمونِ ناوردا
# بتواند **همان** منبعِ حقیقت را بخواند، نه رونوشتی از آن.
offending = {status: count
for status, count in report["summary"].items()
if count > 0 and status not in ACCEPTABLE_STATUSES}
report["offending"] = offending
report["ok"] = not offending
# ── شفافیت: «چیزی اثبات نشد» باید دیده شود ────────────────────────────────
#
# سنجیده شد که با نبودِ هر دو باینری، خروجی `pass=0 skipped=6` و
# `ok=True` و `rc=0` است. این طبقِ طراحی است (بالا) و **نباید** به
# شکست تبدیل شود — سه تستِ موجود (`…optional_category_absence…`،
# `…pipeline output gate…`، `…f7…`) درست به همین رفتار تکیه دارند و
# سختکردنِ دروازه، رگرسیون میساخت نه بهبود.
#
# ولی «طبقِ طراحی» بهمعنای «نامرئی» نیست: تا امروز هیچجا دیده نمیشد
# که این اجرا با کلاینتِ واقعی سنجیده شده یا فقط ساختاری. این پرچمِ
# صریح، تصمیم را از حدسزدن بیرون میآورد و در `validation.json` هم
# ثبت میشود.
report["real_validation"] = report["summary"]["pass"] > 0
return report
def main() -> int:
ap = argparse.ArgumentParser(description="Validate generated client configs")
ap.add_argument("--out", default=os.getcwd(), help="repo root containing all/ heavy/ light/")
ap.add_argument("--strict", action="store_true",
help="exit non-zero when any file fails validation")
ap.add_argument("--json", dest="json_path", default="",
help="also write the report to this path")
args = ap.parse_args()
rep = validate_outputs(os.path.abspath(args.out))
print("🔍 Client validation")
print(f" sing-box: {rep['tools']['sing_box'] or 'NOT FOUND (structural fallback)'}")
print(f" mihomo : {rep['tools']['mihomo'] or 'NOT FOUND (structural fallback)'}")
icons = {"pass": "✅", "fail": "❌", "skipped": "⚠️", "missing": "🚫"}
for cat, res in rep["results"].items():
for kind, r in res.items():
print(f" {icons.get(r['status'], '?')} {cat:<5} {kind:<8} "
f"{r['status']:<8} {r['detail']}")
for cat in rep.get("absent_optional", []):
print(f" ➖ {cat:<5} {'—':<8} not produced in this run")
s = rep["summary"]
print(f" → pass={s['pass']} fail={s['fail']} "
f"skipped={s['skipped']} missing={s['missing']}")
# هر وضعیتِ ناشناختهای که دروازه را بست، باید نامش چاپ شود وگرنه
# عیبیابی کور میشود («چرا قرمز شد؟» بیپاسخ میماند).
extra = {k: v for k, v in s.items()
if v > 0 and k not in ("pass", "fail", "skipped", "missing")}
if extra:
print(f" ⚠️ unrecognised statuses (gate closed): {extra}")
if not rep.get("real_validation", False):
print(" ⚠️ structural fallback only — no real client validated this run")
if args.json_path:
os.makedirs(os.path.dirname(os.path.abspath(args.json_path)) or ".", exist_ok=True)
with open(args.json_path, "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
if args.strict and not rep["ok"]:
print("❌ Validation gate FAILED — outputs must not be published.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())