graphcheck 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. graphcheck/__init__.py +9 -0
  2. graphcheck/application/__init__.py +0 -0
  3. graphcheck/application/artifacts.py +145 -0
  4. graphcheck/application/paths.py +6 -0
  5. graphcheck/application/run.py +233 -0
  6. graphcheck/application/suites.py +44 -0
  7. graphcheck/baselines.py +228 -0
  8. graphcheck/bootstrap.py +19 -0
  9. graphcheck/cli.py +2339 -0
  10. graphcheck/connection_profiles.py +119 -0
  11. graphcheck/contracts/__init__.py +0 -0
  12. graphcheck/contracts/check.py +258 -0
  13. graphcheck/contracts/profile.py +290 -0
  14. graphcheck/contracts/results.py +409 -0
  15. graphcheck/contracts/scalars.py +23 -0
  16. graphcheck/contracts/schemas.py +140 -0
  17. graphcheck/debug_diagnostics.py +155 -0
  18. graphcheck/diff.py +420 -0
  19. graphcheck/engine/__init__.py +64 -0
  20. graphcheck/engine/baseline.py +262 -0
  21. graphcheck/engine/compiler.py +577 -0
  22. graphcheck/engine/core_pack.py +681 -0
  23. graphcheck/engine/evaluator.py +982 -0
  24. graphcheck/engine/executor.py +192 -0
  25. graphcheck/engine/identifiers.py +25 -0
  26. graphcheck/engine/parameters.py +99 -0
  27. graphcheck/engine/pii_pack.py +318 -0
  28. graphcheck/engine/runner.py +1798 -0
  29. graphcheck/engine/sampling.py +316 -0
  30. graphcheck/errors.py +69 -0
  31. graphcheck/generation/__init__.py +1 -0
  32. graphcheck/generation/client.py +383 -0
  33. graphcheck/generation/config.py +100 -0
  34. graphcheck/generation/disclosure.py +115 -0
  35. graphcheck/generation/prompts.py +112 -0
  36. graphcheck/generation/proposals.py +271 -0
  37. graphcheck/generation/service.py +379 -0
  38. graphcheck/generation/transmission.py +311 -0
  39. graphcheck/generation/writer.py +116 -0
  40. graphcheck/mcp/adapter.py +133 -0
  41. graphcheck/mcp/server.py +62 -0
  42. graphcheck/neo4j_adapter.py +1431 -0
  43. graphcheck/observability/__init__.py +13 -0
  44. graphcheck/observability/collector.py +31 -0
  45. graphcheck/observability/health.py +69 -0
  46. graphcheck/observability/metrics.py +34 -0
  47. graphcheck/observability/runner.py +28 -0
  48. graphcheck/observability/server.py +18 -0
  49. graphcheck/packs/__init__.py +213 -0
  50. graphcheck/packs/catalog.py +106 -0
  51. graphcheck/packs/core.yml +113 -0
  52. graphcheck/packs/metadata.py +329 -0
  53. graphcheck/packs/pii.yml +77 -0
  54. graphcheck/profiler.py +822 -0
  55. graphcheck/project.py +115 -0
  56. graphcheck/reporting/__init__.py +41 -0
  57. graphcheck/reporting/explorer.py +372 -0
  58. graphcheck/reporting/history.py +527 -0
  59. graphcheck/reporting/html.py +2239 -0
  60. graphcheck/reporting/presentation.py +156 -0
  61. graphcheck/reporting/redaction.py +321 -0
  62. graphcheck/reporting/writer.py +97 -0
  63. graphcheck/scoring.py +156 -0
  64. graphcheck/telemetry/__init__.py +28 -0
  65. graphcheck/telemetry/collector.py +296 -0
  66. graphcheck/telemetry/consent.py +208 -0
  67. graphcheck/telemetry/events.py +417 -0
  68. graphcheck/telemetry/inactive.py +130 -0
  69. graphcheck/telemetry/policy.py +769 -0
  70. graphcheck/telemetry/posthog.py +253 -0
  71. graphcheck/telemetry/release.py +10 -0
  72. graphcheck/telemetry/runtime.py +375 -0
  73. graphcheck/telemetry/types.py +222 -0
  74. graphcheck/yaml_loader.py +43 -0
  75. graphcheck-0.1.0.dist-info/METADATA +187 -0
  76. graphcheck-0.1.0.dist-info/RECORD +79 -0
  77. graphcheck-0.1.0.dist-info/WHEEL +4 -0
  78. graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
  79. graphcheck-0.1.0.dist-info/licenses/LICENSE +202 -0
graphcheck/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """GraphCheck — semantic observability for property graphs."""
2
+
3
+ # Single source of truth for the version. `pyproject.toml` declares the version as dynamic and
4
+ # reads it from here at build time (see `[tool.hatch.version]`), so the built distribution and
5
+ # `--version` always agree without a second literal or a runtime metadata lookup (the latter is
6
+ # slow enough on Windows to breach the cold-start budget for the `--version` fast path).
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from collections.abc import Callable, Iterator
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+ from filelock import FileLock
12
+
13
+ from graphcheck.contracts.results import Results
14
+
15
+ RenderObserver = Callable[[int, bool], None]
16
+ RenderedArtifacts = tuple[bytes, bytes, bytes]
17
+
18
+ # The `latest` alias is the only artifact target multiple runs contend for (historical run
19
+ # directories are unique per run id). MCP 2.0 dispatches synchronous tools through worker
20
+ # threads, so two run_suite calls can publish concurrently within one process; separate CLI
21
+ # processes can also publish at once. This in-process thread lock is shared by every code
22
+ # path that publishes `latest`.
23
+ _LATEST_PUBLISH_LOCK = threading.Lock()
24
+
25
+
26
+ @contextmanager
27
+ def latest_publication_lock(runs_dir: Path) -> Iterator[None]:
28
+ """Serialize publication of the shared `latest` alias across threads and processes.
29
+
30
+ Every writer that swaps `<runs_dir>/latest` must hold this lock so the exists/move/swap
31
+ sequence in publish_run_directory can never interleave with another publisher.
32
+ """
33
+ file_lock = FileLock(str(runs_dir / ".latest.lock"))
34
+ with _LATEST_PUBLISH_LOCK, file_lock:
35
+ yield
36
+
37
+
38
+ def render_run_artifacts(
39
+ results: Results,
40
+ *,
41
+ render_observer: RenderObserver | None = None,
42
+ ) -> RenderedArtifacts:
43
+ """Render the results.json, report.html, and summary.json bytes exactly once.
44
+
45
+ Rendering once and publishing the bytes to both the history directory and `latest`
46
+ keeps the two directories byte-identical and avoids re-rendering the HTML report twice.
47
+ """
48
+ from graphcheck.reporting.history import report_summary_json
49
+ from graphcheck.reporting.html import render_validated_html_report
50
+ from graphcheck.reporting.writer import validated_results_json
51
+
52
+ model, rendered_json = validated_results_json(results)
53
+
54
+ render_started = time.monotonic()
55
+ try:
56
+ rendered_html = render_validated_html_report(model)
57
+ except Exception:
58
+ if render_observer is not None:
59
+ render_observer(max(0, round((time.monotonic() - render_started) * 1000)), False)
60
+ raise
61
+ if render_observer is not None:
62
+ render_observer(max(0, round((time.monotonic() - render_started) * 1000)), True)
63
+
64
+ rendered_summary = report_summary_json(model)
65
+ return (
66
+ rendered_json.encode("utf-8"),
67
+ rendered_html.encode("utf-8"),
68
+ rendered_summary.encode("utf-8"),
69
+ )
70
+
71
+
72
+ def write_run_artifacts(
73
+ results: Results,
74
+ runs_dir: Path,
75
+ *,
76
+ render_observer: RenderObserver | None = None,
77
+ ) -> tuple[Path, Path]:
78
+ """Publish a run's history directory and refresh the shared `latest` alias.
79
+
80
+ This is the single artifact writer used by both `graphcheck run` and the MCP server
81
+ (through execute_run), so every surface produces identical artifacts: a report_name-based
82
+ history id, an atomically swapped results/report/summary triple, and a serialized `latest`
83
+ refresh.
84
+ """
85
+ from graphcheck.reporting.history import report_name
86
+
87
+ runs_dir.mkdir(parents=True, exist_ok=True)
88
+ resolved_runs = runs_dir.resolve()
89
+ results.run.id = report_name(results)
90
+ historical_dir = runs_dir / results.run.id
91
+ if (
92
+ historical_dir.name.casefold() == "latest"
93
+ or historical_dir.resolve().parent != resolved_runs
94
+ ):
95
+ raise ValueError(f"run id cannot be used as an artifact directory: {results.run.id!r}")
96
+
97
+ artifacts = render_run_artifacts(results, render_observer=render_observer)
98
+ publish_run_directory(artifacts, historical_dir)
99
+
100
+ latest_dir = runs_dir / "latest"
101
+ with latest_publication_lock(runs_dir):
102
+ publish_run_directory(artifacts, latest_dir)
103
+ return latest_dir / "results.json", latest_dir / "report.html"
104
+
105
+
106
+ def publish_run_directory(artifacts: RenderedArtifacts, directory: Path) -> None:
107
+ """Stage and swap a complete results/report/summary triple without exposing a mixed set."""
108
+
109
+ parent = directory.parent
110
+ parent.mkdir(parents=True, exist_ok=True)
111
+ token = uuid.uuid4().hex
112
+ staging = parent / f".{directory.name}.staging-{token}"
113
+ backup = parent / f".{directory.name}.backup-{token}"
114
+ staging.mkdir()
115
+ previous_moved = False
116
+
117
+ try:
118
+ for name, content in zip(
119
+ ("results.json", "report.html", "summary.json"), artifacts, strict=True
120
+ ):
121
+ (staging / name).write_bytes(content)
122
+
123
+ if directory.exists():
124
+ is_junction = getattr(directory, "is_junction", lambda: False)
125
+ if not directory.is_dir() or directory.is_symlink() or is_junction():
126
+ raise OSError(f"refusing to replace linked or non-directory artifact: {directory}")
127
+ directory.replace(backup)
128
+ previous_moved = True
129
+
130
+ staging.replace(directory)
131
+
132
+ except Exception:
133
+ if previous_moved and backup.exists():
134
+ if directory.exists():
135
+ shutil.rmtree(directory)
136
+ backup.replace(directory)
137
+ raise
138
+
139
+ else:
140
+ if backup.exists():
141
+ shutil.rmtree(backup)
142
+
143
+ finally:
144
+ if staging.exists():
145
+ shutil.rmtree(staging)
@@ -0,0 +1,6 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def project_path(root: Path, configured: str) -> Path:
5
+ path = Path(configured)
6
+ return path if path.is_absolute() else root / path
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import time
5
+ from collections.abc import Callable
6
+ from contextlib import suppress
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from graphcheck.application.artifacts import (
11
+ RenderObserver,
12
+ write_run_artifacts,
13
+ )
14
+ from graphcheck.application.paths import project_path
15
+ from graphcheck.application.suites import load_suite_inputs
16
+ from graphcheck.connection_profiles import (
17
+ load_profiles,
18
+ select_profile,
19
+ )
20
+ from graphcheck.contracts.results import CheckError, Results
21
+ from graphcheck.engine import (
22
+ DirectoryBaselineProvider,
23
+ Engine,
24
+ EngineConfig,
25
+ failed_results,
26
+ )
27
+ from graphcheck.errors import GraphCheckError
28
+ from graphcheck.neo4j_adapter import Neo4jClient
29
+ from graphcheck.project import (
30
+ find_project_root,
31
+ load_project_config,
32
+ )
33
+ from graphcheck.telemetry.events import EngineEventSink
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class RunRequest:
38
+ profile: str | None
39
+ suite_ids: list[str]
40
+ tags: list[str]
41
+ fail_fast: bool
42
+ concurrency: int | None = None
43
+ verify_read_only_credential: bool = False
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class RunOutcome:
48
+ results: Results
49
+ results_path: Path | None
50
+ report_path: Path | None
51
+ artifact_error: Exception | None = None
52
+ # `time.monotonic()` boundaries so a caller can attribute setup versus
53
+ # artifact-write time correctly. `setup_done_perf` is stamped once profile,
54
+ # client, credential, and suite setup finish (before the engine runs);
55
+ # `artifact_started_perf` is stamped immediately before artifacts are written.
56
+ setup_done_perf: float | None = None
57
+ artifact_started_perf: float | None = None
58
+
59
+
60
+ def execute_run(
61
+ request: RunRequest,
62
+ *,
63
+ progress_callback: Callable[[int, int, str], None] | None = None,
64
+ event_sink: EngineEventSink | None = None,
65
+ render_observer: RenderObserver | None = None,
66
+ client_factory: Callable[[object, int], Neo4jClient] | None = None,
67
+ artifact_writer: Callable[..., tuple[Path, Path]] = write_run_artifacts,
68
+ target_observer: Callable[[object], None] | None = None,
69
+ ) -> RunOutcome:
70
+ """
71
+ Execute a GraphCheck run independently of the CLI or MCP.
72
+ """
73
+ root = find_project_root()
74
+ config = load_project_config(root)
75
+ artifacts = project_path(root, config.artifacts)
76
+ runs_dir = artifacts / "runs"
77
+
78
+ checks_dir = project_path(root, config.checks)
79
+ profiles = load_profiles(root)
80
+ _, selected_profile = select_profile(
81
+ profiles,
82
+ request.profile,
83
+ )
84
+
85
+ client: Neo4jClient | None = None
86
+ setup_done_perf: float | None = None
87
+ engine_started = False
88
+
89
+ try:
90
+ max_concurrency = request.concurrency or int(config.concurrency)
91
+
92
+ factory = client_factory or _new_neo4j_client
93
+ client = factory(
94
+ selected_profile,
95
+ max_concurrency,
96
+ )
97
+
98
+ if request.verify_read_only_credential:
99
+ target = _verify_cli_audit_credential(client)
100
+ if target_observer is not None:
101
+ target_observer(target)
102
+
103
+ suite_inputs = load_suite_inputs(
104
+ checks_dir,
105
+ request.suite_ids,
106
+ )
107
+ setup_done_perf = time.monotonic()
108
+ engine = Engine(
109
+ client,
110
+ baselines=DirectoryBaselineProvider(
111
+ artifacts / "baselines",
112
+ ),
113
+ config=EngineConfig(max_concurrency=max_concurrency),
114
+ progress_callback=progress_callback,
115
+ event_sink=event_sink,
116
+ )
117
+ engine_started = True
118
+ results = engine.run(
119
+ suite_inputs,
120
+ tags=request.tags,
121
+ fail_fast=request.fail_fast,
122
+ selection_suites=request.suite_ids or None,
123
+ )
124
+
125
+ except GraphCheckError as exc:
126
+ if setup_done_perf is None:
127
+ setup_done_perf = time.monotonic()
128
+ results = failed_results(
129
+ exc.error,
130
+ suite_ids=request.suite_ids,
131
+ tags=request.tags,
132
+ fail_fast=request.fail_fast,
133
+ )
134
+
135
+ except Exception as exc:
136
+ if setup_done_perf is None:
137
+ setup_done_perf = time.monotonic()
138
+ if engine_started:
139
+ # An unexpected fault raised by Engine.run() is an engine error, not a
140
+ # configuration problem. Preserve `engine.unexpected` so the CLI reports it as
141
+ # ENGINE / ENGINE_ERROR rather than a user configuration failure.
142
+ error = CheckError(
143
+ code="engine.unexpected",
144
+ message=f"The GraphCheck engine failed unexpectedly: {type(exc).__name__}: {exc}",
145
+ fix="Re-run the check suite; if it recurs, file a bug with the run details.",
146
+ )
147
+ else:
148
+ error = CheckError(
149
+ code="run.configuration",
150
+ message=f"GraphCheck could not prepare the run: {type(exc).__name__}: {exc}",
151
+ fix="Fix the project configuration, then run `graphcheck debug` and try again.",
152
+ )
153
+ results = failed_results(
154
+ error,
155
+ suite_ids=request.suite_ids,
156
+ tags=request.tags,
157
+ fail_fast=request.fail_fast,
158
+ )
159
+
160
+ finally:
161
+ if client is not None:
162
+ with suppress(Exception):
163
+ client.close()
164
+
165
+ # Publish exactly once, outside the setup/engine exception translation above. A write
166
+ # failure must preserve the completed (or already-failed) result and surface as
167
+ # artifact_error with the real artifact-write timing boundary — never a retried write
168
+ # nor a re-labelled run.configuration result.
169
+ artifact_started_perf = time.monotonic()
170
+ try:
171
+ results_path, report_path = artifact_writer(
172
+ results,
173
+ runs_dir,
174
+ render_observer=render_observer,
175
+ )
176
+ except Exception as artifact_exc:
177
+ return RunOutcome(
178
+ results=results,
179
+ results_path=None,
180
+ report_path=None,
181
+ artifact_error=artifact_exc,
182
+ setup_done_perf=setup_done_perf,
183
+ artifact_started_perf=artifact_started_perf,
184
+ )
185
+
186
+ return RunOutcome(
187
+ results=results,
188
+ results_path=results_path,
189
+ report_path=report_path,
190
+ setup_done_perf=setup_done_perf,
191
+ artifact_started_perf=artifact_started_perf,
192
+ )
193
+
194
+
195
+ def _new_neo4j_client(profile, max_concurrency: int):
196
+ """Construct the workload-aware Neo4j client while retaining simple test doubles."""
197
+ parameters = inspect.signature(Neo4jClient).parameters.values()
198
+ accepts_setting = any(
199
+ parameter.name == "max_concurrency" or parameter.kind is inspect.Parameter.VAR_KEYWORD
200
+ for parameter in parameters
201
+ )
202
+ return (
203
+ Neo4jClient(profile, max_concurrency=max_concurrency)
204
+ if accepts_setting
205
+ else Neo4jClient(profile)
206
+ )
207
+
208
+
209
+ def _verify_cli_audit_credential(client: object) -> object | None:
210
+ """Probe the target, verify the read-only credential, and return the probed target.
211
+
212
+ The returned target carries the live node/relationship counts so a caller can render a
213
+ run header without probing the database a second time.
214
+ """
215
+ verify = getattr(client, "verify_read_only_credential", None)
216
+ probe = getattr(client, "probe", None)
217
+ result = probe() if callable(probe) else None
218
+ if callable(verify):
219
+ verify()
220
+ target = result[0] if isinstance(result, tuple) else result
221
+ if isinstance(result, tuple) and len(result) > 2 and target is not None:
222
+ counts = result[2]
223
+ copy = getattr(target, "model_copy", None)
224
+ if callable(copy):
225
+ target = copy(
226
+ update={
227
+ "nodes": getattr(counts, "nodes", getattr(target, "nodes", None)),
228
+ "relationships": getattr(
229
+ counts, "relationships", getattr(target, "relationships", None)
230
+ ),
231
+ }
232
+ )
233
+ return target
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from graphcheck.engine.runner import SuiteInput
6
+ from graphcheck.errors import GraphCheckError
7
+
8
+
9
+ def load_suite_inputs(checks_dir: Path, requested_suites: list[str]) -> list[SuiteInput]:
10
+ if not checks_dir.is_dir():
11
+ raise GraphCheckError(
12
+ "run.checks_missing",
13
+ f"Configured checks directory was not found: {checks_dir}",
14
+ "Create the directory or fix `checks` in graphcheck.yml.",
15
+ )
16
+ try:
17
+ paths = sorted(
18
+ path
19
+ for path in checks_dir.rglob("*")
20
+ if path.is_file() and path.suffix.lower() in {".yml", ".yaml"}
21
+ )
22
+ except OSError as exc:
23
+ raise GraphCheckError(
24
+ "run.checks_unreadable",
25
+ f"Could not enumerate check suites in {checks_dir}: {exc}",
26
+ "Check the configured checks path and its filesystem permissions.",
27
+ ) from exc
28
+
29
+ loaded: list[SuiteInput] = []
30
+ for path in paths:
31
+ try:
32
+ text = path.read_text(encoding="utf-8")
33
+ loaded.append(SuiteInput.from_yaml(text, source=str(path)))
34
+ except Exception as exc:
35
+ raise GraphCheckError(
36
+ "run.suite_invalid",
37
+ f"Suite {path} is invalid: {type(exc).__name__}: {exc}",
38
+ "Fix the suite YAML and remove unknown keys, then run it again.",
39
+ ) from exc
40
+
41
+ if not requested_suites:
42
+ return loaded
43
+ requested = set(requested_suites)
44
+ return [item for item in loaded if item.suite.suite in requested]
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import UTC, datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ from graphcheck.contracts.profile import BaselineProfile
9
+ from graphcheck.errors import GraphCheckError
10
+ from graphcheck.project import find_project_root
11
+
12
+ _BASELINE_NAME = re.compile(r"\d{8}T\d{6}(?:\.\d{6})?\.json")
13
+
14
+
15
+ def baseline_directory(
16
+ project_root: Path | None = None,
17
+ artifacts: str | Path = ".graphcheck",
18
+ ) -> Path:
19
+ """Return the configured timestamped-baseline directory."""
20
+
21
+ root = find_project_root() if project_root is None else project_root
22
+ if project_root is None and Path(artifacts) == Path(".graphcheck"):
23
+ artifacts = _discovered_artifacts(root)
24
+ configured = Path(artifacts)
25
+ artifacts_dir = configured if configured.is_absolute() else root / configured
26
+ return artifacts_dir / "baselines"
27
+
28
+
29
+ def current_baseline_file(
30
+ project_root: Path | None = None,
31
+ artifacts: str | Path = ".graphcheck",
32
+ ) -> Path:
33
+ root = find_project_root() if project_root is None else project_root
34
+ if project_root is None and Path(artifacts) == Path(".graphcheck"):
35
+ artifacts = _discovered_artifacts(root)
36
+ configured = Path(artifacts)
37
+ artifacts_dir = configured if configured.is_absolute() else root / configured
38
+ return artifacts_dir / "current-baseline.json"
39
+
40
+
41
+ def _discovered_artifacts(root: Path) -> str | Path:
42
+ """Use configured artifacts when a real project file exists; aid legacy callers/tests."""
43
+
44
+ from graphcheck.project import PROJECT_FILE, load_project_config
45
+
46
+ if not (root / PROJECT_FILE).is_file():
47
+ return ".graphcheck"
48
+ return load_project_config(root).artifacts
49
+
50
+
51
+ def write_baseline(
52
+ profile: BaselineProfile,
53
+ project_root: Path | None = None,
54
+ artifacts: str | Path = ".graphcheck",
55
+ ) -> Path:
56
+ baselines_dir = baseline_directory(project_root, artifacts)
57
+ baselines_dir.mkdir(parents=True, exist_ok=True)
58
+ timestamp = datetime.now(UTC)
59
+ content = profile.model_dump_json(
60
+ by_alias=True,
61
+ indent=2,
62
+ )
63
+ while True:
64
+ path = baselines_dir / f"{timestamp:%Y%m%dT%H%M%S.%f}.json"
65
+ try:
66
+ with path.open("x", encoding="utf-8") as snapshot:
67
+ snapshot.write(content)
68
+ return path
69
+ except FileExistsError:
70
+ timestamp += timedelta(microseconds=1)
71
+
72
+
73
+ def list_baselines(
74
+ project_root: Path | None = None,
75
+ artifacts: str | Path = ".graphcheck",
76
+ ) -> list[Path]:
77
+ baselines_dir = baseline_directory(project_root, artifacts)
78
+ if not baselines_dir.is_dir():
79
+ return []
80
+ return sorted(
81
+ path
82
+ for path in baselines_dir.iterdir()
83
+ if path.is_file() and _BASELINE_NAME.fullmatch(path.name)
84
+ )
85
+
86
+
87
+ def latest_baseline(
88
+ project_root: Path | None = None,
89
+ artifacts: str | Path = ".graphcheck",
90
+ ) -> Path | None:
91
+ baselines = list_baselines(project_root, artifacts)
92
+ return baselines[-1] if baselines else None
93
+
94
+
95
+ def set_current_baseline(
96
+ filename: str | None = None,
97
+ project_root: Path | None = None,
98
+ artifacts: str | Path = ".graphcheck",
99
+ ) -> Path:
100
+ baselines_dir = baseline_directory(project_root, artifacts)
101
+ baselines = list_baselines(project_root, artifacts)
102
+
103
+ if filename is None:
104
+ if not baselines:
105
+ raise GraphCheckError(
106
+ "baseline.missing",
107
+ "No timestamped baseline snapshots were found.",
108
+ "Run `graphcheck profile` to create a baseline snapshot first.",
109
+ )
110
+
111
+ # Default to the previous run.
112
+ # If only one baseline exists, use that.
113
+ selected = baselines[0] if len(baselines) == 1 else baselines[-2]
114
+
115
+ else:
116
+ selected = {path.name: path for path in baselines}.get(filename)
117
+ if selected is None:
118
+ raise GraphCheckError(
119
+ "baseline.not_found",
120
+ f"Baseline snapshot {filename!r} was not found in {baselines_dir}.",
121
+ "Choose an existing timestamped snapshot, or run `graphcheck profile`.",
122
+ )
123
+
124
+ selected_file = current_baseline_file(project_root, artifacts)
125
+ selected_file.parent.mkdir(parents=True, exist_ok=True)
126
+ selected_file.write_text(
127
+ json.dumps({"baseline": selected.name}, indent=2) + "\n",
128
+ encoding="utf-8",
129
+ )
130
+
131
+ return selected
132
+
133
+
134
+ def get_current_baseline(
135
+ project_root: Path | None = None,
136
+ artifacts: str | Path = ".graphcheck",
137
+ ) -> Path | None:
138
+ selected_file = current_baseline_file(project_root, artifacts)
139
+ if not selected_file.exists():
140
+ return None
141
+ try:
142
+ payload = json.loads(selected_file.read_text(encoding="utf-8"))
143
+ filename = payload["baseline"]
144
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
145
+ raise GraphCheckError(
146
+ "baseline.current_invalid",
147
+ f"{selected_file} is not valid baseline metadata.",
148
+ "Run `graphcheck baseline set` to select an active baseline again.",
149
+ ) from exc
150
+ if not isinstance(filename, str):
151
+ raise GraphCheckError(
152
+ "baseline.current_invalid",
153
+ f"{selected_file} is not valid baseline metadata.",
154
+ "Run `graphcheck baseline set` to select an active baseline again.",
155
+ )
156
+ baselines = list_baselines(project_root, artifacts)
157
+ selected = {path.name: path for path in baselines}.get(filename)
158
+ if selected is None:
159
+ raise GraphCheckError(
160
+ "baseline.current_missing",
161
+ f"The active baseline snapshot {filename!r} does not exist.",
162
+ "Run `graphcheck baseline set` to select an existing baseline.",
163
+ )
164
+ return selected
165
+
166
+
167
+ def resolve_diff_baselines(
168
+ current_baseline_name: str | None = None,
169
+ latest_baseline_name: str | None = None,
170
+ *,
171
+ project_root: Path | None = None,
172
+ artifacts: str | Path = ".graphcheck",
173
+ ) -> tuple[Path, Path]:
174
+ """Resolve the Current and Latest Baseline snapshots for a diff."""
175
+ if (current_baseline_name is None) != (latest_baseline_name is None):
176
+ raise GraphCheckError(
177
+ "baseline.not_found",
178
+ "Both Current Baseline and Latest Baseline must be specified together.",
179
+ "Provide two baseline snapshots, or omit both to use the defaults.",
180
+ )
181
+
182
+ if current_baseline_name is not None and latest_baseline_name is not None:
183
+ return (
184
+ _resolve_baseline_path(
185
+ current_baseline_name,
186
+ project_root=project_root,
187
+ artifacts=artifacts,
188
+ ),
189
+ _resolve_baseline_path(
190
+ latest_baseline_name,
191
+ project_root=project_root,
192
+ artifacts=artifacts,
193
+ ),
194
+ )
195
+
196
+ baselines = list_baselines(project_root, artifacts)
197
+ if len(baselines) < 2:
198
+ raise GraphCheckError(
199
+ "baseline.missing",
200
+ "At least two timestamped baseline snapshots are required for a diff.",
201
+ "Run `graphcheck profile` at least twice to create baseline snapshots.",
202
+ )
203
+
204
+ current_baseline = get_current_baseline(project_root, artifacts)
205
+ if current_baseline is None:
206
+ current_baseline = baselines[-2]
207
+ return current_baseline, baselines[-1]
208
+
209
+
210
+ def _resolve_baseline_path(
211
+ name: str,
212
+ *,
213
+ project_root: Path | None = None,
214
+ artifacts: str | Path = ".graphcheck",
215
+ ) -> Path:
216
+ requested = Path(name)
217
+ if requested.is_file():
218
+ return requested
219
+
220
+ selected = {path.name: path for path in list_baselines(project_root, artifacts)}.get(name)
221
+ if selected is not None:
222
+ return selected
223
+
224
+ raise GraphCheckError(
225
+ "baseline.not_found",
226
+ f"Baseline snapshot {name!r} was not found.",
227
+ "Choose an existing timestamped snapshot or valid baseline file path.",
228
+ )
@@ -0,0 +1,19 @@
1
+ """Lightweight console entry point with a standard-library-only version fast path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from graphcheck import __version__
8
+
9
+
10
+ def cli() -> None:
11
+ if sys.argv[1:] == ["--version"]:
12
+ print(f"graphcheck {__version__}")
13
+ return
14
+ from graphcheck.telemetry.consent import resolve_consent
15
+
16
+ consent = resolve_consent()
17
+ from graphcheck.cli import cli as typer_cli
18
+
19
+ typer_cli(consent=consent)