loopagent-gate 0.4.2__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.
Files changed (40) hide show
  1. loopagent_gate-0.4.2.dist-info/METADATA +374 -0
  2. loopagent_gate-0.4.2.dist-info/RECORD +40 -0
  3. loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
  4. loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
  5. loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
  6. loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
  7. loopgate/__init__.py +7 -0
  8. loopgate/__main__.py +8 -0
  9. loopgate/cli.py +147 -0
  10. loopgate/gates/anchor_witness.py +249 -0
  11. loopgate/gates/assert_dims.py +88 -0
  12. loopgate/gates/bootstrap_state.py +111 -0
  13. loopgate/gates/change_health.py +232 -0
  14. loopgate/gates/check_loop.py +1267 -0
  15. loopgate/gates/check_product.py +463 -0
  16. loopgate/gates/check_visual.py +224 -0
  17. loopgate/gates/doctor.py +130 -0
  18. loopgate/gates/estimate_task.py +137 -0
  19. loopgate/gates/export_public.py +289 -0
  20. loopgate/gates/guard_card.py +519 -0
  21. loopgate/gates/loop_watch.py +118 -0
  22. loopgate/gates/regressions.py +463 -0
  23. loopgate/gates/render_status_page.py +151 -0
  24. loopgate/gates/render_task_ledger.py +312 -0
  25. loopgate/gates/review_pr.py +370 -0
  26. loopgate/gates/sync_issues_to_kanban.py +593 -0
  27. loopgate/gates/tree_parity.py +307 -0
  28. loopgate/gates/worker_brief.py +396 -0
  29. loopgate/init.py +128 -0
  30. loopgate/paths.py +43 -0
  31. loopgate/py.typed +0 -0
  32. loopgate/templates/__init__.py +1 -0
  33. loopgate/templates/configs/anchor_witness.example.json +14 -0
  34. loopgate/templates/configs/fixtures/issues_sample.json +75 -0
  35. loopgate/templates/configs/fixtures/review_approve.json +5 -0
  36. loopgate/templates/configs/fixtures/review_changes.json +5 -0
  37. loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
  38. loopgate/templates/configs/repo_profiles.example.json +63 -0
  39. loopgate/templates/configs/tree_parity.example.json +16 -0
  40. loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
@@ -0,0 +1,463 @@
1
+ #!/usr/bin/env python3
2
+ """Product acceptance gate (PLANS.md stage 7) — the user-facing criterion, kept separate from the tech gate.
3
+
4
+ Why a second gate
5
+ -----------------
6
+ ``loopgate/gates/check_loop.py`` answers "is this loop disciplined?" — contracts, evidence, state, guardrails.
7
+ It cannot answer "can a user do their job with this product?", and merging the two lets a green technical
8
+ gate hide a product that does not start. The chief accepts by this gate; the tech gate stays a separate
9
+ verdict with a separate owner.
10
+
11
+ Contract
12
+ --------
13
+ * Every scenario in ``configs/product_scenarios.json`` states what a user/operator observes (HTTP status,
14
+ body marker, exit code, shutdown time) and a ``why`` line naming the user-facing consequence.
15
+ * A scenario whose prerequisite is missing is **SKIP with the reason**, never a silent pass; ``--strict``
16
+ turns SKIP into FAIL, mirroring the tech gate.
17
+ * Prints ``PRODUCT_ACCEPTANCE_PASS_0001`` (exit 0) or ``PRODUCT_ACCEPTANCE_STOP_0001`` (exit 1) and writes
18
+ a machine report.
19
+ * ``evaluate(scenario, observation)`` is pure: the tech gate (D11) attacks it with falsified observations
20
+ without starting a product process.
21
+
22
+ Usage
23
+ -----
24
+ python3 -m loopgate.gates.check_product --list
25
+ python3 -m loopgate.gates.check_product # normal mode: optional scenarios may SKIP
26
+ python3 -m loopgate.gates.check_product --strict # SKIP becomes FAIL (product acceptance)
27
+ python3 -m loopgate.gates.check_product --scenario P06 --verbose
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from loopgate.paths import project_root, gate_file
33
+
34
+ import argparse
35
+ import datetime as dt
36
+ import json
37
+ import os
38
+ import shutil
39
+ import socket
40
+ import subprocess
41
+ import sys
42
+ import time
43
+
44
+ ROOT = project_root()
45
+ PASS_MARKER = "PRODUCT_ACCEPTANCE_PASS_0001"
46
+ STOP_MARKER = "PRODUCT_ACCEPTANCE_STOP_0001"
47
+ SCENARIOS_REL = "configs/product_scenarios.json"
48
+ PROFILES_REL = "configs/repo_profiles.json"
49
+ READINESS_TIMEOUT_S = 8.0
50
+ DEFAULT_REPORT = "artifacts/product_acceptance_report.json"
51
+
52
+
53
+ # ---------------------------------------------------------------- config
54
+
55
+ def load_scenarios(root: str = ROOT) -> dict:
56
+ with open(os.path.join(root, SCENARIOS_REL), encoding="utf-8") as fh:
57
+ return json.load(fh)
58
+
59
+
60
+ def load_profile(root: str, name: str) -> dict:
61
+ with open(os.path.join(root, PROFILES_REL), encoding="utf-8") as fh:
62
+ profiles = json.load(fh)["profiles"]
63
+ if name not in profiles:
64
+ raise KeyError(f"unknown repo profile {name!r}; known: {', '.join(sorted(profiles))}")
65
+ return profiles[name]
66
+
67
+
68
+ def repo_path(root: str, profile: dict) -> str:
69
+ """Where the product's source lives: the profile's env var, $PRODUCT_REPO_PATH, or its clone.
70
+
71
+ The env override exists so a clone can carry a profile without carrying someone else's absolute paths:
72
+ the shipped example declares `local_path_env` and leaves `local_path` null.
73
+ """
74
+ env_name = profile.get("local_path_env")
75
+ return (os.environ.get(env_name) if env_name else None) \
76
+ or os.environ.get("PRODUCT_REPO_PATH") \
77
+ or profile.get("local_path") \
78
+ or ""
79
+
80
+
81
+ # ---------------------------------------------------------------- evaluation (pure)
82
+
83
+ def evaluate(scenario: dict, observation: dict) -> list[str]:
84
+ """Reasons the observation violates the scenario; empty list = the scenario passed.
85
+
86
+ Pure on purpose: this is what the technical gate attacks with falsified observations, so the product
87
+ gate cannot degrade into a script that only ever prints PASS.
88
+ """
89
+ exp = scenario.get("expect") or {}
90
+ reasons: list[str] = []
91
+ kind = scenario.get("kind")
92
+
93
+ if kind in {"http", "signal"}:
94
+ if "status" in exp:
95
+ got = observation.get("status")
96
+ if got != exp["status"]:
97
+ reasons.append(f"expected HTTP {exp['status']}, got {got!r} on {scenario.get('path')}")
98
+ if exp.get("json_ok_true"):
99
+ body = observation.get("body") or ""
100
+ try:
101
+ payload = json.loads(body)
102
+ except (TypeError, ValueError):
103
+ reasons.append(f"expected a JSON body with ok=true, got {body[:60]!r}")
104
+ else:
105
+ if payload.get("ok") is not True:
106
+ reasons.append(f"expected ok=true in the body, got {payload!r}")
107
+ if "body_contains" in exp and exp["body_contains"] not in (observation.get("body") or ""):
108
+ reasons.append(f"body does not contain {exp['body_contains']!r}")
109
+ if "exit_code" in exp and observation.get("exit_code") != exp["exit_code"]:
110
+ reasons.append(f"expected exit code {exp['exit_code']}, got {observation.get('exit_code')!r}")
111
+ if "max_shutdown_ms" in exp:
112
+ got = observation.get("shutdown_ms")
113
+ if got is None:
114
+ reasons.append("shutdown time was not measured")
115
+ elif got > exp["max_shutdown_ms"]:
116
+ reasons.append(f"shutdown took {got} ms, want at most {exp['max_shutdown_ms']} ms")
117
+
118
+ elif kind == "startup_failure":
119
+ code = observation.get("exit_code")
120
+ if exp.get("exit_nonzero") and (code == 0 or code is None):
121
+ reasons.append(f"expected a non-zero exit for an invalid configuration, got {code!r}")
122
+ needle = exp.get("stderr_contains")
123
+ if needle and needle not in (observation.get("stderr") or ""):
124
+ reasons.append(f"the error message does not name {needle!r}")
125
+
126
+ elif kind == "json_schema":
127
+ if observation.get("errors"):
128
+ reasons.append(f"the validator could not judge {len(observation['errors'])} fixture(s): "
129
+ f"{observation['errors'][:2]}")
130
+ if exp.get("valid_fixtures_validate") and observation.get("valid_failures"):
131
+ reasons.append(f"valid fixtures rejected: {observation['valid_failures'][:3]}")
132
+ if exp.get("invalid_fixtures_rejected") and observation.get("invalid_accepted"):
133
+ reasons.append(f"invalid fixtures accepted: {observation['invalid_accepted'][:3]}")
134
+
135
+ else:
136
+ reasons.append(f"unknown scenario kind {kind!r}")
137
+
138
+ return reasons
139
+
140
+
141
+ # ---------------------------------------------------------------- process control
142
+
143
+ def free_port() -> int:
144
+ with socket.socket() as sock:
145
+ sock.bind(("127.0.0.1", 0))
146
+ return int(sock.getsockname()[1])
147
+
148
+
149
+ def port_env(scenario: dict, ports: dict) -> dict:
150
+ return dict(ports)
151
+
152
+
153
+ def build_product(path: str) -> tuple[bool, str]:
154
+ """Build the product binary if it is missing. Returns (ok, note)."""
155
+ binary = os.path.join(path, "bin", "sample-service")
156
+ if os.path.exists(binary):
157
+ return True, f"using {os.path.relpath(binary, path)}"
158
+ os.makedirs(os.path.dirname(binary), exist_ok=True)
159
+ proc = subprocess.run(["go", "build", "-mod=vendor", "-o", binary, "./cmd"],
160
+ cwd=path, capture_output=True, text=True)
161
+ if proc.returncode != 0:
162
+ return False, f"go build failed: {proc.stderr.strip()[:200]}"
163
+ return True, "built bin/sample-service"
164
+
165
+
166
+ def start_process(path: str, env: dict, ports: dict) -> subprocess.Popen:
167
+ full_env = {k: str(v) for k, v in env.items()}
168
+ # Ports come from the caller unless the scenario deliberately sets one (that is how the
169
+ # startup_failure scenario injects an invalid value instead of a free port).
170
+ full_env.setdefault("HTTP_SERVER_LISTEN_PORT", str(ports["api"]))
171
+ full_env.setdefault("HTTP_SERVER_PROBES_PORT", str(ports["probes"]))
172
+ full_env.setdefault("HTTP_SERVER_METRICS_PORT", str(ports["metrics"]))
173
+ full_env.setdefault("PATH", os.environ.get("PATH", "/usr/bin:/bin"))
174
+ return subprocess.Popen([os.path.join(path, "bin", "sample-service")], cwd=path, env=full_env,
175
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
176
+
177
+
178
+ def wait_for_port(port: int, proc: subprocess.Popen, timeout: float = READINESS_TIMEOUT_S) -> bool:
179
+ deadline = time.time() + timeout
180
+ while time.time() < deadline:
181
+ if proc.poll() is not None:
182
+ return False
183
+ try:
184
+ with socket.create_connection(("127.0.0.1", port), timeout=0.3):
185
+ return True
186
+ except OSError:
187
+ time.sleep(0.15)
188
+ return False
189
+
190
+
191
+ def http_get(port: int, path: str, timeout: float = 4.0) -> dict:
192
+ import urllib.error
193
+ import urllib.request
194
+ url = f"http://127.0.0.1:{port}{path}"
195
+ try:
196
+ with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - localhost only
197
+ return {"status": resp.status, "body": resp.read().decode("utf-8", "replace")}
198
+ except urllib.error.HTTPError as exc:
199
+ return {"status": exc.code, "body": exc.read().decode("utf-8", "replace")}
200
+ except Exception as exc: # noqa: BLE001 - a refused connection is an observation, not a crash
201
+ return {"status": None, "error": f"{exc.__class__.__name__}: {exc}"}
202
+
203
+
204
+ # ---------------------------------------------------------------- runners
205
+
206
+ def run_http_group(scenarios: list[dict], path: str, env: dict, verbose: bool) -> dict[str, dict]:
207
+ """Start the product once and observe every http/signal scenario against that process."""
208
+ ports = {"api": free_port(), "probes": free_port(), "metrics": free_port()}
209
+ proc = start_process(path, env, ports)
210
+ observations: dict[str, dict] = {}
211
+ try:
212
+ ready_port = ports["probes"]
213
+ if not wait_for_port(ready_port, proc):
214
+ note = f"the process did not bind the probes port within {READINESS_TIMEOUT_S:.0f}s"
215
+ for sc in scenarios:
216
+ observations[sc["id"]] = {"startup_failed": note, "status": None, "exit_code": proc.poll()}
217
+ return observations
218
+ for sc in scenarios:
219
+ if sc["kind"] == "http":
220
+ observations[sc["id"]] = http_get(ports[sc["port"]], sc["path"])
221
+ if verbose:
222
+ print(f" {sc['id']} {sc['path']} -> {observations[sc['id']].get('status')}")
223
+ elif sc["kind"] == "signal":
224
+ started = time.time()
225
+ proc.terminate()
226
+ try:
227
+ code = proc.wait(timeout=5)
228
+ except subprocess.TimeoutExpired:
229
+ proc.kill()
230
+ code = proc.wait(timeout=5)
231
+ observations[sc["id"]] = {"exit_code": code,
232
+ "shutdown_ms": round((time.time() - started) * 1000)}
233
+ finally:
234
+ if proc.poll() is None:
235
+ proc.kill()
236
+ proc.wait(timeout=5)
237
+ return observations
238
+
239
+
240
+ def run_startup_failure(scenario: dict, path: str, env: dict, verbose: bool) -> dict:
241
+ merged = dict(env)
242
+ merged.update(scenario.get("env") or {})
243
+ ports = {"api": free_port(), "probes": free_port(), "metrics": free_port()}
244
+ proc = start_process(path, merged, ports)
245
+ try:
246
+ code = proc.wait(timeout=10)
247
+ except subprocess.TimeoutExpired:
248
+ proc.kill()
249
+ code = None
250
+ stderr = (proc.stderr.read() if proc.stderr else "") or ""
251
+ if verbose:
252
+ print(f" {scenario['id']} exit={code} stderr={stderr.strip()[:120]}")
253
+ return {"exit_code": code, "stderr": stderr}
254
+
255
+
256
+ def run_json_schema(scenario: dict, path: str, verbose: bool) -> dict:
257
+ """Validate the published contracts against their own fixtures.
258
+
259
+ The v1 fixtures are *bundles* (context, plan, result, manifest, worker_events, integration_events), so a
260
+ fixture is only meaningfully judged when each member is validated against its own published schema — the
261
+ version pins live in those member schemas as ``const``, which is exactly what the invalid fixtures break.
262
+ """
263
+ try:
264
+ import jsonschema
265
+ import referencing
266
+ import referencing.jsonschema
267
+ except ImportError as exc:
268
+ # "Not installed" and "installed but broken" are different problems with different fixes, and
269
+ # conflating them sends the reader looking for a package that is already there (rpds-py ships as
270
+ # a binary wheel; a damaged install raises ModuleNotFoundError from inside jsonschema itself).
271
+ detail = str(exc)
272
+ if "rpds" in detail or "referencing" in detail:
273
+ return {"skip": f"python:jsonschema is installed but damaged ({detail}); repair with "
274
+ "`python3 -m pip install --force-reinstall rpds-py referencing`"}
275
+ return {"skip": f"python:jsonschema is not installed ({detail})"}
276
+
277
+ schema_dir = os.path.join(path, os.path.dirname(scenario["schema"]))
278
+ schemas, resources = {}, []
279
+ for name in sorted(os.listdir(schema_dir)):
280
+ if not name.endswith(".schema.json"):
281
+ continue
282
+ doc = json.load(open(os.path.join(schema_dir, name), encoding="utf-8"))
283
+ schemas[name] = doc
284
+ for uri in filter(None, (name, doc.get("$id"))):
285
+ resources.append((uri, referencing.Resource.from_contents(
286
+ doc, default_specification=referencing.jsonschema.DRAFT202012)))
287
+ registry = referencing.Registry().with_resources(resources)
288
+ members = scenario.get("members") or {}
289
+
290
+ valid_failures, invalid_accepted, errors = [], [], []
291
+ for folder, should_pass in ((scenario["fixtures_valid"], True), (scenario["fixtures_invalid"], False)):
292
+ folder_path = os.path.join(path, folder)
293
+ names = sorted(n for n in os.listdir(folder_path) if n.endswith(".json")) \
294
+ if os.path.isdir(folder_path) else []
295
+ for name in names:
296
+ bundle = json.load(open(os.path.join(folder_path, name), encoding="utf-8"))
297
+ rejected: list[str] = []
298
+ for member, schema_name in members.items():
299
+ is_list = member.endswith("[]")
300
+ key = member[:-2] if is_list else member
301
+ value = bundle.get(key)
302
+ if value is None:
303
+ continue
304
+ items = value if is_list and isinstance(value, list) else [value]
305
+ for index, item in enumerate(items):
306
+ if not isinstance(item, dict):
307
+ continue
308
+ where = f"{member}[{index}]" if is_list else member
309
+ try:
310
+ jsonschema.validate(item, schemas[schema_name], registry=registry)
311
+ except jsonschema.ValidationError as exc:
312
+ rejected.append(f"{where}: {exc.message[:70]}")
313
+ except Exception as exc: # noqa: BLE001 - a resolver error is a failed check
314
+ errors.append(f"{name}:{where}: {exc.__class__.__name__}: {str(exc)[:70]}")
315
+ if verbose:
316
+ print(f" {scenario['id']} {folder}/{name} rejected_members={len(rejected)} "
317
+ f"(want {'0' if should_pass else '>0'})")
318
+ if should_pass and rejected:
319
+ valid_failures.append(f"{name} ({rejected[0]})")
320
+ if not should_pass and not rejected:
321
+ invalid_accepted.append(name)
322
+ return {"valid_failures": valid_failures, "invalid_accepted": invalid_accepted, "errors": errors}
323
+
324
+
325
+ # ---------------------------------------------------------------- main
326
+
327
+ def main(argv=None) -> int:
328
+ ap = argparse.ArgumentParser(description="Product acceptance gate (user-facing scenarios).")
329
+ ap.add_argument("--root", default=ROOT)
330
+ ap.add_argument("--repo-path", help="path to the product source (default: the profile's local_path)")
331
+ ap.add_argument("--list", action="store_true", help="print the scenario inventory and exit")
332
+ ap.add_argument("--scenario", action="append", help="run only these scenario ids (repeatable)")
333
+ ap.add_argument("--strict", action="store_true", help="treat SKIP as FAIL (product acceptance)")
334
+ ap.add_argument("--report", default=None)
335
+ ap.add_argument("--json", action="store_true")
336
+ ap.add_argument("--verbose", action="store_true")
337
+ args = ap.parse_args(argv)
338
+
339
+ try:
340
+ config = load_scenarios(args.root)
341
+ profile = load_profile(args.root, config["profile"])
342
+ except (OSError, KeyError, json.JSONDecodeError) as exc:
343
+ print(f"config error: {exc}", file=sys.stderr)
344
+ print(STOP_MARKER)
345
+ return 2
346
+
347
+ scenarios = config["scenarios"]
348
+ if args.list:
349
+ print(f"product: {config['product']} ({len(scenarios)} scenarios, "
350
+ f"{sum(1 for s in scenarios if s.get('optional'))} optional)")
351
+ for sc in scenarios:
352
+ flag = "optional" if sc.get("optional") else "mandatory"
353
+ print(f" {sc['id']} [{flag}] {sc['kind']:<16} {sc['title']}")
354
+ return 0
355
+
356
+ if args.scenario:
357
+ wanted = set(args.scenario)
358
+ scenarios = [s for s in scenarios if s["id"] in wanted]
359
+ if not scenarios:
360
+ print(f"no scenario matches {sorted(wanted)}", file=sys.stderr)
361
+ return 2
362
+
363
+ path = args.repo_path or repo_path(args.root, profile)
364
+ print("PRODUCT ACCEPTANCE GATE — loopgate/gates/check_product.py")
365
+ print(f"product: {config['product']} source: {path or '(not configured)'}")
366
+ print(f"mode: {'strict' if args.strict else 'normal'}")
367
+ print()
368
+
369
+ if not path or not os.path.isdir(path):
370
+ print(f"[FAIL] the product source is missing: {path!r} "
371
+ f"(set local_path in {PROFILES_REL} or pass --repo-path)")
372
+ print(STOP_MARKER)
373
+ return 1
374
+
375
+ ok, note = build_product(path)
376
+ print(f"build: {note}")
377
+ if not ok:
378
+ print(f"[FAIL] {note}")
379
+ print(STOP_MARKER)
380
+ return 1
381
+ print()
382
+
383
+ results: list[dict] = []
384
+ http_group = [s for s in scenarios if s["kind"] in {"http", "signal"}]
385
+ observations: dict[str, dict] = {}
386
+ if http_group:
387
+ observations.update(run_http_group(http_group, path, config["env_base"], args.verbose))
388
+
389
+ for sc in scenarios:
390
+ if sc["kind"] in {"http", "signal"}:
391
+ obs = observations.get(sc["id"], {})
392
+ elif sc["kind"] == "startup_failure":
393
+ obs = run_startup_failure(sc, path, config["env_base"], args.verbose)
394
+ elif sc["kind"] == "json_schema":
395
+ obs = run_json_schema(sc, path, args.verbose)
396
+ else:
397
+ obs = {"skip": f"unknown kind {sc['kind']!r}"}
398
+
399
+ skip_reason = obs.get("skip")
400
+ if not skip_reason and obs.get("startup_failed"):
401
+ skip_reason = None # a product that does not start is a FAIL, not a skip
402
+ reasons = [] if skip_reason else evaluate(sc, obs)
403
+ if not reasons and obs.get("startup_failed"):
404
+ reasons = [obs["startup_failed"]]
405
+ status = "SKIP" if skip_reason else ("OK" if not reasons else "FAIL")
406
+ if skip_reason and args.strict:
407
+ status = "FAIL"
408
+ reasons = [f"prerequisite missing: {skip_reason}"]
409
+ results.append({"id": sc["id"], "title": sc["title"], "kind": sc["kind"],
410
+ "status": status, "reasons": reasons, "observation": obs,
411
+ "optional": bool(sc.get("optional"))})
412
+ print(f"{sc['id']} [{status}] {sc['title']}")
413
+ if skip_reason:
414
+ print(f" - SKIP: {skip_reason}")
415
+ for reason in reasons:
416
+ print(f" - {reason}")
417
+
418
+ passed = sum(1 for r in results if r["status"] == "OK")
419
+ failed = [r for r in results if r["status"] == "FAIL"]
420
+ skipped = [r for r in results if r["status"] == "SKIP"]
421
+ ok = not failed
422
+ print()
423
+ print(f"scenarios: {passed} ok, {len(failed)} fail, {len(skipped)} skip "
424
+ f"(mandatory: {sum(1 for s in scenarios if not s.get('optional'))}, "
425
+ f"optional: {sum(1 for s in scenarios if s.get('optional'))})")
426
+ print(f"verdict: {'product accepted' if ok else 'product NOT accepted'}")
427
+
428
+ report = {
429
+ "marker": PASS_MARKER if ok else STOP_MARKER,
430
+ "product": config["product"],
431
+ "source": path,
432
+ "strict": args.strict,
433
+ "generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
434
+ "scenarios": [{k: v for k, v in r.items() if k != "observation"} | {"observation": r["observation"]}
435
+ for r in results],
436
+ "failures": [r["id"] for r in failed],
437
+ "skips": [r["id"] for r in skipped],
438
+ }
439
+ report_path = args.report or config.get("report") or DEFAULT_REPORT
440
+ if not os.path.isabs(report_path):
441
+ report_path = os.path.join(args.root, report_path)
442
+ try:
443
+ os.makedirs(os.path.dirname(report_path), exist_ok=True)
444
+ with open(report_path, "w", encoding="utf-8") as fh:
445
+ json.dump(report, fh, ensure_ascii=False, indent=2)
446
+ fh.write("\n")
447
+ print(f"report: {os.path.relpath(report_path, args.root)}")
448
+ except OSError as exc:
449
+ print(f"warning: could not write the report ({exc.__class__.__name__}: {exc}); verdict unchanged")
450
+ if args.json:
451
+ print(json.dumps(report, ensure_ascii=False, indent=2))
452
+ if ok:
453
+ print(PASS_MARKER)
454
+ return 0
455
+ print(f"FAILURES ({len(failed)}):")
456
+ for i, r in enumerate(failed, 1):
457
+ print(f" {i}. {r['id']} {r['title']}: {r['reasons'][0] if r['reasons'] else 'see report'}")
458
+ print(STOP_MARKER)
459
+ return 1
460
+
461
+
462
+ if __name__ == "__main__":
463
+ sys.exit(main())