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.
- graphcheck/__init__.py +9 -0
- graphcheck/application/__init__.py +0 -0
- graphcheck/application/artifacts.py +145 -0
- graphcheck/application/paths.py +6 -0
- graphcheck/application/run.py +233 -0
- graphcheck/application/suites.py +44 -0
- graphcheck/baselines.py +228 -0
- graphcheck/bootstrap.py +19 -0
- graphcheck/cli.py +2339 -0
- graphcheck/connection_profiles.py +119 -0
- graphcheck/contracts/__init__.py +0 -0
- graphcheck/contracts/check.py +258 -0
- graphcheck/contracts/profile.py +290 -0
- graphcheck/contracts/results.py +409 -0
- graphcheck/contracts/scalars.py +23 -0
- graphcheck/contracts/schemas.py +140 -0
- graphcheck/debug_diagnostics.py +155 -0
- graphcheck/diff.py +420 -0
- graphcheck/engine/__init__.py +64 -0
- graphcheck/engine/baseline.py +262 -0
- graphcheck/engine/compiler.py +577 -0
- graphcheck/engine/core_pack.py +681 -0
- graphcheck/engine/evaluator.py +982 -0
- graphcheck/engine/executor.py +192 -0
- graphcheck/engine/identifiers.py +25 -0
- graphcheck/engine/parameters.py +99 -0
- graphcheck/engine/pii_pack.py +318 -0
- graphcheck/engine/runner.py +1798 -0
- graphcheck/engine/sampling.py +316 -0
- graphcheck/errors.py +69 -0
- graphcheck/generation/__init__.py +1 -0
- graphcheck/generation/client.py +383 -0
- graphcheck/generation/config.py +100 -0
- graphcheck/generation/disclosure.py +115 -0
- graphcheck/generation/prompts.py +112 -0
- graphcheck/generation/proposals.py +271 -0
- graphcheck/generation/service.py +379 -0
- graphcheck/generation/transmission.py +311 -0
- graphcheck/generation/writer.py +116 -0
- graphcheck/mcp/adapter.py +133 -0
- graphcheck/mcp/server.py +62 -0
- graphcheck/neo4j_adapter.py +1431 -0
- graphcheck/observability/__init__.py +13 -0
- graphcheck/observability/collector.py +31 -0
- graphcheck/observability/health.py +69 -0
- graphcheck/observability/metrics.py +34 -0
- graphcheck/observability/runner.py +28 -0
- graphcheck/observability/server.py +18 -0
- graphcheck/packs/__init__.py +213 -0
- graphcheck/packs/catalog.py +106 -0
- graphcheck/packs/core.yml +113 -0
- graphcheck/packs/metadata.py +329 -0
- graphcheck/packs/pii.yml +77 -0
- graphcheck/profiler.py +822 -0
- graphcheck/project.py +115 -0
- graphcheck/reporting/__init__.py +41 -0
- graphcheck/reporting/explorer.py +372 -0
- graphcheck/reporting/history.py +527 -0
- graphcheck/reporting/html.py +2239 -0
- graphcheck/reporting/presentation.py +156 -0
- graphcheck/reporting/redaction.py +321 -0
- graphcheck/reporting/writer.py +97 -0
- graphcheck/scoring.py +156 -0
- graphcheck/telemetry/__init__.py +28 -0
- graphcheck/telemetry/collector.py +296 -0
- graphcheck/telemetry/consent.py +208 -0
- graphcheck/telemetry/events.py +417 -0
- graphcheck/telemetry/inactive.py +130 -0
- graphcheck/telemetry/policy.py +769 -0
- graphcheck/telemetry/posthog.py +253 -0
- graphcheck/telemetry/release.py +10 -0
- graphcheck/telemetry/runtime.py +375 -0
- graphcheck/telemetry/types.py +222 -0
- graphcheck/yaml_loader.py +43 -0
- graphcheck-0.1.0.dist-info/METADATA +187 -0
- graphcheck-0.1.0.dist-info/RECORD +79 -0
- graphcheck-0.1.0.dist-info/WHEEL +4 -0
- graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|