graphcheck 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.
Files changed (79) hide show
  1. graphcheck/__init__.py +9 -0
  2. graphcheck/application/__init__.py +0 -0
  3. graphcheck/application/artifacts.py +145 -0
  4. graphcheck/application/paths.py +6 -0
  5. graphcheck/application/run.py +233 -0
  6. graphcheck/application/suites.py +44 -0
  7. graphcheck/baselines.py +228 -0
  8. graphcheck/bootstrap.py +19 -0
  9. graphcheck/cli.py +2339 -0
  10. graphcheck/connection_profiles.py +119 -0
  11. graphcheck/contracts/__init__.py +0 -0
  12. graphcheck/contracts/check.py +258 -0
  13. graphcheck/contracts/profile.py +290 -0
  14. graphcheck/contracts/results.py +409 -0
  15. graphcheck/contracts/scalars.py +23 -0
  16. graphcheck/contracts/schemas.py +140 -0
  17. graphcheck/debug_diagnostics.py +155 -0
  18. graphcheck/diff.py +420 -0
  19. graphcheck/engine/__init__.py +64 -0
  20. graphcheck/engine/baseline.py +262 -0
  21. graphcheck/engine/compiler.py +577 -0
  22. graphcheck/engine/core_pack.py +681 -0
  23. graphcheck/engine/evaluator.py +982 -0
  24. graphcheck/engine/executor.py +192 -0
  25. graphcheck/engine/identifiers.py +25 -0
  26. graphcheck/engine/parameters.py +99 -0
  27. graphcheck/engine/pii_pack.py +318 -0
  28. graphcheck/engine/runner.py +1798 -0
  29. graphcheck/engine/sampling.py +316 -0
  30. graphcheck/errors.py +69 -0
  31. graphcheck/generation/__init__.py +1 -0
  32. graphcheck/generation/client.py +383 -0
  33. graphcheck/generation/config.py +100 -0
  34. graphcheck/generation/disclosure.py +115 -0
  35. graphcheck/generation/prompts.py +112 -0
  36. graphcheck/generation/proposals.py +271 -0
  37. graphcheck/generation/service.py +379 -0
  38. graphcheck/generation/transmission.py +311 -0
  39. graphcheck/generation/writer.py +116 -0
  40. graphcheck/mcp/adapter.py +133 -0
  41. graphcheck/mcp/server.py +62 -0
  42. graphcheck/neo4j_adapter.py +1431 -0
  43. graphcheck/observability/__init__.py +13 -0
  44. graphcheck/observability/collector.py +31 -0
  45. graphcheck/observability/health.py +69 -0
  46. graphcheck/observability/metrics.py +34 -0
  47. graphcheck/observability/runner.py +28 -0
  48. graphcheck/observability/server.py +18 -0
  49. graphcheck/packs/__init__.py +213 -0
  50. graphcheck/packs/catalog.py +106 -0
  51. graphcheck/packs/core.yml +113 -0
  52. graphcheck/packs/metadata.py +329 -0
  53. graphcheck/packs/pii.yml +77 -0
  54. graphcheck/profiler.py +822 -0
  55. graphcheck/project.py +115 -0
  56. graphcheck/reporting/__init__.py +41 -0
  57. graphcheck/reporting/explorer.py +372 -0
  58. graphcheck/reporting/history.py +527 -0
  59. graphcheck/reporting/html.py +2239 -0
  60. graphcheck/reporting/presentation.py +156 -0
  61. graphcheck/reporting/redaction.py +321 -0
  62. graphcheck/reporting/writer.py +97 -0
  63. graphcheck/scoring.py +156 -0
  64. graphcheck/telemetry/__init__.py +28 -0
  65. graphcheck/telemetry/collector.py +296 -0
  66. graphcheck/telemetry/consent.py +208 -0
  67. graphcheck/telemetry/events.py +417 -0
  68. graphcheck/telemetry/inactive.py +130 -0
  69. graphcheck/telemetry/policy.py +769 -0
  70. graphcheck/telemetry/posthog.py +253 -0
  71. graphcheck/telemetry/release.py +10 -0
  72. graphcheck/telemetry/runtime.py +375 -0
  73. graphcheck/telemetry/types.py +222 -0
  74. graphcheck/yaml_loader.py +43 -0
  75. graphcheck-0.1.0.dist-info/METADATA +187 -0
  76. graphcheck-0.1.0.dist-info/RECORD +79 -0
  77. graphcheck-0.1.0.dist-info/WHEEL +4 -0
  78. graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
  79. graphcheck-0.1.0.dist-info/licenses/LICENSE +202 -0
graphcheck/cli.py ADDED
@@ -0,0 +1,2339 @@
1
+ import json
2
+ import logging
3
+ import sys
4
+ import threading
5
+ import time
6
+ import webbrowser
7
+ from collections.abc import Callable, Iterator, Sequence
8
+ from contextlib import ExitStack, contextmanager, suppress
9
+ from contextvars import ContextVar
10
+ from dataclasses import replace
11
+ from functools import wraps
12
+ from importlib import import_module
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Annotated
15
+
16
+ import typer
17
+
18
+ from graphcheck import __version__
19
+ from graphcheck.telemetry.consent import (
20
+ disable_telemetry,
21
+ enable_telemetry,
22
+ reset_installation_id,
23
+ resolve_consent,
24
+ )
25
+ from graphcheck.telemetry.types import (
26
+ CONSENT_VERSION,
27
+ TELEMETRY_SCHEMA_VERSION,
28
+ ArtifactOutcome,
29
+ CliFailureStage,
30
+ CommandAction,
31
+ CommandName,
32
+ ConsentState,
33
+ EventOutcome,
34
+ OutputMode,
35
+ ProcessOutcome,
36
+ SafeErrorCode,
37
+ safe_command,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from graphcheck.contracts.profile import BaselineProfile
42
+ from graphcheck.contracts.results import CheckError, Results, RunTarget
43
+ from graphcheck.engine import SuiteInput
44
+ from graphcheck.generation.disclosure import GenerateDisclosure
45
+ from graphcheck.generation.service import DroppedCandidate, GenerateResult
46
+ from graphcheck.reporting.history import ReportRun
47
+
48
+ _NEO4J_NOTIFICATION_LOGGER = "neo4j.notifications"
49
+
50
+
51
+ def _call(module: str, name: str, *args, **kwargs):
52
+ """Import a command dependency only when its command actually uses it."""
53
+ return getattr(import_module(module), name)(*args, **kwargs)
54
+
55
+
56
+ # Stable injection points for tests and integrations; each forwards lazily by default.
57
+ def find_project_root(*args, **kwargs):
58
+ return _call("graphcheck.project", "find_project_root", *args, **kwargs)
59
+
60
+
61
+ def load_profiles(*args, **kwargs):
62
+ return _call("graphcheck.connection_profiles", "load_profiles", *args, **kwargs)
63
+
64
+
65
+ def select_profile(*args, **kwargs):
66
+ return _call("graphcheck.connection_profiles", "select_profile", *args, **kwargs)
67
+
68
+
69
+ def Neo4jClient(*args, **kwargs):
70
+ return _call("graphcheck.neo4j_adapter", "Neo4jClient", *args, **kwargs)
71
+
72
+
73
+ def init_trace(*args, **kwargs):
74
+ return _call("graphcheck.neo4j_adapter", "init_trace", *args, **kwargs)
75
+
76
+
77
+ def debug_trace(*args, **kwargs):
78
+ return _call("graphcheck.neo4j_adapter", "debug_trace", *args, **kwargs)
79
+
80
+
81
+ def build_profile(*args, **kwargs):
82
+ return _call("graphcheck.profiler", "profile", *args, **kwargs)
83
+
84
+
85
+ def resolve_diff_baselines(*args, **kwargs):
86
+ return _call("graphcheck.baselines", "resolve_diff_baselines", *args, **kwargs)
87
+
88
+
89
+ def compare_baselines(*args, **kwargs):
90
+ return _call("graphcheck.diff", "compare", *args, **kwargs)
91
+
92
+
93
+ def generation_service_factory(*args, **kwargs):
94
+ return _call("graphcheck.generation.service", "GenerationService", *args, **kwargs)
95
+
96
+
97
+ def write_results(*args, **kwargs):
98
+ return _call("graphcheck.reporting.writer", "write_results", *args, **kwargs)
99
+
100
+
101
+ def redact_results(*args, **kwargs):
102
+ return _call("graphcheck.reporting.redaction", "redact_results", *args, **kwargs)
103
+
104
+
105
+ def write_html_report(*args, **kwargs):
106
+ return _call("graphcheck.reporting.html", "write_html_report", *args, **kwargs)
107
+
108
+
109
+ def open_report_explorer(*args, **kwargs):
110
+ return _call("graphcheck.reporting.explorer", "launch_report_explorer", *args, **kwargs)
111
+
112
+
113
+ def run_monitor(*args, **kwargs):
114
+ return _call("graphcheck.observability.runner", "run_monitor", *args, **kwargs)
115
+
116
+
117
+ app = typer.Typer(
118
+ name="graphcheck",
119
+ help="Semantic observability for property graphs.",
120
+ add_completion=False,
121
+ no_args_is_help=True,
122
+ rich_markup_mode="rich",
123
+ )
124
+
125
+ mcp_app = typer.Typer(help="Model Context Protocol commands.")
126
+ app.add_typer(mcp_app, name="mcp")
127
+
128
+ telemetry_app = typer.Typer(
129
+ name="telemetry",
130
+ help="Manage anonymous opt-in product telemetry.",
131
+ no_args_is_help=True,
132
+ )
133
+ app.add_typer(telemetry_app, name="telemetry")
134
+
135
+ baseline_app = typer.Typer(help="Manage baseline snapshots.")
136
+ app.add_typer(baseline_app, name="baseline")
137
+
138
+ _COMMAND_TELEMETRY: ContextVar[object | None] = ContextVar(
139
+ "graphcheck_command_telemetry",
140
+ default=None,
141
+ )
142
+
143
+
144
+ def _telemetry_command(command: CommandName):
145
+ def decorate(function):
146
+ @wraps(function)
147
+ def instrumented(*args, **kwargs):
148
+ runtime = _command_telemetry()
149
+ owns_runtime = runtime is None
150
+ token = None
151
+ if runtime is None:
152
+ output_mode = (
153
+ OutputMode.JSON if kwargs.get("json_output") is True else OutputMode.HUMAN
154
+ )
155
+ runtime = _start_telemetry_runtime(command, output_mode=output_mode)
156
+ token = _COMMAND_TELEMETRY.set(runtime)
157
+ runtime.mark_callback_entered()
158
+ try:
159
+ return function(*args, **kwargs)
160
+ except typer.Exit:
161
+ raise
162
+ except Exception:
163
+ if runtime.process_outcome is ProcessOutcome.SUCCESS:
164
+ runtime.fail(
165
+ ProcessOutcome.UNEXPECTED_ERROR,
166
+ CliFailureStage.PROJECT_DISCOVERY,
167
+ SafeErrorCode.UNKNOWN,
168
+ )
169
+ raise
170
+ finally:
171
+ if owns_runtime:
172
+ with suppress(Exception):
173
+ runtime.finish()
174
+ assert token is not None
175
+ _COMMAND_TELEMETRY.reset(token)
176
+
177
+ return instrumented
178
+
179
+ return decorate
180
+
181
+
182
+ def _command_telemetry():
183
+ return _COMMAND_TELEMETRY.get()
184
+
185
+
186
+ def cli(*, consent: ConsentState | None = None) -> None:
187
+ """Run Typer behind the true command boundary, including argument-parsing failures."""
188
+
189
+ arguments = tuple(sys.argv[1:])
190
+ command = _command_from_argv(arguments)
191
+ if command is CommandName.TELEMETRY:
192
+ app()
193
+ return
194
+
195
+ output_mode = OutputMode.JSON if "--json" in arguments else OutputMode.HUMAN
196
+ runtime = _start_telemetry_runtime(command, output_mode=output_mode, consent=consent)
197
+ token = _COMMAND_TELEMETRY.set(runtime)
198
+ try:
199
+ app()
200
+ except SystemExit as exc:
201
+ if not runtime.callback_entered and exc.code not in {None, 0}:
202
+ runtime.fail(
203
+ ProcessOutcome.USER_ERROR,
204
+ CliFailureStage.CONFIG_LOAD,
205
+ SafeErrorCode.CONFIG_INVALID,
206
+ )
207
+ raise
208
+ except Exception:
209
+ if runtime.process_outcome is ProcessOutcome.SUCCESS:
210
+ runtime.fail(
211
+ ProcessOutcome.UNEXPECTED_ERROR,
212
+ CliFailureStage.CONFIG_LOAD,
213
+ SafeErrorCode.UNKNOWN,
214
+ )
215
+ raise
216
+ finally:
217
+ with suppress(Exception):
218
+ runtime.finish()
219
+ _COMMAND_TELEMETRY.reset(token)
220
+
221
+
222
+ def _command_from_argv(arguments: Sequence[str]) -> CommandName:
223
+ for argument in arguments:
224
+ if argument == "--":
225
+ break
226
+ if not argument.startswith("-"):
227
+ return safe_command(argument)
228
+ return CommandName.OTHER
229
+
230
+
231
+ def _start_telemetry_runtime(
232
+ command: CommandName,
233
+ *,
234
+ output_mode: OutputMode,
235
+ consent: ConsentState | None = None,
236
+ action: CommandAction | str | None = None,
237
+ ):
238
+ state = consent
239
+ if state is None:
240
+ try:
241
+ state = resolve_consent()
242
+ except Exception:
243
+ from graphcheck.telemetry.types import ConsentSource
244
+
245
+ state = ConsentState(False, ConsentSource.DEFAULT)
246
+ if state.enabled:
247
+ from graphcheck.telemetry.runtime import CommandTelemetryRuntime
248
+
249
+ return CommandTelemetryRuntime.start(
250
+ command,
251
+ action=action,
252
+ output_mode=output_mode,
253
+ consent=state,
254
+ )
255
+ from graphcheck.telemetry.inactive import InactiveCommandTelemetryRuntime
256
+
257
+ return InactiveCommandTelemetryRuntime.start(
258
+ command,
259
+ action=action,
260
+ output_mode=output_mode,
261
+ consent=state,
262
+ )
263
+
264
+
265
+ def _version(value: bool) -> None:
266
+ if value:
267
+ typer.secho(f"graphcheck {__version__}", fg=typer.colors.BRIGHT_BLUE, bold=True)
268
+ raise typer.Exit()
269
+
270
+
271
+ @app.callback()
272
+ def main(
273
+ version: bool = typer.Option(
274
+ False, "--version", callback=_version, is_eager=True, help="Show version and exit."
275
+ ),
276
+ ) -> None:
277
+ """Run graph checks, diagnose Neo4j, and inspect offline reports."""
278
+
279
+ # Neo4j 6 logs every server notification at WARNING by default. These are query metadata,
280
+ # which the adapter consumes and promotes when GraphCheck needs to act on them; letting the
281
+ # driver's logger also write them to stderr overwhelms the CLI's stable human output.
282
+ logging.getLogger(_NEO4J_NOTIFICATION_LOGGER).setLevel(logging.ERROR)
283
+
284
+
285
+ @telemetry_app.command("enable")
286
+ def telemetry_enable() -> None:
287
+ """Enable anonymous telemetry for this user."""
288
+
289
+ started = time.monotonic()
290
+ before = resolve_consent()
291
+ enable_telemetry()
292
+ state = resolve_consent()
293
+ typer.secho("Anonymous GraphCheck telemetry is enabled.", fg=typer.colors.GREEN)
294
+ if not _telemetry_delivery_configured():
295
+ typer.secho(
296
+ "Telemetry delivery is not configured in this build; consent remains stored.",
297
+ fg=typer.colors.YELLOW,
298
+ )
299
+ # This is the single telemetry control action that may emit: consent exists only after the
300
+ # state has been stored, so construct the command runtime at that point.
301
+ try:
302
+ if before.enabled or not state.enabled:
303
+ return
304
+ runtime = _start_telemetry_runtime(
305
+ CommandName.TELEMETRY,
306
+ action=CommandAction.ENABLE,
307
+ output_mode=OutputMode.HUMAN,
308
+ consent=state,
309
+ )
310
+ runtime.started_perf = started
311
+ runtime.finish()
312
+ except Exception:
313
+ pass
314
+
315
+
316
+ @telemetry_app.command("disable")
317
+ def telemetry_disable() -> None:
318
+ """Disable telemetry without sending an event."""
319
+
320
+ disable_telemetry()
321
+ typer.secho("Anonymous GraphCheck telemetry is disabled.", fg=typer.colors.YELLOW)
322
+
323
+
324
+ @telemetry_app.command("status")
325
+ def telemetry_status() -> None:
326
+ """Show the effective user/process telemetry state."""
327
+
328
+ state = resolve_consent()
329
+ typer.secho(
330
+ f"Telemetry: {'enabled' if state.enabled else 'disabled'}",
331
+ fg=typer.colors.GREEN if state.enabled else typer.colors.YELLOW,
332
+ )
333
+ typer.echo(f"Source: {state.source.value}")
334
+ delivery = "configured" if _telemetry_delivery_configured() else "not configured"
335
+ typer.echo(f"Delivery: {delivery}")
336
+ if state.renewal_required:
337
+ typer.secho(
338
+ "Consent renewal is required for the current telemetry consent version.",
339
+ fg=typer.colors.YELLOW,
340
+ )
341
+
342
+
343
+ @telemetry_app.command("preview")
344
+ def telemetry_preview() -> None:
345
+ """Print representative sanitized payloads without sending them."""
346
+
347
+ payload = _telemetry_preview_payload()
348
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
349
+
350
+
351
+ @telemetry_app.command("reset-id")
352
+ def telemetry_reset_id() -> None:
353
+ """Break installation linkage by replacing or clearing the stored ID."""
354
+
355
+ state = reset_installation_id()
356
+ if state.enabled:
357
+ typer.secho(
358
+ "Telemetry installation ID was reset; telemetry remains enabled.",
359
+ fg=typer.colors.GREEN,
360
+ )
361
+ else:
362
+ typer.secho("Inactive telemetry installation ID was cleared.", fg=typer.colors.GREEN)
363
+
364
+
365
+ @app.command()
366
+ @_telemetry_command(CommandName.INIT)
367
+ def init() -> None:
368
+ """Scaffold a new GraphCheck project in the current directory."""
369
+ from graphcheck.connection_profiles import write_default_profiles
370
+ from graphcheck.errors import GraphCheckError
371
+ from graphcheck.project import (
372
+ PROJECT_FILE,
373
+ ensure_gitignore_entries,
374
+ write_default_project,
375
+ write_example_suite,
376
+ )
377
+
378
+ root = Path.cwd()
379
+ write_default_project(root)
380
+ write_default_profiles(root)
381
+ ensure_gitignore_entries(root)
382
+ write_example_suite(root)
383
+
384
+ typer.secho(f"Wrote {PROJECT_FILE}", fg=typer.colors.GREEN)
385
+ typer.secho("Wrote profiles.yml", fg=typer.colors.GREEN)
386
+ typer.secho("Profile setup help is included in profiles.yml", fg=typer.colors.CYAN)
387
+ typer.secho("Wrote checks/example.yml with 3 sample checks", fg=typer.colors.GREEN)
388
+
389
+ profiles = load_profiles(root)
390
+ profile_name, profile = select_profile(profiles)
391
+ probe_started = time.monotonic()
392
+ connected = False
393
+ try:
394
+ trace = init_trace(profile_name, profile)
395
+ except GraphCheckError as exc:
396
+ if (telemetry := _command_telemetry()) is not None:
397
+ telemetry.record_probe(
398
+ started_perf=probe_started,
399
+ outcome=EventOutcome.ERROR,
400
+ )
401
+ typer.secho(f"Neo4j was not detected: {exc.error.code}", fg=typer.colors.YELLOW)
402
+ typer.secho(exc.error.message, fg=typer.colors.YELLOW)
403
+ typer.secho(f"Fix: {exc.error.fix}", fg=typer.colors.CYAN)
404
+ except Exception as exc:
405
+ if (telemetry := _command_telemetry()) is not None:
406
+ telemetry.record_probe(started_perf=probe_started, outcome=EventOutcome.ERROR)
407
+ typer.secho("Neo4j was not detected: init.internal_error", fg=typer.colors.YELLOW)
408
+ typer.secho(
409
+ "GraphCheck could not complete the connection diagnostic.", fg=typer.colors.YELLOW
410
+ )
411
+ typer.secho(
412
+ "Fix: Run `graphcheck debug --json`; if the problem persists, report the JSON "
413
+ f"error and GraphCheck version. ({type(exc).__name__})",
414
+ fg=typer.colors.CYAN,
415
+ )
416
+ else:
417
+ connected = True
418
+ if (telemetry := _command_telemetry()) is not None:
419
+ telemetry.record_probe(
420
+ started_perf=probe_started,
421
+ outcome=EventOutcome.SUCCESS,
422
+ target=trace.target,
423
+ )
424
+ typer.secho(
425
+ f"Detected Neo4j at {profile.uri} (version {trace.target.server_version})",
426
+ fg=typer.colors.GREEN,
427
+ )
428
+ typer.secho(
429
+ f"APOC: {'yes' if trace.target.capabilities.apoc else 'no'}",
430
+ fg=typer.colors.GREEN if trace.target.capabilities.apoc else typer.colors.YELLOW,
431
+ )
432
+ typer.secho(
433
+ (
434
+ "Next: edit checks/example.yml, then run `graphcheck run`"
435
+ if connected
436
+ else "Next: apply the fix above, then run `graphcheck debug`"
437
+ ),
438
+ fg=typer.colors.CYAN,
439
+ )
440
+
441
+
442
+ @app.command()
443
+ @_telemetry_command(CommandName.DEBUG)
444
+ def debug(
445
+ profile: str | None = typer.Option(None, "--profile", help="Connection profile to use."),
446
+ json_output: bool = typer.Option(False, "--json", help="Emit the stable debug JSON trace."),
447
+ ) -> None:
448
+ """Diagnose Neo4j connectivity and apply the edition-specific read-only policy."""
449
+ from graphcheck.debug_diagnostics import CapabilityContext, blocked_checks_for_project
450
+ from graphcheck.errors import GraphCheckError
451
+ from graphcheck.neo4j_adapter import error_json
452
+
453
+ profile_name = profile or "local"
454
+ try:
455
+ root = find_project_root()
456
+ profiles = load_profiles(root)
457
+ profile_name, selected = select_profile(profiles, profile)
458
+ probe_started = time.monotonic()
459
+ trace = debug_trace(profile_name, selected)
460
+ if (telemetry := _command_telemetry()) is not None:
461
+ telemetry.record_probe(
462
+ started_perf=probe_started,
463
+ outcome=EventOutcome.SUCCESS,
464
+ target=trace.target,
465
+ )
466
+ trace = replace(
467
+ trace,
468
+ blocked_checks=tuple(
469
+ blocked_checks_for_project(
470
+ root,
471
+ CapabilityContext.from_probe(trace.target.capabilities, trace.visibility),
472
+ )
473
+ ),
474
+ )
475
+ except GraphCheckError as exc:
476
+ if (telemetry := _command_telemetry()) is not None:
477
+ if "probe_started" in locals():
478
+ telemetry.record_probe(
479
+ started_perf=probe_started,
480
+ outcome=EventOutcome.ERROR,
481
+ )
482
+ telemetry.fail(
483
+ ProcessOutcome.USER_ERROR,
484
+ _cli_stage_for_error(exc.error.code),
485
+ exc.error.code,
486
+ )
487
+ payload = error_json(profile_name, exc.error)
488
+ if json_output:
489
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
490
+ else:
491
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
492
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
493
+ raise typer.Exit(1) from exc
494
+ except Exception as exc:
495
+ from graphcheck.contracts.results import CheckError
496
+
497
+ error = CheckError(
498
+ code="debug.internal_error",
499
+ message="GraphCheck could not complete the connection diagnostic.",
500
+ fix="Run `graphcheck debug --json`; if the problem persists, report the JSON error "
501
+ "and GraphCheck version.",
502
+ )
503
+ payload = error_json(profile_name, error)
504
+ if json_output:
505
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
506
+ else:
507
+ typer.echo(f"{error.code}: {error.message}", err=True)
508
+ typer.echo(f"Fix: {error.fix}", err=True)
509
+ raise typer.Exit(1) from exc
510
+
511
+ if json_output:
512
+ typer.echo(json.dumps(trace.as_json(), indent=2, sort_keys=True))
513
+ return
514
+
515
+ caps = trace.target.capabilities
516
+ typer.echo(f"Profile: {trace.profile}")
517
+ if trace.versions is not None:
518
+ typer.echo(f"GraphCheck version: {trace.versions.graphcheck}")
519
+ typer.echo(f"Neo4j Python driver: {trace.versions.neo4j_driver}")
520
+ typer.echo(f"Neo4j Server: {trace.target.server_version}")
521
+ if trace.versions is not None:
522
+ typer.echo(f"Cypher: {trace.versions.cypher}")
523
+ typer.echo(f"Edition: {trace.target.edition}")
524
+ typer.echo(f"Database name: {trace.target.database}")
525
+ typer.echo(f"APOC: {'yes' if caps.apoc else 'no'}")
526
+ typer.echo(f"Count store: {'yes' if caps.count_store else 'no'}")
527
+ can_see = []
528
+ if trace.visibility.can_connect:
529
+ can_see.append("connect")
530
+ if trace.visibility.can_read:
531
+ can_see.append("read")
532
+ if trace.visibility.can_show_procedures:
533
+ can_see.append("procedures")
534
+ cannot_see = []
535
+ if not trace.visibility.can_connect:
536
+ cannot_see.append("connect")
537
+ if not trace.visibility.can_read:
538
+ cannot_see.append("read")
539
+ if not trace.visibility.can_show_procedures:
540
+ cannot_see.append("procedures")
541
+ typer.echo(f"Credentials can see: {', '.join(can_see) if can_see else 'none detected'}")
542
+ cannot_see_text = ", ".join(cannot_see) if cannot_see else "none detected"
543
+ typer.echo(f"Credentials cannot see: {cannot_see_text}")
544
+ if trace.blocked_checks:
545
+ typer.echo("Blocked checks:")
546
+ for blocked in trace.blocked_checks:
547
+ typer.echo(
548
+ f"- {blocked.suite}/{blocked.check_id} ({blocked.check}) requires "
549
+ f"{blocked.missing_capability}: {blocked.fix}"
550
+ )
551
+ else:
552
+ typer.echo("Blocked checks: none")
553
+ if trace.counts.nodes is None or trace.counts.relationships is None:
554
+ typer.echo("Counts: unavailable (read access denied)")
555
+ else:
556
+ typer.echo(
557
+ f"Counts: {trace.counts.nodes} nodes, {trace.counts.relationships} relationships"
558
+ )
559
+
560
+
561
+ @app.command()
562
+ @_telemetry_command(CommandName.PROFILE)
563
+ def profile(
564
+ profile: str | None = typer.Option(
565
+ None,
566
+ "--profile",
567
+ help="Connection profile to use.",
568
+ ),
569
+ json_output: bool = typer.Option(
570
+ False,
571
+ "--json",
572
+ help="Emit the complete baseline profile as JSON.",
573
+ ),
574
+ ) -> None:
575
+ """Generate a baseline profile for the connected Neo4j graph."""
576
+ from graphcheck.baselines import write_baseline
577
+ from graphcheck.contracts.profile import ProfileStatus
578
+ from graphcheck.errors import GraphCheckError
579
+ from graphcheck.project import PROJECT_FILE, default_project_config, load_project_config
580
+
581
+ telemetry = _command_telemetry()
582
+ setup_started = time.monotonic()
583
+ profiling_started = False
584
+ unexpected_stage = CliFailureStage.PROJECT_DISCOVERY
585
+ try:
586
+ root = find_project_root()
587
+ config = (
588
+ load_project_config(root)
589
+ if (root / PROJECT_FILE).is_file()
590
+ else default_project_config()
591
+ )
592
+ unexpected_stage = CliFailureStage.PROFILE_LOAD
593
+ profiles = load_profiles(root)
594
+ _, selected = select_profile(profiles, profile)
595
+
596
+ unexpected_stage = CliFailureStage.CLIENT_SETUP
597
+ client = Neo4jClient(selected)
598
+ if telemetry is not None:
599
+ telemetry.mark_setup(setup_started)
600
+ try:
601
+ profiling_started = True
602
+ unexpected_stage = CliFailureStage.PROFILE_COLLECTION
603
+ observer = (
604
+ telemetry.record_profile_stage
605
+ if telemetry is not None and telemetry.enabled
606
+ else None
607
+ )
608
+ result_observer = (
609
+ telemetry.record_profile_result
610
+ if telemetry is not None and telemetry.enabled
611
+ else None
612
+ )
613
+ baseline = build_profile(
614
+ client,
615
+ telemetry_observer=observer,
616
+ telemetry_result_observer=result_observer,
617
+ )
618
+ finally:
619
+ client.close()
620
+ except GraphCheckError as exc:
621
+ if telemetry is not None:
622
+ if telemetry.setup_ms is None:
623
+ telemetry.mark_setup(setup_started)
624
+ telemetry.fail(
625
+ ProcessOutcome.USER_ERROR,
626
+ (
627
+ CliFailureStage.PROFILE_COLLECTION
628
+ if profiling_started
629
+ else _cli_stage_for_error(exc.error.code)
630
+ ),
631
+ exc.error.code,
632
+ )
633
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
634
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
635
+ raise typer.Exit(1) from exc
636
+ except Exception:
637
+ if telemetry is not None and telemetry.process_outcome is ProcessOutcome.SUCCESS:
638
+ if telemetry.setup_ms is None:
639
+ telemetry.mark_setup(setup_started)
640
+ telemetry.fail(
641
+ ProcessOutcome.UNEXPECTED_ERROR,
642
+ unexpected_stage,
643
+ (
644
+ SafeErrorCode.PROFILE_COLLECTION_FAILED
645
+ if unexpected_stage is CliFailureStage.PROFILE_COLLECTION
646
+ else SafeErrorCode.UNKNOWN
647
+ ),
648
+ )
649
+ raise
650
+
651
+ if telemetry is not None and not telemetry.profile_result_recorded:
652
+ telemetry.record_profile_result(
653
+ baseline.status,
654
+ None if baseline.status is ProfileStatus.COMPLETE else "unknown",
655
+ )
656
+ artifact_started = time.monotonic()
657
+ try:
658
+ path = write_baseline(baseline, root, config.artifacts)
659
+ except OSError as exc:
660
+ if telemetry is not None:
661
+ telemetry.baseline_artifact = ArtifactOutcome.ERROR
662
+ telemetry.artifact_write_ms = max(
663
+ 0,
664
+ round((time.monotonic() - artifact_started) * 1000),
665
+ )
666
+ telemetry.fail(
667
+ ProcessOutcome.UNEXPECTED_ERROR,
668
+ CliFailureStage.BASELINE_WRITE,
669
+ SafeErrorCode.BASELINE_WRITE_FAILED,
670
+ )
671
+ typer.echo(f"baseline.write_failed: Could not write the baseline: {exc}", err=True)
672
+ raise typer.Exit(1) from exc
673
+ if telemetry is not None:
674
+ telemetry.baseline_artifact = ArtifactOutcome.WRITTEN
675
+ telemetry.artifact_write_ms = max(
676
+ 0,
677
+ round((time.monotonic() - artifact_started) * 1000),
678
+ )
679
+ if json_output:
680
+ typer.echo(baseline.model_dump_json(indent=2, by_alias=True))
681
+ else:
682
+ _print_profile_summary(
683
+ baseline,
684
+ path,
685
+ )
686
+
687
+
688
+ @app.command()
689
+ def monitor(
690
+ profile: str | None = typer.Option(None, "--profile", help="Connection profile to use."),
691
+ host: str = typer.Option("127.0.0.1", "--host", help="Metrics server host."),
692
+ port: int = typer.Option(9100, "--port", min=1, max=65535, help="Metrics server port."),
693
+ interval: int = typer.Option(
694
+ 15,
695
+ "--interval",
696
+ min=1,
697
+ help="Seconds between database health checks.",
698
+ ),
699
+ ) -> None:
700
+ """Expose connected-database health metrics until interrupted."""
701
+ from graphcheck.errors import GraphCheckError
702
+
703
+ client: Neo4jClient | None = None
704
+ try:
705
+ root = find_project_root()
706
+ profiles = load_profiles(root)
707
+ _, selected = select_profile(profiles, profile)
708
+ client = Neo4jClient(selected)
709
+ display_host = "localhost" if host == "0.0.0.0" else host
710
+
711
+ typer.echo("Starting GraphCheck monitoring...")
712
+ typer.echo(f"Metrics endpoint: http://{display_host}:{port}/metrics")
713
+ typer.echo(f"Health check interval: {interval} seconds")
714
+ typer.echo("Press Ctrl+C to stop monitoring.")
715
+
716
+ try:
717
+ run_monitor(
718
+ client=client,
719
+ interval_seconds=interval,
720
+ host=host,
721
+ port=port,
722
+ )
723
+ except OSError:
724
+ typer.echo("Unable to start GraphCheck monitoring.", err=True)
725
+ typer.echo(
726
+ f"Failed to start metrics server on {display_host}:{port}.",
727
+ err=True,
728
+ )
729
+ typer.echo("The port may already be in use.", err=True)
730
+ raise typer.Exit(1) from None
731
+ except GraphCheckError as exc:
732
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
733
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
734
+ raise typer.Exit(1) from exc
735
+ finally:
736
+ if client is not None:
737
+ client.close()
738
+
739
+
740
+ @app.command()
741
+ @_telemetry_command(CommandName.GENERATE)
742
+ def generate(
743
+ from_: Annotated[
744
+ Path | None,
745
+ typer.Option("--from", help="Baseline JSON; defaults to the latest valid snapshot."),
746
+ ] = None,
747
+ docs: Annotated[
748
+ list[Path] | None,
749
+ typer.Option(
750
+ "--docs",
751
+ help="UTF-8 domain document sent verbatim. Repeat for multiple files.",
752
+ ),
753
+ ] = None,
754
+ count: Annotated[
755
+ int,
756
+ typer.Option("--count", min=1, max=20, help="Approximate number of checks to propose."),
757
+ ] = 5,
758
+ json_output: Annotated[
759
+ bool,
760
+ typer.Option("--json", help="Emit a stable machine-readable result."),
761
+ ] = False,
762
+ ) -> None:
763
+ """Generate non-deterministic, inert check suggestions for human review."""
764
+ from graphcheck.errors import GraphCheckError
765
+ from graphcheck.generation.service import GenerationStage
766
+
767
+ telemetry = _command_telemetry()
768
+ current_stage = CliFailureStage.PROJECT_DISCOVERY
769
+ artifact_started: float | None = None
770
+
771
+ def observe_stage(stage: GenerationStage) -> None:
772
+ nonlocal artifact_started, current_stage
773
+ current_stage = CliFailureStage(stage.value)
774
+ if stage is GenerationStage.ARTIFACT_WRITE:
775
+ artifact_started = time.monotonic()
776
+
777
+ def mark_generated_artifact(outcome: ArtifactOutcome) -> None:
778
+ if telemetry is not None and artifact_started is not None:
779
+ telemetry.mark_generated_artifact(artifact_started, outcome)
780
+
781
+ def disclose(event: "GenerateDisclosure") -> None:
782
+ if json_output:
783
+ typer.echo(
784
+ json.dumps(event.as_json(), separators=(",", ":"), ensure_ascii=False),
785
+ err=True,
786
+ )
787
+ else:
788
+ typer.echo(event.render_human(), err=True)
789
+
790
+ def warn(candidate: "DroppedCandidate") -> None:
791
+ if not json_output:
792
+ typer.echo(
793
+ f"Warning [{candidate.code}] {candidate.candidate}: {candidate.reason}",
794
+ err=True,
795
+ )
796
+
797
+ try:
798
+ root = find_project_root()
799
+ current_stage = CliFailureStage.CONFIG_LOAD
800
+ result = generation_service_factory().generate(
801
+ project_root=root,
802
+ baseline_from=from_,
803
+ document_paths=docs,
804
+ requested_count=count,
805
+ disclosure_sink=disclose,
806
+ warning_sink=warn,
807
+ invocation_dir=Path.cwd(),
808
+ stage_observer=observe_stage,
809
+ )
810
+ except GraphCheckError as exc:
811
+ if exc.error.code.startswith("generate.write_"):
812
+ mark_generated_artifact(ArtifactOutcome.ERROR)
813
+ if telemetry is not None:
814
+ telemetry.fail(
815
+ ProcessOutcome.USER_ERROR,
816
+ _cli_stage_for_error(exc.error.code),
817
+ exc.error.code,
818
+ )
819
+ if json_output:
820
+ typer.echo(
821
+ json.dumps(
822
+ {
823
+ "command": "generate",
824
+ "status": "error",
825
+ "error": exc.error.model_dump(mode="json"),
826
+ },
827
+ separators=(",", ":"),
828
+ ensure_ascii=False,
829
+ )
830
+ )
831
+ else:
832
+ typer.echo(f"Error [{exc.error.code}]: {exc.error.message}", err=True)
833
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
834
+ raise typer.Exit(1) from exc
835
+ except Exception:
836
+ if current_stage is CliFailureStage.ARTIFACT_WRITE:
837
+ mark_generated_artifact(ArtifactOutcome.ERROR)
838
+ if telemetry is not None:
839
+ telemetry.fail(
840
+ ProcessOutcome.UNEXPECTED_ERROR,
841
+ current_stage,
842
+ SafeErrorCode.UNKNOWN,
843
+ )
844
+ raise
845
+
846
+ mark_generated_artifact(ArtifactOutcome.WRITTEN)
847
+ _render_generate_result(result, json_output=json_output)
848
+
849
+
850
+ def _render_generate_result(result: "GenerateResult", *, json_output: bool) -> None:
851
+ if json_output:
852
+ typer.echo(
853
+ json.dumps(
854
+ result.model_dump(mode="json"),
855
+ separators=(",", ":"),
856
+ ensure_ascii=False,
857
+ )
858
+ )
859
+ return
860
+ dropped = f" ({result.dropped} dropped)" if result.dropped else ""
861
+ typer.echo(
862
+ f"Wrote {result.written} generated checks to {result.path}{dropped}; "
863
+ "review them and remove generated: true to activate."
864
+ )
865
+
866
+
867
+ @app.command()
868
+ @_telemetry_command(CommandName.REPORT)
869
+ def report(
870
+ report_id: str | None = typer.Argument(
871
+ None,
872
+ metavar="[ID]",
873
+ help="Historical run ID to open; valid only with --open.",
874
+ ),
875
+ open_report: bool = typer.Option(
876
+ False,
877
+ "--open",
878
+ help="Open the latest report, or the selected ID when one is provided.",
879
+ ),
880
+ list_reports: bool = typer.Option(
881
+ False,
882
+ "--list",
883
+ help="List report history with timestamps, scores, and statuses.",
884
+ ),
885
+ compare: tuple[str, str] | None = typer.Option(
886
+ None,
887
+ "--compare",
888
+ metavar="RUN1 RUN2",
889
+ help="Compare the check outcomes in two historical reports.",
890
+ ),
891
+ prune: bool = typer.Option(
892
+ False,
893
+ "--prune",
894
+ help="Remove old historical report artifacts.",
895
+ ),
896
+ keep: int | None = typer.Option(
897
+ None,
898
+ "--keep",
899
+ metavar="COUNT",
900
+ help="Number of newest historical runs to retain with --prune.",
901
+ ),
902
+ failures_only: bool = typer.Option(
903
+ False,
904
+ "--failures-only",
905
+ help="Write a report containing only failures, warnings, and errors.",
906
+ ),
907
+ ) -> None:
908
+ """Open, list, compare, prune, or filter generated reports."""
909
+ telemetry = _command_telemetry()
910
+ if telemetry is not None:
911
+ telemetry.set_action(
912
+ _report_action(
913
+ open_report=open_report,
914
+ list_reports=list_reports,
915
+ compare=compare,
916
+ prune=prune,
917
+ failures_only=failures_only,
918
+ )
919
+ )
920
+ _validate_report_options(
921
+ open_report=open_report,
922
+ report_id=report_id,
923
+ list_reports=list_reports,
924
+ compare=compare,
925
+ prune=prune,
926
+ keep=keep,
927
+ failures_only=failures_only,
928
+ )
929
+ if not any((open_report, list_reports, compare, prune, failures_only)):
930
+ _print_report_command_help()
931
+ return
932
+
933
+ from graphcheck.contracts.results import Verdict
934
+ from graphcheck.errors import GraphCheckError
935
+ from graphcheck.project import load_project_config
936
+ from graphcheck.reporting.history import (
937
+ ReportHistoryError,
938
+ discover_report_runs,
939
+ find_report_run,
940
+ format_report_comparison,
941
+ format_report_history,
942
+ prune_report_runs,
943
+ )
944
+
945
+ try:
946
+ root = find_project_root()
947
+ config = load_project_config(root)
948
+ except GraphCheckError as exc:
949
+ if (telemetry := _command_telemetry()) is not None:
950
+ telemetry.fail(
951
+ ProcessOutcome.USER_ERROR,
952
+ _cli_stage_for_error(exc.error.code),
953
+ exc.error.code,
954
+ )
955
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
956
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
957
+ raise typer.Exit(1) from exc
958
+
959
+ runs_dir = root / config.artifacts / "runs"
960
+ try:
961
+ if list_reports:
962
+ typer.echo(format_report_history(discover_report_runs(runs_dir)))
963
+ return
964
+
965
+ if compare is not None:
966
+ records = discover_report_runs(runs_dir)
967
+ first = find_report_run(records, compare[0])
968
+ second = find_report_run(records, compare[1])
969
+ typer.echo(format_report_comparison(first, second))
970
+ return
971
+
972
+ if prune:
973
+ assert keep is not None
974
+ removed = prune_report_runs(runs_dir, keep)
975
+ if not removed:
976
+ typer.echo(f"No historical report runs needed pruning; keeping newest {keep}.")
977
+ return
978
+ typer.echo(f"Pruned {len(removed)} historical report run(s):")
979
+ for record in removed:
980
+ typer.echo(f" {record.id}")
981
+ return
982
+
983
+ if failures_only:
984
+ records = discover_report_runs(runs_dir)
985
+ record = find_report_run(records, report_id) if report_id else _latest_run(records)
986
+ output = record.directory / "report.failures.html"
987
+ render_started = time.monotonic()
988
+ try:
989
+ write_html_report(
990
+ record.results,
991
+ output,
992
+ verdicts={Verdict.FAIL, Verdict.WARN, Verdict.ERRORED},
993
+ )
994
+ except OSError as exc:
995
+ if telemetry is not None:
996
+ telemetry.render_ms = max(0, round((time.monotonic() - render_started) * 1000))
997
+ telemetry.report_artifact = ArtifactOutcome.ERROR
998
+ telemetry.fail(
999
+ ProcessOutcome.UNEXPECTED_ERROR,
1000
+ CliFailureStage.REPORT_RENDER,
1001
+ SafeErrorCode.REPORT_RENDER_FAILED,
1002
+ )
1003
+ typer.echo("report.error: failed to render the requested report", err=True)
1004
+ raise typer.Exit(1) from exc
1005
+ if telemetry is not None:
1006
+ telemetry.render_ms = max(0, round((time.monotonic() - render_started) * 1000))
1007
+ telemetry.report_artifact = ArtifactOutcome.WRITTEN
1008
+ typer.secho(f"Wrote {output}", fg=typer.colors.GREEN)
1009
+ if open_report:
1010
+ _open_html_report(output)
1011
+ return
1012
+
1013
+ if open_report:
1014
+ records = discover_report_runs(runs_dir)
1015
+ if records:
1016
+ record = (
1017
+ find_report_run(records, report_id) if report_id is not None else records[0]
1018
+ )
1019
+ open_report_explorer(
1020
+ runs_dir,
1021
+ record.id,
1022
+ opener=webbrowser.open,
1023
+ on_open=lambda _: typer.echo(
1024
+ f"Opened report explorer for {record.id}. "
1025
+ "Keep this terminal open; press Ctrl+C to stop."
1026
+ ),
1027
+ )
1028
+ elif report_id is not None:
1029
+ find_report_run(records, report_id)
1030
+ else:
1031
+ _open_html_report(_latest_html_report(runs_dir))
1032
+ except ReportHistoryError as exc:
1033
+ if telemetry is not None:
1034
+ render_failed = (
1035
+ failures_only and telemetry.report_artifact is not ArtifactOutcome.WRITTEN
1036
+ )
1037
+ if render_failed:
1038
+ telemetry.report_artifact = ArtifactOutcome.ERROR
1039
+ stage = (
1040
+ CliFailureStage.REPORT_RENDER
1041
+ if render_failed or not open_report
1042
+ else CliFailureStage.REPORT_OPEN
1043
+ )
1044
+ code = (
1045
+ SafeErrorCode.REPORT_RENDER_FAILED
1046
+ if stage is CliFailureStage.REPORT_RENDER
1047
+ else SafeErrorCode.REPORT_OPEN_FAILED
1048
+ )
1049
+ telemetry.fail(ProcessOutcome.USER_ERROR, stage, code)
1050
+ typer.echo(f"report.error: {exc}", err=True)
1051
+ raise typer.Exit(1) from exc
1052
+
1053
+
1054
+ @baseline_app.command("set")
1055
+ @_telemetry_command(CommandName.BASELINE)
1056
+ def baseline_set(
1057
+ filename: str | None = typer.Argument(
1058
+ None,
1059
+ help="Timestamped baseline filename to activate; defaults to the newest snapshot.",
1060
+ ),
1061
+ ) -> None:
1062
+ """Select an existing snapshot as the active baseline."""
1063
+ from graphcheck.baselines import set_current_baseline
1064
+ from graphcheck.errors import GraphCheckError
1065
+
1066
+ telemetry = _command_telemetry()
1067
+ if telemetry is not None:
1068
+ telemetry.set_action(CommandAction.SET)
1069
+ try:
1070
+ selected = set_current_baseline(filename)
1071
+ except GraphCheckError as exc:
1072
+ if telemetry is not None:
1073
+ telemetry.fail(
1074
+ ProcessOutcome.USER_ERROR,
1075
+ CliFailureStage.BASELINE_LOAD,
1076
+ exc.error.code,
1077
+ )
1078
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
1079
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
1080
+ raise typer.Exit(1) from exc
1081
+ typer.secho(f"Baseline set to {selected.name}", fg=typer.colors.GREEN)
1082
+
1083
+
1084
+ @app.command("diff")
1085
+ @_telemetry_command(CommandName.DIFF)
1086
+ def diff_command(
1087
+ current_baseline_name: str | None = typer.Argument(
1088
+ None,
1089
+ help="Current Baseline filename or path.",
1090
+ ),
1091
+ latest_baseline_name: str | None = typer.Argument(
1092
+ None,
1093
+ help="Latest Baseline filename or path.",
1094
+ ),
1095
+ json_output: bool = typer.Option(False, "--json", help="Emit the structured diff as JSON."),
1096
+ ) -> None:
1097
+ """Compare two stored baseline snapshots."""
1098
+ from pydantic import ValidationError
1099
+
1100
+ from graphcheck.contracts.profile import BaselineProfile, ProfileStatus
1101
+ from graphcheck.diff import SchemaVersionMismatch, render_human, render_json
1102
+ from graphcheck.errors import GraphCheckError
1103
+
1104
+ telemetry = _command_telemetry()
1105
+ try:
1106
+ current_baseline_path, latest_baseline_path = resolve_diff_baselines(
1107
+ current_baseline_name,
1108
+ latest_baseline_name,
1109
+ )
1110
+ current_raw = current_baseline_path.read_text(encoding="utf-8")
1111
+ latest_raw = latest_baseline_path.read_text(encoding="utf-8")
1112
+ current_data = json.loads(current_raw)
1113
+ latest_data = json.loads(latest_raw)
1114
+ if not isinstance(current_data, dict) or not isinstance(latest_data, dict):
1115
+ raise GraphCheckError(
1116
+ "baseline.invalid",
1117
+ "Baseline JSON root must be an object.",
1118
+ "Choose a valid baseline snapshot generated by `graphcheck profile`.",
1119
+ )
1120
+ if current_data.get("schema_version") != latest_data.get("schema_version"):
1121
+ raise SchemaVersionMismatch(
1122
+ "cannot diff baselines with different schema_version "
1123
+ f"(a={current_data.get('schema_version')}, "
1124
+ f"b={latest_data.get('schema_version')})"
1125
+ )
1126
+ current_baseline = BaselineProfile.model_validate_json(current_raw)
1127
+ latest_baseline = BaselineProfile.model_validate_json(latest_raw)
1128
+ if (
1129
+ current_baseline.status is ProfileStatus.PARTIAL
1130
+ or latest_baseline.status is ProfileStatus.PARTIAL
1131
+ ):
1132
+ raise GraphCheckError(
1133
+ "diff.partial_baseline",
1134
+ "Comparison is inconclusive because one or more baselines are partial.",
1135
+ "Generate complete baseline profiles before running `graphcheck diff`.",
1136
+ )
1137
+ except GraphCheckError as exc:
1138
+ if telemetry is not None:
1139
+ telemetry.fail(
1140
+ ProcessOutcome.USER_ERROR,
1141
+ CliFailureStage.BASELINE_LOAD,
1142
+ exc.error.code,
1143
+ )
1144
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
1145
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
1146
+ raise typer.Exit(2) from exc
1147
+ except SchemaVersionMismatch as exc:
1148
+ if telemetry is not None:
1149
+ telemetry.fail(
1150
+ ProcessOutcome.USER_ERROR,
1151
+ CliFailureStage.DIFF_COMPARE,
1152
+ SafeErrorCode.DIFF_INCOMPARABLE,
1153
+ )
1154
+ typer.echo(f"error: {exc}", err=True)
1155
+ raise typer.Exit(2) from exc
1156
+ except (OSError, ValidationError, json.JSONDecodeError) as exc:
1157
+ if telemetry is not None:
1158
+ telemetry.fail(
1159
+ ProcessOutcome.USER_ERROR,
1160
+ CliFailureStage.BASELINE_LOAD,
1161
+ (
1162
+ SafeErrorCode.BASELINE_LOAD_FAILED
1163
+ if isinstance(exc, OSError)
1164
+ else SafeErrorCode.BASELINE_INVALID
1165
+ ),
1166
+ )
1167
+ typer.echo(f"error: unable to read baseline: {exc}", err=True)
1168
+ raise typer.Exit(2) from exc
1169
+
1170
+ if _target_identity(current_baseline.target) != _target_identity(latest_baseline.target):
1171
+ if json_output:
1172
+ if telemetry is not None:
1173
+ telemetry.fail(
1174
+ ProcessOutcome.USER_ERROR,
1175
+ CliFailureStage.DIFF_COMPARE,
1176
+ SafeErrorCode.DIFF_INCOMPARABLE,
1177
+ )
1178
+ typer.echo(
1179
+ "error: cannot diff baselines from different databases "
1180
+ f"(a={current_baseline.target.database}, b={latest_baseline.target.database})",
1181
+ err=True,
1182
+ )
1183
+ raise typer.Exit(2)
1184
+ _print_target_identity_warning(current_baseline, latest_baseline)
1185
+ if not typer.confirm("Do you want to continue?", default=False):
1186
+ typer.secho("Diff cancelled by user.", fg=typer.colors.YELLOW)
1187
+ return
1188
+
1189
+ try:
1190
+ report = compare_baselines(current_baseline, latest_baseline)
1191
+ except GraphCheckError as exc:
1192
+ if telemetry is not None:
1193
+ telemetry.fail(
1194
+ ProcessOutcome.USER_ERROR,
1195
+ CliFailureStage.DIFF_COMPARE,
1196
+ exc.error.code,
1197
+ )
1198
+ typer.echo(f"{exc.error.code}: {exc.error.message}", err=True)
1199
+ typer.echo(f"Fix: {exc.error.fix}", err=True)
1200
+ raise typer.Exit(2) from exc
1201
+ except SchemaVersionMismatch as exc:
1202
+ if telemetry is not None:
1203
+ telemetry.fail(
1204
+ ProcessOutcome.USER_ERROR,
1205
+ CliFailureStage.DIFF_COMPARE,
1206
+ SafeErrorCode.DIFF_INCOMPARABLE,
1207
+ )
1208
+ typer.echo(f"error: {exc}", err=True)
1209
+ raise typer.Exit(2) from exc
1210
+ except Exception:
1211
+ if telemetry is not None:
1212
+ telemetry.fail(
1213
+ ProcessOutcome.UNEXPECTED_ERROR,
1214
+ CliFailureStage.DIFF_COMPARE,
1215
+ SafeErrorCode.DIFF_FAILED,
1216
+ )
1217
+ raise
1218
+ if isinstance(report, list): # Compatibility with the original line-oriented hook.
1219
+ if not report:
1220
+ typer.secho("No drift detected.", fg=typer.colors.GREEN, bold=True)
1221
+ return
1222
+ typer.secho("Graph drift detected.\n", fg=typer.colors.YELLOW, bold=True)
1223
+ for message in report:
1224
+ typer.echo(message)
1225
+ return
1226
+ report = replace(
1227
+ report,
1228
+ baseline_a=current_baseline_path.name,
1229
+ baseline_b=latest_baseline_path.name,
1230
+ )
1231
+ typer.echo(render_json(report) if json_output else render_human(report))
1232
+ if report.drift_detected:
1233
+ raise typer.Exit(1)
1234
+
1235
+
1236
+ def _target_identity(target: "RunTarget") -> str:
1237
+ return target.database
1238
+
1239
+
1240
+ def _target_identity_json(target: "RunTarget") -> dict[str, str]:
1241
+ return {"database": _target_identity(target)}
1242
+
1243
+
1244
+ def _print_target_identity_warning(
1245
+ current_baseline: "BaselineProfile",
1246
+ latest_baseline: "BaselineProfile",
1247
+ ) -> None:
1248
+ typer.secho("WARNING", fg=typer.colors.YELLOW, bold=True)
1249
+ typer.echo()
1250
+ typer.echo("The selected baseline snapshots belong to different database / target identities.")
1251
+ typer.echo()
1252
+ typer.echo("Current Baseline")
1253
+ typer.echo(json.dumps(_target_identity_json(current_baseline.target), indent=2, sort_keys=True))
1254
+ typer.echo()
1255
+ typer.echo("Latest Baseline")
1256
+ typer.echo(json.dumps(_target_identity_json(latest_baseline.target), indent=2, sort_keys=True))
1257
+ typer.echo()
1258
+ typer.echo(
1259
+ "Comparing baseline snapshots from different databases or targets may produce "
1260
+ "misleading drift results."
1261
+ )
1262
+
1263
+
1264
+ def _print_profile_summary(
1265
+ baseline: "BaselineProfile",
1266
+ baseline_path: Path,
1267
+ ) -> None:
1268
+ from graphcheck.contracts.profile import ProfileStatus
1269
+
1270
+ if baseline.status is ProfileStatus.PARTIAL:
1271
+ typer.secho("Profile completed with partial data.", fg=typer.colors.YELLOW, bold=True)
1272
+ typer.echo()
1273
+ typer.echo(f"Status: {baseline.status}")
1274
+ typer.echo(f"Reason: {baseline.partial_reason}")
1275
+ typer.echo(
1276
+ f"Collected: {baseline.statistics.node_count} nodes, "
1277
+ f"{baseline.statistics.relationship_count} relationships"
1278
+ )
1279
+ typer.echo()
1280
+ typer.echo(f"Baseline written to:\n{baseline_path}")
1281
+ return
1282
+
1283
+ typer.secho("Profile completed.", fg=typer.colors.GREEN, bold=True)
1284
+ typer.echo()
1285
+
1286
+ typer.echo(f"Status: {baseline.status}")
1287
+
1288
+ if baseline.partial_reason:
1289
+ typer.echo(f"Reason: {baseline.partial_reason}")
1290
+
1291
+ typer.echo()
1292
+
1293
+ typer.echo(f"Nodes: {baseline.statistics.node_count}")
1294
+ typer.echo(f"Relationships: {baseline.statistics.relationship_count}")
1295
+
1296
+ typer.echo()
1297
+
1298
+ typer.echo(f"Labels: {len(baseline.graph_schema.labels)}")
1299
+ typer.echo(f"Relationship Types: {len(baseline.graph_schema.relationship_types)}")
1300
+ typer.echo(f"Constraints: {len(baseline.graph_schema.constraints)}")
1301
+ typer.echo(f"Indexes: {len(baseline.graph_schema.indexes)}")
1302
+
1303
+ typer.echo()
1304
+
1305
+ typer.echo("Degree Distribution:")
1306
+ for label in baseline.graph_schema.labels:
1307
+ distribution = label.degree_distribution
1308
+ if distribution is None:
1309
+ typer.echo(f" {label.name}: unavailable")
1310
+ else:
1311
+ typer.echo(
1312
+ f" {label.name}: median={distribution.median}, p95={distribution.p95}, "
1313
+ f"p99={distribution.p99}, maximum={distribution.maximum}"
1314
+ )
1315
+
1316
+ typer.echo()
1317
+
1318
+ typer.echo("Property Coverage:")
1319
+ for coverage in baseline.statistics.property_coverage:
1320
+ typer.echo(
1321
+ f" {coverage.owner_name}.{coverage.property} ({coverage.owner}): {coverage.coverage}%"
1322
+ )
1323
+
1324
+ typer.echo()
1325
+
1326
+ typer.echo(f"Baseline written to:\n{baseline_path}")
1327
+
1328
+
1329
+ def _validate_report_options(
1330
+ *,
1331
+ open_report: bool,
1332
+ report_id: str | None,
1333
+ list_reports: bool,
1334
+ compare: tuple[str, str] | None,
1335
+ prune: bool,
1336
+ keep: int | None,
1337
+ failures_only: bool,
1338
+ ) -> None:
1339
+ if report_id is not None and not open_report:
1340
+ _report_usage_error("A report ID requires --open.")
1341
+ if keep is not None and not prune:
1342
+ _report_usage_error("--keep requires --prune.")
1343
+ if prune and keep is None:
1344
+ _report_usage_error("--prune requires --keep COUNT.")
1345
+ if keep is not None and keep < 1:
1346
+ _report_usage_error("--keep must be at least 1.")
1347
+
1348
+ standalone = sum((list_reports, compare is not None, prune))
1349
+ selection_actions = open_report or failures_only
1350
+ if standalone > 1 or (standalone and selection_actions):
1351
+ _report_usage_error(
1352
+ "--list, --compare, and --prune are standalone actions and cannot be combined "
1353
+ "with other report actions."
1354
+ )
1355
+
1356
+
1357
+ def _report_usage_error(message: str) -> None:
1358
+ if (telemetry := _command_telemetry()) is not None:
1359
+ telemetry.fail(
1360
+ ProcessOutcome.USER_ERROR,
1361
+ CliFailureStage.CONFIG_LOAD,
1362
+ SafeErrorCode.CONFIG_INVALID,
1363
+ )
1364
+ typer.echo(f"report.usage: {message}", err=True)
1365
+ raise typer.Exit(2)
1366
+
1367
+
1368
+ def _report_action(
1369
+ *,
1370
+ open_report: bool,
1371
+ list_reports: bool,
1372
+ compare: tuple[str, str] | None,
1373
+ prune: bool,
1374
+ failures_only: bool,
1375
+ ) -> CommandAction | None:
1376
+ if list_reports:
1377
+ return CommandAction.LIST
1378
+ if compare is not None:
1379
+ return CommandAction.COMPARE
1380
+ if prune:
1381
+ return CommandAction.PRUNE
1382
+ if failures_only:
1383
+ return CommandAction.FAILURES_ONLY
1384
+ if open_report:
1385
+ return CommandAction.OPEN
1386
+ return None
1387
+
1388
+
1389
+ def _cli_stage_for_error(code: str) -> CliFailureStage:
1390
+ if code == "project.missing":
1391
+ return CliFailureStage.PROJECT_DISCOVERY
1392
+ if code.startswith("profile."):
1393
+ return CliFailureStage.PROFILE_LOAD
1394
+ if "suite" in code or "checks_" in code:
1395
+ return CliFailureStage.SUITE_LOAD
1396
+ if code.startswith("neo4j."):
1397
+ return CliFailureStage.PROBE
1398
+ if code.startswith("report."):
1399
+ return CliFailureStage.REPORT_RENDER
1400
+ if code.startswith("generate.baseline_"):
1401
+ return CliFailureStage.BASELINE_LOAD
1402
+ if code.startswith("generate.doc_"):
1403
+ return CliFailureStage.DOCUMENT_LOAD
1404
+ if code.startswith("generate.provider_"):
1405
+ return CliFailureStage.PROVIDER_REQUEST
1406
+ if code in {"generate.output_invalid", "generate.no_valid_candidates"}:
1407
+ return CliFailureStage.GENERATION_VALIDATION
1408
+ if code.startswith("generate.write_"):
1409
+ return CliFailureStage.ARTIFACT_WRITE
1410
+ return CliFailureStage.CONFIG_LOAD
1411
+
1412
+
1413
+ def _telemetry_preview_payload() -> dict[str, object]:
1414
+ from graphcheck.telemetry.policy import assert_private_payload
1415
+
1416
+ common = {
1417
+ "telemetry_schema_version": TELEMETRY_SCHEMA_VERSION,
1418
+ "consent_version": CONSENT_VERSION,
1419
+ "graphcheck_version": __version__,
1420
+ "distinct_id": "<installation-uuid>",
1421
+ "session_id": "<process-uuid>",
1422
+ "telemetry_command_id": "<command-uuid>",
1423
+ "process_person_profile": False,
1424
+ "geoip_enrichment": False,
1425
+ "$process_person_profile": False,
1426
+ "$geoip_disable": True,
1427
+ }
1428
+ events = [
1429
+ {
1430
+ "event": "graphcheck_run_started",
1431
+ "properties": {
1432
+ **common,
1433
+ "telemetry_run_id": "<run-uuid>",
1434
+ "suite_count": 1,
1435
+ "selected_check_count": 3,
1436
+ "uses_sampling": True,
1437
+ "uses_baselines": False,
1438
+ "fail_fast_enabled": False,
1439
+ },
1440
+ },
1441
+ {
1442
+ "event": "graphcheck_check_processed",
1443
+ "properties": {
1444
+ **common,
1445
+ "telemetry_run_id": "<run-uuid>",
1446
+ "check_sequence": 1,
1447
+ "pattern": "conformance",
1448
+ "template": "existence",
1449
+ "processing_outcome": "completed",
1450
+ "query_count": 1,
1451
+ },
1452
+ },
1453
+ {
1454
+ "event": "graphcheck_run_completed",
1455
+ "properties": {
1456
+ **common,
1457
+ "telemetry_run_id": "<run-uuid>",
1458
+ "terminal_kind": "finished",
1459
+ "outcome": "complete",
1460
+ "selected_check_count": 3,
1461
+ "query_count": 3,
1462
+ },
1463
+ },
1464
+ {
1465
+ "event": "graphcheck_engine_faulted",
1466
+ "properties": {
1467
+ **common,
1468
+ "telemetry_run_id": "<run-uuid>",
1469
+ "engine_stage": "finalize",
1470
+ "exception_type": "RuntimeError",
1471
+ "safe_error_code": "engine.unexpected",
1472
+ "elapsed_ms": 125,
1473
+ },
1474
+ },
1475
+ {
1476
+ "event": "graphcheck_command_completed",
1477
+ "properties": {
1478
+ **common,
1479
+ "command": "run",
1480
+ "action": None,
1481
+ "process_outcome": "success",
1482
+ "failure_stage": None,
1483
+ "telemetry_run_id": "<run-uuid>",
1484
+ "os_family": "linux",
1485
+ "os_version": "6.8",
1486
+ "python_minor": "3.12",
1487
+ },
1488
+ },
1489
+ {
1490
+ "event": "graphcheck_profile_completed",
1491
+ "properties": {
1492
+ **common,
1493
+ "outcome": "complete",
1494
+ "duration_ms": 900,
1495
+ "schema_ms": 200,
1496
+ "property_coverage_ms": 400,
1497
+ "degree_distribution_ms": 200,
1498
+ "deadline_exhausted": False,
1499
+ "last_completed_stage": "degree_distribution",
1500
+ "partial_reason": None,
1501
+ "safe_error_code": None,
1502
+ },
1503
+ },
1504
+ ]
1505
+ for event in events:
1506
+ assert_private_payload(event["properties"])
1507
+ return {"sent": False, "events": events}
1508
+
1509
+
1510
+ def _telemetry_delivery_configured() -> bool:
1511
+ from graphcheck.telemetry.posthog import telemetry_delivery_configured
1512
+
1513
+ return telemetry_delivery_configured()
1514
+
1515
+
1516
+ def _print_report_command_help() -> None:
1517
+ typer.echo(
1518
+ "Report commands:\n"
1519
+ " graphcheck report --open [ID] Open the latest or a selected report.\n"
1520
+ " graphcheck report --list List available report IDs.\n"
1521
+ " graphcheck report --compare ID1 ID2 Compare two report outcomes.\n"
1522
+ " graphcheck report --prune --keep N Retain the newest N historical runs.\n"
1523
+ " graphcheck report --failures-only Write a diagnostic-only report.\n"
1524
+ "Use `graphcheck report --help` for option details."
1525
+ )
1526
+
1527
+
1528
+ def _latest_run(records: list["ReportRun"]) -> "ReportRun":
1529
+ from graphcheck.reporting.history import ReportHistoryError
1530
+
1531
+ if not records:
1532
+ raise ReportHistoryError(
1533
+ "No results.json found in report history. Run `graphcheck run` first."
1534
+ )
1535
+ return records[0]
1536
+
1537
+
1538
+ def _latest_html_report(runs_dir: Path) -> Path:
1539
+ from graphcheck.reporting.history import ReportHistoryError
1540
+
1541
+ reports = list(runs_dir.rglob("report.html")) if runs_dir.is_dir() else []
1542
+ if not reports:
1543
+ raise ReportHistoryError(
1544
+ f"No report.html found under {runs_dir}. Run `graphcheck run` to generate one first."
1545
+ )
1546
+ return max(reports, key=lambda path: (path.stat().st_mtime_ns, str(path)))
1547
+
1548
+
1549
+ def _open_html_report(path: Path) -> None:
1550
+ from graphcheck.reporting.history import ReportHistoryError
1551
+
1552
+ if not path.is_file():
1553
+ raise ReportHistoryError(f"No report.html found for the selected run at {path}.")
1554
+ try:
1555
+ opened = webbrowser.open(path.resolve().as_uri())
1556
+ except (OSError, webbrowser.Error) as exc:
1557
+ raise ReportHistoryError(f"Could not open {path} in the default browser: {exc}") from exc
1558
+ if not opened:
1559
+ raise ReportHistoryError(f"Could not open {path} in the default browser.")
1560
+ typer.secho(f"Opened {path}", fg=typer.colors.CYAN)
1561
+
1562
+
1563
+ @mcp_app.command("serve")
1564
+ def mcp_serve() -> None:
1565
+ """Start the GraphCheck MCP server."""
1566
+ _call("graphcheck.mcp.server", "run")
1567
+
1568
+
1569
+ @app.command("run")
1570
+ @_telemetry_command(CommandName.RUN)
1571
+ def run_command(
1572
+ profile: str | None = typer.Option(None, "--profile", help="Connection profile to use."),
1573
+ select: list[str] | None = typer.Option( # noqa: B008 - Typer declaration
1574
+ None,
1575
+ "--select",
1576
+ help="Select checks by tag (for example, --select tag:production). Repeatable.",
1577
+ ),
1578
+ suite: list[str] | None = typer.Option( # noqa: B008 - Typer declaration
1579
+ None,
1580
+ "--suite",
1581
+ help="Run only this suite id. Repeatable.",
1582
+ ),
1583
+ fail_fast: bool = typer.Option(
1584
+ False,
1585
+ "--fail-fast",
1586
+ help="Stop after the first error-severity failure and mark later checks not run.",
1587
+ ),
1588
+ concurrency: int | None = typer.Option(
1589
+ None,
1590
+ "--concurrency",
1591
+ min=1,
1592
+ help="Maximum concurrent checks; overrides graphcheck.yml.",
1593
+ ),
1594
+ redacted: bool = typer.Option(
1595
+ False,
1596
+ "--redact",
1597
+ "--redacted",
1598
+ help="Mask parameter, expected, query, and evidence literal values in exported artifacts.",
1599
+ ),
1600
+ ) -> None:
1601
+ """Execute selected check suites and write machine and offline reports."""
1602
+ from graphcheck.application.run import RunRequest, execute_run
1603
+ from graphcheck.contracts.results import CheckError
1604
+ from graphcheck.engine import failed_results
1605
+ from graphcheck.errors import GraphCheckError
1606
+ from graphcheck.project import ARTIFACTS_DIR, load_project_config
1607
+
1608
+ requested_suites = list(dict.fromkeys(suite or []))
1609
+ root: Path | None = None
1610
+ runs_dir: Path | None = None
1611
+ tags: list[str] = []
1612
+ results_path: Path | None = None
1613
+ report_path: Path | None = None
1614
+ setup_started = time.monotonic()
1615
+ telemetry = _command_telemetry()
1616
+
1617
+ artifact_started = time.monotonic()
1618
+ render_times: list[int] = []
1619
+ render_failed = False
1620
+ check_count = 0
1621
+ progress_scope = ExitStack()
1622
+ run_progress_callback: Callable[[int, int, str], None] | None = None
1623
+
1624
+ def observe_render(duration_ms: int, succeeded: bool) -> None:
1625
+ nonlocal render_failed
1626
+ render_times.append(duration_ms)
1627
+ render_failed = render_failed or not succeeded
1628
+
1629
+ def display_error(error: CheckError) -> CheckError:
1630
+ # Mask the human-facing message and fix when the run is redacted.
1631
+ return (
1632
+ CheckError(code=error.code, message="[REDACTED]", fix="[REDACTED]")
1633
+ if redacted
1634
+ else error
1635
+ )
1636
+
1637
+ def export_run_artifacts(run_results, target_runs_dir, *, render_observer=None):
1638
+ # Redact parameter, expected, query, and evidence literals in the written artifacts
1639
+ # when --redact is set. The in-memory results stay unredacted so telemetry and the
1640
+ # engine result are unaffected.
1641
+ exported = redact_results(run_results) if redacted else run_results
1642
+ return _write_run_artifacts(exported, target_runs_dir, render_observer=render_observer)
1643
+
1644
+ def show_run_target(target: object) -> None:
1645
+ # The shared service calls this once the target is probed and the credential is
1646
+ # verified, before any check runs. Print the target header first (unless redacted),
1647
+ # then open the progress bar so the header always precedes progress output.
1648
+ nonlocal run_progress_callback
1649
+ if not redacted:
1650
+ _print_run_target(target)
1651
+ run_progress_callback = progress_scope.enter_context(
1652
+ _run_progress(check_count, redacted=redacted)
1653
+ )
1654
+
1655
+ def forward_progress(completed: int, total: int, check_name: str) -> None:
1656
+ if run_progress_callback is not None:
1657
+ run_progress_callback(completed, total, check_name)
1658
+
1659
+ def report_artifact_failure(exc: Exception) -> None:
1660
+ # Shared reporting for the two artifact-write failure paths. A render failure is
1661
+ # reported as a report-render failure, not an artifact-write failure, and the printed
1662
+ # cause is masked when the run is redacted.
1663
+ if telemetry is not None:
1664
+ telemetry.render_ms = sum(render_times) if render_times else None
1665
+ telemetry.mark_artifacts(
1666
+ artifact_started,
1667
+ results=ArtifactOutcome.ERROR,
1668
+ report=ArtifactOutcome.ERROR,
1669
+ exclude_ms=sum(render_times),
1670
+ )
1671
+ telemetry.fail(
1672
+ ProcessOutcome.UNEXPECTED_ERROR,
1673
+ CliFailureStage.REPORT_RENDER if render_failed else CliFailureStage.ARTIFACT_WRITE,
1674
+ SafeErrorCode.REPORT_RENDER_FAILED
1675
+ if render_failed
1676
+ else SafeErrorCode.ARTIFACT_WRITE_FAILED,
1677
+ )
1678
+ if results.run.error is not None:
1679
+ _print_setup_error(display_error(results.run.error))
1680
+ typer.secho(
1681
+ "run.artifact_failed: Could not write run artifacts: "
1682
+ f"{'[REDACTED]' if redacted else exc}",
1683
+ fg=typer.colors.RED,
1684
+ bold=True,
1685
+ err=True,
1686
+ )
1687
+ typer.secho(
1688
+ "Fix: Check the configured artifacts path and filesystem permissions.",
1689
+ fg=typer.colors.YELLOW,
1690
+ err=True,
1691
+ )
1692
+
1693
+ try:
1694
+ root = find_project_root()
1695
+ runs_dir = root / ARTIFACTS_DIR / "runs"
1696
+ config = load_project_config(root)
1697
+ artifacts = _project_path(root, config.artifacts)
1698
+ runs_dir = artifacts / "runs"
1699
+ tags = _selection_tags(select or [])
1700
+ suite_inputs = _load_suite_inputs(
1701
+ _project_path(root, config.checks),
1702
+ requested_suites,
1703
+ )
1704
+ check_count = _selected_check_count(suite_inputs, tags)
1705
+
1706
+ with progress_scope:
1707
+ outcome = execute_run(
1708
+ RunRequest(
1709
+ profile=profile,
1710
+ suite_ids=requested_suites,
1711
+ tags=tags,
1712
+ fail_fast=fail_fast,
1713
+ concurrency=concurrency,
1714
+ verify_read_only_credential=True,
1715
+ ),
1716
+ progress_callback=forward_progress,
1717
+ event_sink=telemetry.event_sink if telemetry is not None else None,
1718
+ render_observer=observe_render if telemetry is not None else None,
1719
+ client_factory=_new_neo4j_client,
1720
+ artifact_writer=export_run_artifacts,
1721
+ target_observer=show_run_target,
1722
+ )
1723
+
1724
+ results = outcome.results
1725
+ results_path = outcome.results_path
1726
+ report_path = outcome.report_path
1727
+
1728
+ # Attribute time to the real boundaries the shared service reports, not the whole
1729
+ # command: setup_ms ends once client setup completes, and artifact_write_ms starts
1730
+ # immediately before publication.
1731
+ if telemetry is not None:
1732
+ if outcome.setup_done_perf is not None:
1733
+ telemetry.setup_ms = max(0, round((outcome.setup_done_perf - setup_started) * 1000))
1734
+ elif telemetry.setup_ms is None:
1735
+ telemetry.mark_setup(setup_started)
1736
+ if outcome.artifact_started_perf is not None:
1737
+ artifact_started = outcome.artifact_started_perf
1738
+
1739
+ if outcome.artifact_error is not None:
1740
+ report_artifact_failure(outcome.artifact_error)
1741
+ raise typer.Exit(3)
1742
+
1743
+ except typer.Exit:
1744
+ raise
1745
+
1746
+ except GraphCheckError as exc:
1747
+ if telemetry is not None:
1748
+ if telemetry.setup_ms is None:
1749
+ telemetry.mark_setup(setup_started)
1750
+ telemetry.fail(
1751
+ ProcessOutcome.USER_ERROR,
1752
+ _cli_stage_for_error(exc.error.code),
1753
+ exc.error.code,
1754
+ )
1755
+ if runs_dir is None:
1756
+ _print_setup_error(display_error(exc.error))
1757
+ raise typer.Exit(3) from exc
1758
+ results = failed_results(
1759
+ exc.error,
1760
+ suite_ids=requested_suites,
1761
+ tags=tags,
1762
+ fail_fast=fail_fast,
1763
+ )
1764
+
1765
+ except Exception as exc:
1766
+ if telemetry is not None:
1767
+ if telemetry.setup_ms is None:
1768
+ telemetry.mark_setup(setup_started)
1769
+ telemetry.fail(
1770
+ (
1771
+ ProcessOutcome.ENGINE_ERROR
1772
+ if telemetry.telemetry_run_id is not None
1773
+ else ProcessOutcome.UNEXPECTED_ERROR
1774
+ ),
1775
+ (
1776
+ CliFailureStage.ENGINE
1777
+ if telemetry.telemetry_run_id is not None
1778
+ else CliFailureStage.CONFIG_LOAD
1779
+ ),
1780
+ (
1781
+ SafeErrorCode.ENGINE_UNEXPECTED
1782
+ if telemetry.telemetry_run_id is not None
1783
+ else SafeErrorCode.CONFIG_INVALID
1784
+ ),
1785
+ )
1786
+ error = CheckError(
1787
+ code="run.configuration",
1788
+ message=f"GraphCheck could not prepare the run: {type(exc).__name__}: {exc}",
1789
+ fix="Fix the project configuration, then run `graphcheck debug` and try again.",
1790
+ )
1791
+ if runs_dir is None:
1792
+ _print_setup_error(display_error(error))
1793
+ raise typer.Exit(3) from exc
1794
+ results = failed_results(
1795
+ error,
1796
+ suite_ids=requested_suites,
1797
+ tags=tags,
1798
+ fail_fast=fail_fast,
1799
+ )
1800
+
1801
+ if results_path is None:
1802
+ assert runs_dir is not None
1803
+
1804
+ artifact_started = time.monotonic()
1805
+ try:
1806
+ results_path, report_path = export_run_artifacts(
1807
+ results,
1808
+ runs_dir,
1809
+ render_observer=observe_render if telemetry is not None else None,
1810
+ )
1811
+ except Exception as artifact_exc:
1812
+ report_artifact_failure(artifact_exc)
1813
+ raise typer.Exit(3) from artifact_exc
1814
+
1815
+ if (
1816
+ results.run.status.value == "failed"
1817
+ and telemetry is not None
1818
+ and telemetry.process_outcome is ProcessOutcome.SUCCESS
1819
+ ):
1820
+ # A failed run.status here comes from the shared service converting a preflight,
1821
+ # configuration, or unexpected-engine error into a result. Classify an engine fault as
1822
+ # ENGINE / ENGINE_ERROR and everything else by its error code, so preflight failures
1823
+ # are not mislabelled as engine errors and engine faults are not mislabelled as user
1824
+ # configuration errors.
1825
+ error_code = results.run.error.code if results.run.error is not None else None
1826
+ if error_code == "engine.unexpected":
1827
+ telemetry.fail(
1828
+ ProcessOutcome.ENGINE_ERROR,
1829
+ CliFailureStage.ENGINE,
1830
+ SafeErrorCode.ENGINE_UNEXPECTED,
1831
+ )
1832
+ else:
1833
+ telemetry.fail(
1834
+ ProcessOutcome.USER_ERROR,
1835
+ _cli_stage_for_error(error_code)
1836
+ if error_code is not None
1837
+ else CliFailureStage.ENGINE,
1838
+ error_code,
1839
+ )
1840
+
1841
+ if telemetry is not None:
1842
+ telemetry.render_ms = sum(render_times)
1843
+ telemetry.mark_artifacts(
1844
+ artifact_started,
1845
+ results=ArtifactOutcome.WRITTEN,
1846
+ report=ArtifactOutcome.WRITTEN,
1847
+ exclude_ms=sum(render_times),
1848
+ )
1849
+
1850
+ exported_results = redact_results(results) if redacted else results
1851
+ _print_run_summary(
1852
+ exported_results,
1853
+ results_path,
1854
+ report_path,
1855
+ display_run_id=results.run.id,
1856
+ )
1857
+ raise typer.Exit(results.run.exit_code)
1858
+
1859
+
1860
+ @app.command("redact")
1861
+ def redact_command(
1862
+ source: Annotated[
1863
+ Path,
1864
+ typer.Argument(
1865
+ exists=True,
1866
+ readable=True,
1867
+ help="Existing results.json file or run directory to redact.",
1868
+ ),
1869
+ ],
1870
+ output: Annotated[
1871
+ Path | None,
1872
+ typer.Option(
1873
+ "--output",
1874
+ "-o",
1875
+ help="Destination file (default: results.redacted.json beside the source).",
1876
+ ),
1877
+ ] = None,
1878
+ ) -> None:
1879
+ """Create a verified, mask-redacted results export from an existing run."""
1880
+
1881
+ source_path = source / "results.json" if source.is_dir() else source
1882
+ if not source_path.is_file():
1883
+ raise typer.BadParameter(f"No results.json found at {source_path}", param_hint="source")
1884
+ redacted_name = f"{source_path.stem}.redacted{source_path.suffix}"
1885
+ destination = output or source_path.with_name(redacted_name)
1886
+ if destination.resolve() == source_path.resolve():
1887
+ raise typer.BadParameter("output must differ from source", param_hint="--output")
1888
+ redacted = redact_results(source_path.read_text(encoding="utf-8"))
1889
+ destination.parent.mkdir(parents=True, exist_ok=True)
1890
+ write_results(redacted, destination)
1891
+ typer.secho(f"Verified redacted export written to {destination}", fg=typer.colors.GREEN)
1892
+
1893
+
1894
+ def _new_neo4j_client(profile, max_concurrency: int):
1895
+ """Construct the real workload-aware client while retaining simple CLI test doubles."""
1896
+
1897
+ import inspect
1898
+
1899
+ parameters = inspect.signature(Neo4jClient).parameters.values()
1900
+ accepts_setting = any(
1901
+ parameter.name == "max_concurrency" or parameter.kind is inspect.Parameter.VAR_KEYWORD
1902
+ for parameter in parameters
1903
+ )
1904
+ return (
1905
+ Neo4jClient(profile, max_concurrency=max_concurrency)
1906
+ if accepts_setting
1907
+ else Neo4jClient(profile)
1908
+ )
1909
+
1910
+
1911
+ def _selected_check_count(suites: Sequence["SuiteInput"], tags: Sequence[str]) -> int:
1912
+ return sum(
1913
+ 1
1914
+ for suite_input in suites
1915
+ for check in suite_input.suite.checks
1916
+ if not tags or any(tag in check.tags for tag in tags)
1917
+ )
1918
+
1919
+
1920
+ def _interactive_stderr() -> bool:
1921
+ return bool(getattr(sys.stderr, "isatty", lambda: False)())
1922
+
1923
+
1924
+ def _elapsed_clock(started: float) -> str:
1925
+ minutes, seconds = divmod(max(0, int(time.monotonic() - started)), 60)
1926
+ return f"{minutes:02d}:{seconds:02d}"
1927
+
1928
+
1929
+ def _progress_template(check_name: str) -> str:
1930
+ return "%(label)s [%(bar)s] %(info)s Complete | Checking: " + check_name.replace("%", "%%")
1931
+
1932
+
1933
+ _PROGRESS_FILL_CHAR = "━"
1934
+ _PROGRESS_EMPTY_CHAR = "─"
1935
+
1936
+
1937
+ @contextmanager
1938
+ def _run_progress(
1939
+ total_checks: int,
1940
+ *,
1941
+ redacted: bool = False,
1942
+ ) -> Iterator[Callable[[int, int, str], None] | None]:
1943
+ if total_checks == 0 or not _interactive_stderr():
1944
+ yield None
1945
+ return
1946
+
1947
+ started = time.monotonic()
1948
+ state = {"check": "Preparing redacted graph checks" if redacted else "Preparing graph checks"}
1949
+ lock = threading.Lock()
1950
+ stopped = threading.Event()
1951
+ with typer.progressbar(
1952
+ length=total_checks,
1953
+ label="00:00",
1954
+ file=sys.stderr,
1955
+ show_eta=False,
1956
+ show_percent=True,
1957
+ show_pos=True,
1958
+ fill_char=typer.style(_PROGRESS_FILL_CHAR, fg=typer.colors.GREEN),
1959
+ empty_char=typer.style(_PROGRESS_EMPTY_CHAR, fg=typer.colors.BRIGHT_BLACK),
1960
+ width=28,
1961
+ color=True,
1962
+ bar_template=_progress_template(state["check"]),
1963
+ ) as bar:
1964
+
1965
+ def refresh() -> None:
1966
+ with lock:
1967
+ bar.label = _elapsed_clock(started)
1968
+ bar.bar_template = _progress_template(state["check"])
1969
+ render = getattr(bar, "render_progress", None)
1970
+ if callable(render):
1971
+ render()
1972
+
1973
+ def tick() -> None:
1974
+ while not stopped.wait(1):
1975
+ refresh()
1976
+
1977
+ ticker = threading.Thread(target=tick, name="graphcheck-progress-clock", daemon=True)
1978
+ ticker.start()
1979
+
1980
+ def update(completed: int, total: int, check_name: str) -> None:
1981
+ display_name = "redacted check" if redacted else check_name
1982
+ with lock:
1983
+ state["check"] = display_name
1984
+ bar.label = _elapsed_clock(started)
1985
+ bar.bar_template = _progress_template(display_name)
1986
+ bar.update(1)
1987
+
1988
+ try:
1989
+ yield update
1990
+ finally:
1991
+ stopped.set()
1992
+ ticker.join(timeout=1)
1993
+
1994
+
1995
+ def _selection_tags(selectors: list[str]) -> list[str]:
1996
+ from graphcheck.errors import GraphCheckError
1997
+
1998
+ tags: list[str] = []
1999
+ for selector in selectors:
2000
+ kind, separator, value = selector.partition(":")
2001
+ if separator != ":" or kind != "tag" or not value.strip():
2002
+ raise GraphCheckError(
2003
+ "run.invalid_selector",
2004
+ f"Unsupported selector {selector!r}.",
2005
+ "Use `--select tag:<name>`; repeat the option to match any selected tag.",
2006
+ )
2007
+ tag = value.strip()
2008
+ if tag not in tags:
2009
+ tags.append(tag)
2010
+ return tags
2011
+
2012
+
2013
+ def _load_suite_inputs(checks_dir: Path, requested_suites: list[str]) -> list["SuiteInput"]:
2014
+ from graphcheck.engine import SuiteInput
2015
+ from graphcheck.errors import GraphCheckError
2016
+
2017
+ if not checks_dir.is_dir():
2018
+ raise GraphCheckError(
2019
+ "run.checks_missing",
2020
+ f"Configured checks directory was not found: {checks_dir}",
2021
+ "Create the directory or fix `checks` in graphcheck.yml.",
2022
+ )
2023
+ try:
2024
+ paths = sorted(
2025
+ path
2026
+ for path in checks_dir.rglob("*")
2027
+ if path.is_file() and path.suffix.lower() in {".yml", ".yaml"}
2028
+ )
2029
+ except OSError as exc:
2030
+ raise GraphCheckError(
2031
+ "run.checks_unreadable",
2032
+ f"Could not enumerate check suites in {checks_dir}: {exc}",
2033
+ "Check the configured checks path and its filesystem permissions.",
2034
+ ) from exc
2035
+
2036
+ requested = set(requested_suites)
2037
+ loaded: list[SuiteInput] = []
2038
+ for path in paths:
2039
+ try:
2040
+ loaded.append(SuiteInput.from_yaml(path.read_text(encoding="utf-8"), source=str(path)))
2041
+ except Exception as exc:
2042
+ raise GraphCheckError(
2043
+ "run.suite_invalid",
2044
+ f"Suite {path} is invalid: {type(exc).__name__}: {exc}",
2045
+ "Fix the suite YAML and remove unknown keys, then run it again.",
2046
+ ) from exc
2047
+
2048
+ return [item for item in loaded if not requested or item.suite.suite in requested]
2049
+
2050
+
2051
+ def _project_path(root: Path, configured: str) -> Path:
2052
+ path = Path(configured)
2053
+ return path if path.is_absolute() else root / path
2054
+
2055
+
2056
+ def _write_run_artifacts(
2057
+ results: "Results",
2058
+ runs_dir: Path,
2059
+ *,
2060
+ render_observer: Callable[[int, bool], None] | None = None,
2061
+ ) -> tuple[Path, Path]:
2062
+ """Delegate to the shared artifact writer.
2063
+
2064
+ Kept as a lazy, module-level CLI entry point so the run command and the CLI tests share
2065
+ one implementation with the MCP surface while preserving fast startup.
2066
+ """
2067
+ from graphcheck.application.artifacts import write_run_artifacts
2068
+
2069
+ return write_run_artifacts(results, runs_dir, render_observer=render_observer)
2070
+
2071
+
2072
+ def _print_setup_error(error: "CheckError") -> None:
2073
+ typer.secho(f"{error.code}: {error.message}", fg=typer.colors.RED, bold=True, err=True)
2074
+ typer.secho(f"Fix: {error.fix}", fg=typer.colors.YELLOW, err=True)
2075
+
2076
+
2077
+ def _run_status_color(status: str) -> str:
2078
+ return {
2079
+ "complete": typer.colors.GREEN,
2080
+ "partial": typer.colors.MAGENTA,
2081
+ "failed": typer.colors.RED,
2082
+ }.get(status, typer.colors.WHITE)
2083
+
2084
+
2085
+ def _exit_code_color(exit_code: int) -> str:
2086
+ return {
2087
+ 0: typer.colors.GREEN,
2088
+ 1: typer.colors.RED,
2089
+ 2: typer.colors.YELLOW,
2090
+ 3: typer.colors.RED,
2091
+ }.get(exit_code, typer.colors.WHITE)
2092
+
2093
+
2094
+ def _print_run_target(target) -> None:
2095
+ if target is None:
2096
+ return
2097
+ nodes = "unavailable" if target.nodes is None else f"{target.nodes:,}"
2098
+ relationships = "unavailable" if target.relationships is None else f"{target.relationships:,}"
2099
+ typer.echo(
2100
+ f"Target: {target.database} · Neo4j {target.server_version} {target.edition} · "
2101
+ f"{nodes} nodes · {relationships} relationships"
2102
+ )
2103
+
2104
+
2105
+ def _suite_score_style(score: int | None) -> str:
2106
+ if score is None:
2107
+ return "white"
2108
+ if score == 100:
2109
+ return "green"
2110
+ return "yellow" if score >= 50 else "red"
2111
+
2112
+
2113
+ def _suite_coverage_style(evaluated: int, selected: int) -> str:
2114
+ return "green" if evaluated == selected else "yellow"
2115
+
2116
+
2117
+ def _summary_table():
2118
+ from rich import box
2119
+ from rich.table import Table
2120
+
2121
+ return Table(
2122
+ box=box.SIMPLE_HEAD,
2123
+ safe_box=False,
2124
+ show_edge=False,
2125
+ show_lines=False,
2126
+ pad_edge=False,
2127
+ collapse_padding=True,
2128
+ header_style="bold white",
2129
+ )
2130
+
2131
+
2132
+ def _suite_score_table(results: "Results"):
2133
+ from rich.text import Text
2134
+
2135
+ table = _summary_table()
2136
+ table.add_column("Suite", no_wrap=True)
2137
+ table.add_column("Score", justify="right", no_wrap=True)
2138
+ table.add_column("Check Coverage", justify="right", no_wrap=True)
2139
+ for heading in ("Passed", "Failed", "Warnings", "Errored", "Skipped"):
2140
+ table.add_column(heading, justify="right", width=len(heading), no_wrap=True)
2141
+ if not results.suites:
2142
+ table.add_row(*(Text("n/a", style="bright_black") for _ in range(8)))
2143
+ return table
2144
+ state_colors = ("green", "red", "yellow", "magenta", "bright_black")
2145
+ for suite in results.suites:
2146
+ score = "n/a" if suite.score is None else f"{suite.score}/100"
2147
+ evaluated = suite.totals.checks - suite.totals.skipped
2148
+ counts = (
2149
+ suite.totals.passed,
2150
+ suite.totals.fail,
2151
+ suite.totals.warn,
2152
+ suite.totals.errored,
2153
+ suite.totals.skipped,
2154
+ )
2155
+ table.add_row(
2156
+ Text(suite.id, style="italic"),
2157
+ Text(score, style=_suite_score_style(suite.score)),
2158
+ Text(
2159
+ f"{evaluated}/{suite.totals.checks}",
2160
+ style=_suite_coverage_style(evaluated, suite.totals.checks),
2161
+ ),
2162
+ *(
2163
+ Text(str(count), style=color) if count else Text("-")
2164
+ for count, color in zip(counts, state_colors, strict=True)
2165
+ ),
2166
+ )
2167
+ return table
2168
+
2169
+
2170
+ def _print_suite_score_table(results: "Results") -> None:
2171
+ from rich.console import Console
2172
+
2173
+ typer.echo("Score breakdown by check suite:")
2174
+ typer.echo()
2175
+ Console(highlight=False).print(_suite_score_table(results))
2176
+
2177
+
2178
+ def _result_text(presentation, *, has_not_evaluated: bool = False):
2179
+ from rich.text import Text
2180
+
2181
+ text = Text(presentation.primary_sentence)
2182
+ if has_not_evaluated:
2183
+ text.append(
2184
+ " Coverage is incomplete due to the following check(s) which have not been evaluated:"
2185
+ )
2186
+ return text
2187
+ if not presentation.coverage_incomplete:
2188
+ return text
2189
+ text.append(" Coverage is incomplete.")
2190
+ if not presentation.skipped_suites:
2191
+ return text
2192
+ text = Text(presentation.primary_sentence)
2193
+ text.append(" Coverage is incomplete due to skipped check(s) from ")
2194
+ for index, suite in enumerate(presentation.skipped_suites):
2195
+ if index:
2196
+ text.append(", ")
2197
+ text.append(suite, style="italic")
2198
+ text.append(".")
2199
+ return text
2200
+
2201
+
2202
+ def _print_result(presentation, *, has_not_evaluated: bool = False) -> None:
2203
+ from rich.console import Console
2204
+ from rich.text import Text
2205
+
2206
+ Console(highlight=False).print(
2207
+ Text("Result: ") + _result_text(presentation, has_not_evaluated=has_not_evaluated),
2208
+ soft_wrap=True,
2209
+ )
2210
+
2211
+
2212
+ def _not_evaluated_table(results: "Results"):
2213
+ from rich.text import Text
2214
+
2215
+ from graphcheck.reporting.presentation import present_check
2216
+
2217
+ table = _summary_table()
2218
+ table.add_column("Suite", no_wrap=True)
2219
+ table.add_column("Check")
2220
+ table.add_column("Reason")
2221
+ skipped = sorted(
2222
+ (check for check in results.checks if not check.executed),
2223
+ key=lambda check: (check.suite_id, check.id),
2224
+ )
2225
+ for check in skipped:
2226
+ reason = present_check(check).skip_reason
2227
+ assert reason is not None
2228
+ name = Text(check.name, style="italic")
2229
+ name.append(f" ({check.id})", style="dim")
2230
+ table.add_row(
2231
+ Text(check.suite_id, style="italic"),
2232
+ name,
2233
+ f"{reason.code}: {reason.explanation}",
2234
+ )
2235
+ return table
2236
+
2237
+
2238
+ def _print_not_evaluated(results: "Results") -> None:
2239
+ from rich.console import Console
2240
+
2241
+ if not results.totals.skipped:
2242
+ return
2243
+ typer.echo()
2244
+ Console(highlight=False).print(_not_evaluated_table(results))
2245
+ typer.echo()
2246
+
2247
+
2248
+ def _execution_error_table(results: "Results"):
2249
+ from rich.text import Text
2250
+
2251
+ table = _summary_table()
2252
+ include_fix = not results.run.redaction.applied
2253
+ table.add_column("Suite", no_wrap=True)
2254
+ table.add_column("Check")
2255
+ table.add_column("Reason")
2256
+ if include_fix:
2257
+ table.add_column("Fix")
2258
+ for check in sorted(
2259
+ (check for check in results.checks if check.error is not None),
2260
+ key=lambda check: (check.suite_id, check.id),
2261
+ ):
2262
+ name = Text(check.name, style="italic white")
2263
+ name.append(f" ({check.id})", style="dim white")
2264
+ cells = [
2265
+ Text(check.suite_id, style="italic white"),
2266
+ name,
2267
+ Text(f"{check.error.code}: {check.error.message}", style="magenta"),
2268
+ ]
2269
+ if include_fix:
2270
+ cells.append(Text(check.error.fix, style="white"))
2271
+ table.add_row(*cells)
2272
+ return table
2273
+
2274
+
2275
+ def _print_execution_errors(results: "Results") -> None:
2276
+ from rich.console import Console
2277
+
2278
+ if not results.totals.errored:
2279
+ return
2280
+ typer.echo(
2281
+ "The following check(s) have execution errors:"
2282
+ if results.run.redaction.applied
2283
+ else "The following check(s) have execution errors. Suggested fixes are provided:"
2284
+ )
2285
+ typer.echo()
2286
+ Console(highlight=False).print(_execution_error_table(results))
2287
+ typer.echo()
2288
+
2289
+
2290
+ def _print_run_summary(
2291
+ results: "Results",
2292
+ results_path: Path,
2293
+ report_path: Path,
2294
+ *,
2295
+ display_run_id: str | None = None,
2296
+ ) -> None:
2297
+ from graphcheck.reporting.history import display_run_status
2298
+ from graphcheck.reporting.presentation import present_results
2299
+
2300
+ totals = results.totals
2301
+ presentation = present_results(results)
2302
+ status = display_run_status(results).value
2303
+ run_label = "GraphCheck redacted run" if results.run.redaction.applied else "GraphCheck run"
2304
+ typer.echo(
2305
+ f"{run_label} {display_run_id or results.run.id}: "
2306
+ f"{typer.style(status, fg=_run_status_color(status), bold=True)}"
2307
+ )
2308
+ _print_suite_score_table(results)
2309
+ typer.echo()
2310
+ if results.run.partial_reason is not None:
2311
+ typer.echo(f"Partial: {results.run.partial_reason}")
2312
+ _print_result(presentation, has_not_evaluated=bool(totals.skipped))
2313
+ _print_not_evaluated(results)
2314
+ if results.run.error is not None:
2315
+ _print_setup_error(results.run.error)
2316
+ _print_execution_errors(results)
2317
+ if not any(check.measured is not None for check in results.checks) and (
2318
+ results.run.error is not None or any(check.error is not None for check in results.checks)
2319
+ ):
2320
+ typer.echo(
2321
+ "Nothing to evaluate: no selected check produced a measured result. "
2322
+ "Fix: adjust the selection or resolve the reported errors or skip reasons, then rerun."
2323
+ )
2324
+ elif (
2325
+ results.run.target is not None
2326
+ and results.run.target.nodes == 0
2327
+ and results.run.target.relationships == 0
2328
+ ):
2329
+ typer.echo(
2330
+ "Empty graph: checks were evaluated against zero nodes and relationships; "
2331
+ "load data if this was unexpected."
2332
+ )
2333
+ typer.echo(f"Results and Report saved to: {results_path.parent}")
2334
+ typer.secho(
2335
+ f"Exit code: {results.run.exit_code}",
2336
+ fg=_exit_code_color(results.run.exit_code),
2337
+ bold=True,
2338
+ )
2339
+ typer.echo()