stack-guardian 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,491 @@
1
+ """Core types for "stack-guardian", a doctor that health-checks an AI stack snapshot.
2
+
3
+ Pure and standalone: standard library only, no network, no persistent files.
4
+
5
+ Public contract:
6
+ SEVERITIES / LEVELS / CONFIDENCES
7
+ make_finding(code, severity, level, node, summary, evidence=None, try_=None,
8
+ confidence="LIKELY", fix=None) -> dict
9
+ run_check(check_id, fn, snapshot, ctx, node="") -> dict or None
10
+ worst(findings) -> str
11
+ exit_code(findings) -> int
12
+
13
+ CLI:
14
+ python sg_finding.py --worst PATH # last stdout line is worst(...); rc is exit_code(...)
15
+ python sg_finding.py --selftest # counted checks; rc 0 only if all pass
16
+ """
17
+
18
+ import argparse
19
+ import contextlib
20
+ import io
21
+ import json
22
+ import os
23
+ import shutil
24
+ import sys
25
+ import tempfile
26
+
27
+ SEVERITIES = ("ok", "partial", "missing", "crash", "n/a")
28
+ LEVELS = ("OK", "INFO", "WARN", "FAIL") # ascending badness
29
+ CONFIDENCES = ("VERIFIED", "LIKELY", "INSUFFICIENT")
30
+
31
+ # Severities whose level is forced, regardless of the caller-supplied level.
32
+ _FORCED_LEVELS = {"ok": "OK", "n/a": "INFO", "crash": "WARN"}
33
+
34
+ SUMMARY_MAX = 120
35
+ CRASH_EVIDENCE_MAX = 300
36
+
37
+ FINDING_KEYS = (
38
+ "code",
39
+ "severity",
40
+ "level",
41
+ "node",
42
+ "summary",
43
+ "evidence",
44
+ "try",
45
+ "confidence",
46
+ "fix",
47
+ )
48
+
49
+
50
+ def _valid_fix(fix):
51
+ """True iff fix is a dict shaped {"action": str, "args": dict}."""
52
+ if not isinstance(fix, dict):
53
+ return False
54
+ if "action" not in fix or "args" not in fix:
55
+ return False
56
+ return isinstance(fix["action"], str) and isinstance(fix["args"], dict)
57
+
58
+
59
+ def make_finding(
60
+ code,
61
+ severity,
62
+ level,
63
+ node,
64
+ summary,
65
+ evidence=None,
66
+ try_=None,
67
+ confidence="LIKELY",
68
+ fix=None,
69
+ ):
70
+ """Build a finding dict with exactly the FINDING_KEYS keys.
71
+
72
+ Validation of severity/level/confidence happens before any level override,
73
+ so a bad enum always raises rather than being masked by the override.
74
+ """
75
+ if severity not in SEVERITIES:
76
+ raise ValueError("unknown severity: %r" % (severity,))
77
+ if level not in LEVELS:
78
+ raise ValueError("unknown level: %r" % (level,))
79
+ if confidence not in CONFIDENCES:
80
+ raise ValueError("unknown confidence: %r" % (confidence,))
81
+ if fix is not None and not _valid_fix(fix):
82
+ raise ValueError("fix must be None or {'action': str, 'args': dict}")
83
+
84
+ effective = _FORCED_LEVELS.get(severity, level)
85
+ return {
86
+ "code": str(code),
87
+ "severity": severity,
88
+ "level": effective,
89
+ "node": str(node),
90
+ "summary": str(summary)[:SUMMARY_MAX],
91
+ "evidence": [str(x) for x in (evidence or [])],
92
+ "try": [str(x) for x in (try_ or [])],
93
+ "confidence": confidence,
94
+ "fix": None if fix is None else dict(fix),
95
+ }
96
+
97
+
98
+ def run_check(check_id, fn, snapshot, ctx, node=""):
99
+ """Run fn(snapshot, ctx) (a finding, a list of findings, or None); convert any Exception into a crash finding.
100
+
101
+ A buggy check never takes down the run and never reports FAIL: the crash
102
+ finding is severity "crash", which forces level "WARN".
103
+ """
104
+ try:
105
+ return fn(snapshot, ctx)
106
+ except Exception as exc: # noqa: BLE001 - a broken check must not abort the run
107
+ # Coerce eagerly: reporting the crash must not raise a second time, or a
108
+ # non-str check_id would take down the run the handler exists to protect.
109
+ label = "%s" % (check_id,)
110
+ return make_finding(
111
+ "check.crashed",
112
+ "crash",
113
+ "WARN",
114
+ node,
115
+ "check %s raised %s" % (label, type(exc).__name__),
116
+ evidence=[("%s: %s" % (type(exc).__name__, exc))[:CRASH_EVIDENCE_MAX]],
117
+ try_=["fix or disable the check " + label],
118
+ confidence="VERIFIED",
119
+ )
120
+
121
+
122
+ def worst(findings):
123
+ """Highest LEVELS entry among the findings' "level" values; "OK" if none."""
124
+ best = "OK"
125
+ rank = LEVELS.index(best)
126
+ for finding in findings or ():
127
+ level = finding.get("level") if isinstance(finding, dict) else None
128
+ if level in LEVELS and LEVELS.index(level) > rank:
129
+ best = level
130
+ rank = LEVELS.index(level)
131
+ return best
132
+
133
+
134
+ def exit_code(findings):
135
+ """2 for FAIL, 1 for WARN, else 0."""
136
+ top = worst(findings)
137
+ if top == "FAIL":
138
+ return 2
139
+ if top == "WARN":
140
+ return 1
141
+ return 0
142
+
143
+
144
+ def load_findings(path):
145
+ """Read a JSON file holding a list of findings (None entries allowed)."""
146
+ with open(path, "r", encoding="utf-8") as handle:
147
+ data = json.load(handle)
148
+ if not isinstance(data, list):
149
+ raise ValueError("%s must hold a JSON list of findings" % (path,))
150
+ return data
151
+
152
+
153
+ def main(argv=None):
154
+ """CLI entry point; returns the process exit code."""
155
+ parser = argparse.ArgumentParser(
156
+ prog="sg_finding", description="Finding helpers for stack-guardian."
157
+ )
158
+ parser.add_argument("--worst", metavar="PATH", help="JSON file holding findings")
159
+ parser.add_argument(
160
+ "--selftest", action="store_true", help="run the in-file selftest"
161
+ )
162
+ args = parser.parse_args(argv)
163
+
164
+ if args.selftest:
165
+ return selftest()
166
+ if args.worst:
167
+ try:
168
+ findings = load_findings(args.worst)
169
+ except (OSError, ValueError) as exc:
170
+ sys.stderr.write("sg_finding: cannot read findings: %s\n" % (exc,))
171
+ return 2
172
+ top = worst(findings)
173
+ sys.stdout.write(top + "\n")
174
+ return exit_code(findings)
175
+ parser.error("one of --worst PATH or --selftest is required")
176
+ return 1 # unreachable; parser.error raises SystemExit
177
+
178
+
179
+ # --------------------------------------------------------------------------
180
+ # selftest
181
+ # --------------------------------------------------------------------------
182
+
183
+ def _expect_value_error(fn, *args, **kwargs):
184
+ try:
185
+ fn(*args, **kwargs)
186
+ except ValueError:
187
+ return True
188
+ except Exception: # a different exception type is still a contract failure
189
+ return False
190
+ return False
191
+
192
+
193
+ def _raising_check(_snapshot, _ctx):
194
+ raise ValueError("boom")
195
+
196
+
197
+ def _raising_long_check(_snapshot, _ctx):
198
+ raise RuntimeError("x" * 500)
199
+
200
+
201
+ def check_constants():
202
+ return (
203
+ SEVERITIES == ("ok", "partial", "missing", "crash", "n/a")
204
+ and LEVELS == ("OK", "INFO", "WARN", "FAIL")
205
+ and CONFIDENCES == ("VERIFIED", "LIKELY", "INSUFFICIENT")
206
+ and LEVELS.index("OK") < LEVELS.index("INFO") < LEVELS.index("WARN") < LEVELS.index("FAIL")
207
+ )
208
+
209
+
210
+ def check_finding_keys():
211
+ finding = make_finding("c", "partial", "WARN", "n1", "s")
212
+ keys = set(finding)
213
+ return (
214
+ len(keys) == len(FINDING_KEYS)
215
+ and keys == set(FINDING_KEYS)
216
+ and tuple(finding) == FINDING_KEYS
217
+ and isinstance(finding["evidence"], list)
218
+ and isinstance(finding["try"], list)
219
+ and finding["fix"] is None
220
+ and finding["confidence"] == "LIKELY"
221
+ )
222
+
223
+
224
+ def check_level_override():
225
+ pairs = (
226
+ ("ok", "FAIL", "OK"),
227
+ ("n/a", "FAIL", "INFO"),
228
+ ("crash", "FAIL", "WARN"),
229
+ ("partial", "WARN", "WARN"),
230
+ ("partial", "FAIL", "FAIL"),
231
+ ("missing", "FAIL", "FAIL"),
232
+ ("missing", "INFO", "INFO"),
233
+ )
234
+ for severity, given, expected in pairs:
235
+ got = make_finding("c", severity, given, "n", "s")["level"]
236
+ if got != expected:
237
+ return False
238
+ return True
239
+
240
+
241
+ def check_text_coercion():
242
+ long_summary = "y" * 400
243
+ finding = make_finding(
244
+ "c",
245
+ "missing",
246
+ "WARN",
247
+ "node-1",
248
+ long_summary,
249
+ evidence=[1, None, "e"],
250
+ try_=[2.5, "z"],
251
+ )
252
+ coerced = make_finding("c", "ok", "OK", 7, 12345)
253
+ return (
254
+ len(finding["summary"]) == SUMMARY_MAX
255
+ and finding["summary"] == "y" * SUMMARY_MAX
256
+ and finding["summary"] == str(long_summary)[:SUMMARY_MAX]
257
+ and finding["evidence"] == ["1", "None", "e"]
258
+ and finding["try"] == ["2.5", "z"]
259
+ and coerced["summary"] == "12345"
260
+ and coerced["node"] == "7"
261
+ and coerced["code"] == "c"
262
+ and make_finding(99, "ok", "OK", "n", None)["code"] == "99"
263
+ and make_finding("c", "ok", "OK", "n", None)["summary"] == "None"
264
+ )
265
+
266
+
267
+ def check_bad_enum_raises():
268
+ return (
269
+ _expect_value_error(make_finding, "c", "nope", "WARN", "n", "s")
270
+ and _expect_value_error(make_finding, "c", "ok", "nope", "n", "s")
271
+ and _expect_value_error(
272
+ make_finding, "c", "ok", "OK", "n", "s", confidence="MAYBE"
273
+ )
274
+ and _expect_value_error(make_finding, "c", "warn", "OK", "n", "s")
275
+ and _expect_value_error(make_finding, "c", "OK", "OK", "n", "s")
276
+ )
277
+
278
+
279
+ def check_enum_checked_before_override():
280
+ # A bad level must raise even when severity would have overridden it.
281
+ ok = _expect_value_error(make_finding, "c", "ok", "NOPE", "n", "s")
282
+ bad_sev = _expect_value_error(make_finding, "c", "NOPE", "OK", "n", "s")
283
+ return ok and bad_sev and SEVERITIES[0] == "ok"
284
+
285
+
286
+ def check_fix_validation():
287
+ supplied = {"action": "install", "args": {"pkg": "x"}}
288
+ good = make_finding("c", "partial", "WARN", "n", "s", fix=supplied)
289
+ bad_values = (
290
+ "install",
291
+ ["install", {}],
292
+ {"action": "install"},
293
+ {"args": {}},
294
+ {"action": 1, "args": {}},
295
+ {"action": "install", "args": []},
296
+ {},
297
+ )
298
+ return (
299
+ good["fix"] == {"action": "install", "args": {"pkg": "x"}}
300
+ and good["fix"] is not supplied
301
+ and all(_expect_value_error(make_finding, "c", "ok", "OK", "n", "s", fix=b)
302
+ for b in bad_values)
303
+ and make_finding("c", "ok", "OK", "n", "s", fix=None)["fix"] is None
304
+ )
305
+
306
+
307
+ def check_run_check_passthrough():
308
+ snapshot = {"nodes": 1}
309
+ ctx = {"deep": True}
310
+ seen = []
311
+
312
+ def ok_check(snap, c):
313
+ seen.append((snap, c))
314
+ return make_finding("ok", "ok", "OK", "", "fine")
315
+
316
+ def none_check(snap, c):
317
+ seen.append((snap, c))
318
+ return None
319
+
320
+ finding = run_check("ok", ok_check, snapshot, ctx)
321
+ passed_none = run_check("none", none_check, snapshot, ctx)
322
+ return (
323
+ isinstance(finding, dict)
324
+ and finding["code"] == "ok"
325
+ and finding["node"] == ""
326
+ and passed_none is None
327
+ and len(seen) == 2
328
+ and all(s is snapshot and c is ctx for s, c in seen)
329
+ )
330
+
331
+
332
+ def check_run_check_crash():
333
+ finding = run_check("c1", _raising_check, {}, {}, node="nodeA")
334
+ evidence = "%s: %s" % ("ValueError", "boom")
335
+ return (
336
+ finding["code"] == "check.crashed"
337
+ and finding["severity"] == "crash"
338
+ and finding["level"] == "WARN"
339
+ and finding["node"] == "nodeA"
340
+ and finding["summary"] == "check c1 raised ValueError"
341
+ and finding["evidence"] == [evidence]
342
+ and finding["try"] == ["fix or disable the check c1"]
343
+ and finding["confidence"] == "VERIFIED"
344
+ and finding["fix"] is None
345
+ )
346
+
347
+
348
+ def check_crash_never_fail_and_truncates():
349
+ finding = run_check("c2", _raising_long_check, {}, {})
350
+ msg = "%s: %s" % ("RuntimeError", "x" * 500)
351
+ return (
352
+ finding["level"] == "WARN"
353
+ and finding["level"] != "FAIL"
354
+ and exit_code([finding]) == 1
355
+ and len(finding["evidence"]) == 1
356
+ and len(finding["evidence"][0]) == CRASH_EVIDENCE_MAX
357
+ and finding["evidence"][0] == msg[:CRASH_EVIDENCE_MAX]
358
+ and finding["node"] == ""
359
+ )
360
+
361
+
362
+ def check_run_check_survives_odd_ids():
363
+ # Reporting the crash must never raise, whatever the id/node look like.
364
+ findings = [
365
+ run_check(7, _raising_check, {}, {}),
366
+ run_check(None, _raising_check, {}, {}, node=None),
367
+ run_check(("a", 1), _raising_check, {}, {}, node=("n", 2)),
368
+ run_check("x", _raising_check, {}, {}, node=""),
369
+ ]
370
+ return (
371
+ len(findings) == 4
372
+ and all(isinstance(f, dict) for f in findings)
373
+ and all(f["code"] == "check.crashed" for f in findings)
374
+ and all(f["level"] == "WARN" for f in findings)
375
+ and findings[0]["try"] == ["fix or disable the check 7"]
376
+ and findings[0]["summary"] == "check 7 raised ValueError"
377
+ and findings[1]["node"] == "None"
378
+ and findings[2]["node"] == "('n', 2)"
379
+ and all(exit_code([f]) == 1 for f in findings)
380
+ )
381
+
382
+
383
+ def check_worst_basics():
384
+ ok = make_finding("a", "ok", "OK", "", "s")
385
+ warn = make_finding("b", "partial", "WARN", "", "s")
386
+ fail = make_finding("c", "missing", "FAIL", "", "s")
387
+ return (
388
+ worst([ok]) == "OK"
389
+ and worst([ok, warn]) == "WARN"
390
+ and worst([warn, ok, fail]) == "FAIL"
391
+ and worst([fail, warn]) == "FAIL"
392
+ and worst([ok, ok]) == "OK"
393
+ )
394
+
395
+
396
+ def check_worst_ignores_none():
397
+ no_level = make_finding("a", "partial", "WARN", "", "s")
398
+ no_level["level"] = None
399
+ return (
400
+ worst([]) == "OK"
401
+ and worst(None) == "OK"
402
+ and worst([None, None]) == "OK"
403
+ and worst([None, no_level]) == "OK"
404
+ and worst([None, no_level, {"level": "nonsense"}, "junk", make_finding("d", "missing", "FAIL", "", "s")]) == "FAIL"
405
+ )
406
+
407
+
408
+ def check_exit_codes():
409
+ ok = make_finding("a", "ok", "OK", "", "s")
410
+ info = make_finding("b", "n/a", "FAIL", "", "s")
411
+ warn = make_finding("c", "partial", "WARN", "", "s")
412
+ fail = make_finding("d", "missing", "FAIL", "", "s")
413
+ return (
414
+ exit_code([]) == 0
415
+ and exit_code([ok, info]) == 0
416
+ and exit_code([ok, warn]) == 1
417
+ and exit_code([warn, fail]) == 2
418
+ and exit_code([None, ok]) == 0
419
+ and exit_code([None, warn]) == 1
420
+ )
421
+
422
+
423
+ def check_cli_roundtrip():
424
+ findings = [
425
+ make_finding("a", "ok", "OK", "", "healthy"),
426
+ None,
427
+ make_finding("b", "partial", "WARN", "n", "degraded"),
428
+ ]
429
+ workdir = tempfile.mkdtemp()
430
+ try:
431
+ path = os.path.join(workdir, "findings.json")
432
+ with open(path, "w", encoding="utf-8") as handle:
433
+ json.dump(findings, handle)
434
+ buf = io.StringIO()
435
+ with contextlib.redirect_stdout(buf):
436
+ code = main(["--worst", path])
437
+ lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
438
+ return (
439
+ os.path.exists(path)
440
+ and code == 1
441
+ and len(lines) >= 1
442
+ and lines[-1] == worst(findings)
443
+ and lines[-1] == "WARN"
444
+ )
445
+ finally:
446
+ shutil.rmtree(workdir, ignore_errors=True)
447
+
448
+
449
+ SELFTEST_CHECKS = [
450
+ ("constants", check_constants),
451
+ ("finding_keys", check_finding_keys),
452
+ ("level_override", check_level_override),
453
+ ("text_coercion", check_text_coercion),
454
+ ("bad_enum_raises", check_bad_enum_raises),
455
+ ("enum_checked_before_override", check_enum_checked_before_override),
456
+ ("fix_validation", check_fix_validation),
457
+ ("run_check_passthrough", check_run_check_passthrough),
458
+ ("run_check_crash", check_run_check_crash),
459
+ ("crash_never_fail_and_truncates", check_crash_never_fail_and_truncates),
460
+ ("run_check_survives_odd_ids", check_run_check_survives_odd_ids),
461
+ ("worst_basics", check_worst_basics),
462
+ ("worst_ignores_none", check_worst_ignores_none),
463
+ ("exit_codes", check_exit_codes),
464
+ ("cli_roundtrip", check_cli_roundtrip),
465
+ ]
466
+
467
+
468
+ def selftest():
469
+ """Run every named check; print the counted summary; return an exit code."""
470
+ outcomes = []
471
+ for _name, check in SELFTEST_CHECKS:
472
+ try:
473
+ outcomes.append(bool(check()))
474
+ except Exception as exc: # a broken check is a failure, not a crash
475
+ sys.stderr.write("check %s errored: %s\n" % (_name, exc))
476
+ outcomes.append(False)
477
+ total = len(SELFTEST_CHECKS)
478
+ passed = 0
479
+ for outcome in outcomes:
480
+ if outcome:
481
+ passed += 1
482
+ failed = total - passed
483
+ sys.stdout.write(
484
+ "sg_finding selftest: %d checks, %d passed, %d failed\n"
485
+ % (total, passed, failed)
486
+ )
487
+ return 0 if failed == 0 and total > 0 else 1
488
+
489
+
490
+ if __name__ == "__main__":
491
+ sys.exit(main(sys.argv[1:]))