commitguardian 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 (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,441 @@
1
+ """``commitguard benchmark``: reproducible measurements of CommitGuard itself.
2
+
3
+ Benchmarks never need credentials and never contact the network. Results are
4
+ printed as text or JSON; ``--output`` writes the JSON document to a file and
5
+ ``--record`` adds it to a results directory as a new, immutable result.
6
+
7
+ The research package is imported inside each command so that the everyday CLI
8
+ (and the Git hooks, which run it) does not load benchmark code.
9
+
10
+ Exit codes: 0 when the benchmark ran and every correctness check held, 1 when
11
+ it ran but a correctness check failed (a detection mismatch, a hook that did not
12
+ enforce, a platform check that failed), 2 on errors.
13
+ """
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Annotated, Any
18
+
19
+ import typer
20
+
21
+ from commitguard.cli.output import ExitCode, fail, handled_errors, info
22
+
23
+ if TYPE_CHECKING:
24
+ from commitguard.research.environment import BenchmarkManifest
25
+
26
+ benchmark_app = typer.Typer(
27
+ help="Run reproducible benchmarks: detection, performance, hooks, repository, platform.",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+ JsonOption = Annotated[bool, typer.Option("--json", help="Print the result document as JSON.")]
32
+ OutputOption = Annotated[
33
+ Path | None, typer.Option("--output", help="Also write the JSON document to this file.")
34
+ ]
35
+ RecordOption = Annotated[
36
+ Path | None,
37
+ typer.Option(
38
+ "--record",
39
+ help="Add the result to this results directory (e.g. benchmarks/results).",
40
+ ),
41
+ ]
42
+ VerboseOption = Annotated[bool, typer.Option("--verbose", help="Show every mismatch or check.")]
43
+ DatasetVersionOption = Annotated[
44
+ str | None,
45
+ typer.Option("--dataset-version", help="Build this dataset version (default: the latest)."),
46
+ ]
47
+ DatasetOption = Annotated[
48
+ Path | None,
49
+ typer.Option("--dataset", help="Load the dataset from this directory instead of building it."),
50
+ ]
51
+
52
+
53
+ def emit(
54
+ benchmark: str,
55
+ manifest: "BenchmarkManifest",
56
+ result: Any,
57
+ *,
58
+ as_json: bool,
59
+ output: Path | None,
60
+ record: Path | None,
61
+ text: list[str],
62
+ ok: bool = True,
63
+ ) -> None:
64
+ from commitguard.research.results import result_document, write_result
65
+
66
+ document = result_document(benchmark, manifest, result)
67
+ rendered = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False)
68
+ if output is not None:
69
+ output.parent.mkdir(parents=True, exist_ok=True)
70
+ output.write_text(rendered + "\n", encoding="utf-8")
71
+ recorded = write_result(record, document) if record is not None else None
72
+ if as_json:
73
+ info(rendered)
74
+ else:
75
+ for line in text:
76
+ info(line)
77
+ info("")
78
+ info(
79
+ f"CommitGuard {manifest.commitguard_version} · {manifest.operating_system} "
80
+ f"{manifest.os_release} · Python {manifest.python_version} · "
81
+ f"Git {manifest.git_version or 'unknown'}"
82
+ )
83
+ if recorded is not None:
84
+ info(f"Recorded: {recorded}")
85
+ if not ok:
86
+ raise typer.Exit(ExitCode.BLOCKED)
87
+
88
+
89
+ def _dataset_file_version(directory: Path) -> str:
90
+ manifest = directory / "manifest.json"
91
+ try:
92
+ return f"{json.loads(manifest.read_text(encoding='utf-8'))['version']} (file)"
93
+ except (OSError, ValueError, KeyError):
94
+ return f"file:{directory}"
95
+
96
+
97
+ def _pct(value: float | None) -> str:
98
+ return "n/a (no cases)" if value is None else f"{value * 100:.3f}%"
99
+
100
+
101
+ def _mib(value: int | None) -> str:
102
+ return "not available on this platform" if value is None else f"{value / 1_048_576:.1f} MiB"
103
+
104
+
105
+ @benchmark_app.command("dataset")
106
+ def dataset_command(
107
+ write: Annotated[Path, typer.Option("--write", help="Directory to write the dataset to.")],
108
+ dataset_version: DatasetVersionOption = None,
109
+ ) -> None:
110
+ """Write the labelled detection dataset (JSONL per class and a manifest)."""
111
+ from commitguard.research.datasets import DATASET_VERSION, build_dataset, write_dataset
112
+
113
+ version = dataset_version or DATASET_VERSION
114
+ with handled_errors():
115
+ manifest = write_dataset(build_dataset(version=version), write, version=version)
116
+ info(json.dumps(manifest, indent=2, sort_keys=True))
117
+
118
+
119
+ @benchmark_app.command("detection")
120
+ def detection_command(
121
+ as_json: JsonOption = False,
122
+ output: OutputOption = None,
123
+ record: RecordOption = None,
124
+ verbose: VerboseOption = False,
125
+ dataset: DatasetOption = None,
126
+ dataset_version: DatasetVersionOption = None,
127
+ ) -> None:
128
+ """Detection accuracy on the labelled dataset (TP, FP, TN, FN, precision, recall)."""
129
+ from commitguard.research.datasets import (
130
+ DATASET_VERSION,
131
+ build_dataset,
132
+ fingerprint,
133
+ load_dataset,
134
+ )
135
+ from commitguard.research.detection import run_detection
136
+ from commitguard.research.environment import collect_manifest
137
+
138
+ with handled_errors():
139
+ if dataset is not None:
140
+ # This load_dataset reads local JSONL files written by
141
+ # `commitguard benchmark dataset`; nothing is downloaded.
142
+ cases = load_dataset(dataset) # nosec B615
143
+ version = _dataset_file_version(dataset)
144
+ else:
145
+ version = dataset_version or DATASET_VERSION
146
+ cases = build_dataset(version=version)
147
+ result = run_detection(cases)
148
+ manifest = collect_manifest(dataset_version=version, dataset_fingerprint=fingerprint(cases))
149
+ d, rates, latency = result.decision, result.decision_rates, result.latency
150
+ text = [
151
+ "CommitGuard detection benchmark",
152
+ "",
153
+ f"Dataset {version} ({result.cases} cases: {result.positives} must not "
154
+ f"be allowed, {result.negatives} must be allowed)",
155
+ f"True positives {d.true_positive}",
156
+ f"False negatives {d.false_negative} (violations allowed)",
157
+ f"True negatives {d.true_negative}",
158
+ f"False positives {d.false_positive} (clean commits not allowed)",
159
+ f"Precision {_pct(rates['precision'])}",
160
+ f"Recall {_pct(rates['recall'])}",
161
+ f"False positive rate {_pct(rates['false_positive_rate'])}",
162
+ f"False negative rate {_pct(rates['false_negative_rate'])}",
163
+ f"Exact decision {result.exact_decision_matches}/{result.cases}",
164
+ f"Latency per commit p50 {latency.p50_ms} ms · p95 {latency.p95_ms} ms · "
165
+ f"p99 {latency.p99_ms} ms",
166
+ "",
167
+ "By class:",
168
+ ]
169
+ for name, counts in result.by_class.items():
170
+ text.append(
171
+ f" {name:<12} {counts.get('cases', 0):>6} cases · exact "
172
+ f"{counts.get('exact_decision', 0)} · FN {counts.get('false_negative', 0)} · "
173
+ f"FP {counts.get('false_positive', 0)}"
174
+ )
175
+ if result.mismatches:
176
+ text += ["", f"Mismatches ({len(result.mismatches)}):"]
177
+ for m in result.mismatches if verbose else result.mismatches[:20]:
178
+ line = f" {m.id}: expected {m.expected_decision}, got {m.decision}"
179
+ if m.missing_rules:
180
+ line += f"; missing {', '.join(m.missing_rules)}"
181
+ if m.unexpected_rules:
182
+ line += f"; unexpected {', '.join(m.unexpected_rules)}"
183
+ text.append(line)
184
+ emit(
185
+ "detection",
186
+ manifest,
187
+ result,
188
+ as_json=as_json,
189
+ output=output,
190
+ record=record,
191
+ text=text,
192
+ ok=not result.mismatches,
193
+ )
194
+
195
+
196
+ @benchmark_app.command("performance")
197
+ def performance_command(
198
+ as_json: JsonOption = False,
199
+ output: OutputOption = None,
200
+ record: RecordOption = None,
201
+ quick: Annotated[
202
+ bool, typer.Option("--quick", help="Smaller batches and messages (smoke test).")
203
+ ] = False,
204
+ ) -> None:
205
+ """Detection latency (p50/p95/p99), message-size scaling, CPU time and memory."""
206
+ from commitguard.research.environment import collect_manifest
207
+ from commitguard.research.performance import run_performance
208
+
209
+ with handled_errors():
210
+ if quick:
211
+ result = run_performance(batch_sizes=(1, 10, 100), message_sizes=(1_024, 102_400))
212
+ else:
213
+ result = run_performance()
214
+ manifest = collect_manifest()
215
+ text = ["CommitGuard detection performance", "", f"Rule loading {result.startup_ms} ms", ""]
216
+ text.append("Commits wall ms CPU ms commits/s p50 ms p95 ms p99 ms alloc peak")
217
+ for b in result.batches:
218
+ text.append(
219
+ f"{b.commits:>7} {b.wall_ms:>9} {b.cpu_ms:>9} {b.commits_per_second:>11} "
220
+ f"{b.latency.p50_ms:>8} {b.latency.p95_ms:>8} {b.latency.p99_ms:>8} "
221
+ f"{b.python_peak_allocated_bytes / 1024:>9.0f} KiB"
222
+ )
223
+ text += ["", "Message size runs p50 ms p99 ms CPU ms/commit alloc peak decision"]
224
+ for s in result.message_sizes:
225
+ text.append(
226
+ f"{s.message_bytes:>12} {s.repetitions:>7} {s.latency.p50_ms:>8} "
227
+ f"{s.latency.p99_ms:>9} {s.cpu_ms_per_commit:>15} "
228
+ f"{s.python_peak_allocated_bytes / 1_048_576:>9.1f} MiB {s.decision}"
229
+ )
230
+ text += ["", f"Peak RSS (whole run) {_mib(result.peak_rss_bytes)}"]
231
+ decisions_ok = all(s.decision == "block" for s in result.message_sizes)
232
+ emit(
233
+ "performance",
234
+ manifest,
235
+ result,
236
+ as_json=as_json,
237
+ output=output,
238
+ record=record,
239
+ text=text,
240
+ ok=decisions_ok,
241
+ )
242
+
243
+
244
+ @benchmark_app.command("hooks")
245
+ def hooks_command(
246
+ as_json: JsonOption = False,
247
+ output: OutputOption = None,
248
+ record: RecordOption = None,
249
+ repetitions: Annotated[int, typer.Option("--repetitions", min=1, max=500)] = 20,
250
+ ) -> None:
251
+ """Git commit and push with and without CommitGuard hooks, including --no-verify."""
252
+ from commitguard.research.environment import collect_manifest
253
+ from commitguard.research.hooks import run_hooks
254
+
255
+ with handled_errors():
256
+ result = run_hooks(repetitions)
257
+ manifest = collect_manifest()
258
+ text = [
259
+ "CommitGuard hook overhead",
260
+ "",
261
+ "Scenario p50 ms p95 ms exit codes",
262
+ ]
263
+ for s in result.scenarios:
264
+ codes = ", ".join(f"{code}x{count}" for code, count in sorted(s.exit_codes.items()))
265
+ text.append(f"{s.name:<25} {s.latency.p50_ms:>9} {s.latency.p95_ms:>9} {codes}")
266
+ text += ["", "Overhead (median):"]
267
+ text += [
268
+ f" {o.operation:<30} +{o.overhead_p50_ms} ms ({o.without_hooks_p50_ms} -> "
269
+ f"{o.with_hooks_p50_ms} ms)"
270
+ for o in result.overhead
271
+ ]
272
+ text += ["", "Observed behaviour:"]
273
+ text += [
274
+ f" [{'OK' if o.matches else 'MISMATCH'}] {o.check}: {o.observed}"
275
+ for o in result.observations
276
+ ]
277
+ emit(
278
+ "hooks",
279
+ manifest,
280
+ result,
281
+ as_json=as_json,
282
+ output=output,
283
+ record=record,
284
+ text=text,
285
+ ok=all(o.matches for o in result.observations),
286
+ )
287
+
288
+
289
+ @benchmark_app.command("repository")
290
+ def repository_command(
291
+ as_json: JsonOption = False,
292
+ output: OutputOption = None,
293
+ record: RecordOption = None,
294
+ sizes: Annotated[
295
+ str, typer.Option("--sizes", help="Comma-separated history sizes.")
296
+ ] = "100,1000,10000,100000",
297
+ ) -> None:
298
+ """Scan real repository histories of increasing size (list, read, analyse)."""
299
+ from commitguard.research.environment import collect_manifest
300
+ from commitguard.research.repository import run_repository
301
+
302
+ with handled_errors():
303
+ try:
304
+ parsed = tuple(int(part) for part in sizes.split(",") if part.strip())
305
+ except ValueError:
306
+ parsed = ()
307
+ if not parsed or any(size < 2 or size > 1_000_000 for size in parsed):
308
+ raise typer.BadParameter("sizes must be integers between 2 and 1000000")
309
+ result = run_repository(parsed)
310
+ manifest = collect_manifest()
311
+ text = [
312
+ "CommitGuard repository history scan",
313
+ "",
314
+ "Commits create ms list ms read ms analyse ms total ms commits/s violations",
315
+ ]
316
+ for h in result.histories:
317
+ text.append(
318
+ f"{h.commits:>7} {h.create_ms:>11} {h.list_ms:>9} {h.read_ms:>9} {h.analyze_ms:>12} "
319
+ f"{h.scan_total_ms:>10} {h.commits_per_second:>11} {h.blocked}/{h.seeded_violations}"
320
+ )
321
+ emit(
322
+ "repository",
323
+ manifest,
324
+ result,
325
+ as_json=as_json,
326
+ output=output,
327
+ record=record,
328
+ text=text,
329
+ ok=all(h.all_seeded_violations_found for h in result.histories),
330
+ )
331
+
332
+
333
+ @benchmark_app.command("platform")
334
+ def platform_command(
335
+ as_json: JsonOption = False,
336
+ output: OutputOption = None,
337
+ record: RecordOption = None,
338
+ ) -> None:
339
+ """Validate the local enforcement path on this operating system."""
340
+ from commitguard.research.environment import collect_manifest
341
+ from commitguard.research.platform import run_platform
342
+
343
+ with handled_errors():
344
+ result = run_platform()
345
+ manifest = collect_manifest()
346
+ text = [f"CommitGuard platform validation: {result.system}", ""]
347
+ text += [f" {c.status:<7} {c.area:<24} {c.name}: {c.observed}" for c in result.checks]
348
+ text += ["", f"PASS {result.passed} · FAIL {result.failed} · SKIPPED {result.skipped}"]
349
+ emit(
350
+ "platform",
351
+ manifest,
352
+ result,
353
+ as_json=as_json,
354
+ output=output,
355
+ record=record,
356
+ text=text,
357
+ ok=result.failed == 0,
358
+ )
359
+
360
+
361
+ @benchmark_app.command("compare")
362
+ def compare_command(
363
+ results: Annotated[
364
+ Path, typer.Option("--results", help="Results directory (e.g. benchmarks/results).")
365
+ ] = Path("benchmarks/results"),
366
+ benchmark: Annotated[
367
+ str, typer.Option("--benchmark", help="Which benchmark to compare.")
368
+ ] = "detection",
369
+ baseline: Annotated[
370
+ Path | None,
371
+ typer.Option("--baseline", help="Baseline result file (default: the previous run)."),
372
+ ] = None,
373
+ current: Annotated[
374
+ Path | None,
375
+ typer.Option("--current", help="Result file to judge (default: the latest run)."),
376
+ ] = None,
377
+ as_json: JsonOption = False,
378
+ ) -> None:
379
+ """Compare a benchmark result with an earlier one, using documented thresholds.
380
+
381
+ Exit codes: 0 when nothing regressed, 1 when something did, 2 on errors. A
382
+ correctness regression counts at any size; a performance regression only
383
+ beyond the threshold for that measure.
384
+ """
385
+ from commitguard.research.compare import THRESHOLDS, compare
386
+ from commitguard.research.results import load_results
387
+
388
+ with handled_errors():
389
+ recorded = [
390
+ (path, document)
391
+ for path, document in load_results(results)
392
+ if document.get("benchmark") == benchmark
393
+ ]
394
+ if baseline is None or current is None:
395
+ if len(recorded) < 2:
396
+ fail(
397
+ f"need two recorded {benchmark} results to compare; "
398
+ f"{len(recorded)} found in {results}"
399
+ )
400
+ first, second = recorded[-2], recorded[-1]
401
+ else:
402
+ first = (baseline, json.loads(baseline.read_text(encoding="utf-8")))
403
+ second = (current, json.loads(current.read_text(encoding="utf-8")))
404
+ before = {**first[1], "_file": str(first[0])}
405
+ after = {**second[1], "_file": str(second[0])}
406
+ comparison = compare(before, after, benchmark=benchmark)
407
+
408
+ if as_json:
409
+ info(json.dumps(comparison.model_dump(mode="json"), indent=2, sort_keys=True))
410
+ else:
411
+ info(f"CommitGuard benchmark comparison: {benchmark}")
412
+ info("")
413
+ info(f" baseline {comparison.baseline_file}")
414
+ info(f" {comparison.baseline_environment}")
415
+ info(f" current {comparison.current_file}")
416
+ info(f" {comparison.current_environment}")
417
+ if not comparison.same_environment:
418
+ info(" NOTE: different CPUs; performance numbers are not comparable.")
419
+ if not comparison.same_dataset and comparison.baseline_dataset:
420
+ info(
421
+ f" NOTE: different datasets ({comparison.baseline_dataset} then "
422
+ f"{comparison.current_dataset}); accuracy counts are not comparable."
423
+ )
424
+ info("")
425
+ info(f"{'Metric':<40}{'baseline':>14}{'current':>14}{'change':>12} verdict")
426
+ for metric in comparison.metrics:
427
+ change = "-" if metric.change_ratio is None else f"{metric.change_ratio * 100:+.1f}%"
428
+ base = "-" if metric.baseline is None else f"{metric.baseline:,.4g}"
429
+ now = "-" if metric.current is None else f"{metric.current:,.4g}"
430
+ info(f"{metric.name:<40}{base:>14}{now:>14}{change:>12} {metric.verdict}")
431
+ info("")
432
+ thresholds = ", ".join(f"{k} {v * 100:.0f}%" for k, v in sorted(THRESHOLDS.items()))
433
+ info(f"Thresholds: {thresholds}")
434
+ if comparison.regressions:
435
+ info(f"REGRESSED: {', '.join(comparison.regressions)}")
436
+ else:
437
+ info("No regression.")
438
+ if comparison.improvements:
439
+ info(f"Improved: {', '.join(comparison.improvements)}")
440
+ if not comparison.ok:
441
+ raise typer.Exit(ExitCode.BLOCKED)
@@ -0,0 +1,100 @@
1
+ """``commitguard check``: machine-friendly pass/fail analysis.
2
+
3
+ Same pipeline as ``scan``; the difference is the output contract:
4
+
5
+ * text output is one tab-separated line per finding
6
+ (``ACTION sha detector rule evidence``) followed by
7
+ ``result=ALLOW|WARN|BLOCK commits=N block=N warn=N allow=N``;
8
+ * ``--quiet`` prints nothing - only the exit code matters;
9
+ * ``--message-file`` checks a commit that does not exist yet, using the
10
+ author/committer Git would use (``git var``). Phase 3 hooks will call this.
11
+
12
+ Exit codes: 0 allowed (including warnings), 1 blocked, 2 error.
13
+ """
14
+
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+
20
+ from commitguard.cli.common import (
21
+ DEFAULT_MAX_COMMITS,
22
+ ConfigOption,
23
+ FormatOption,
24
+ MaxCommitsOption,
25
+ RevisionArgument,
26
+ )
27
+ from commitguard.cli.output import ExitCode, OutputFormat, fail, handled_errors, info
28
+ from commitguard.cli.render import render_check_text, render_json, render_scan_text
29
+ from commitguard.core.context import ScanTrigger
30
+ from commitguard.core.decision import Action
31
+ from commitguard.exceptions.base import UnsafeInputError
32
+ from commitguard.git.repository import Repository
33
+ from commitguard.services.analysis import (
34
+ analyze_message_file,
35
+ analyze_revisions,
36
+ build_report,
37
+ load_analyzer,
38
+ )
39
+
40
+
41
+ def check_command(
42
+ revision_range: RevisionArgument = "HEAD",
43
+ message_file: Annotated[
44
+ Path | None,
45
+ typer.Option(
46
+ "--message-file",
47
+ help="Check a pending commit message file instead of existing commits.",
48
+ dir_okay=False,
49
+ ),
50
+ ] = None,
51
+ config: ConfigOption = None,
52
+ output_format: FormatOption = OutputFormat.TEXT,
53
+ max_commits: MaxCommitsOption = DEFAULT_MAX_COMMITS,
54
+ quiet: Annotated[
55
+ bool, typer.Option("--quiet", "-q", help="Print nothing; use the exit code.")
56
+ ] = False,
57
+ verbose: Annotated[
58
+ bool,
59
+ typer.Option("--verbose", "-v", help="Human-readable output with full evidence."),
60
+ ] = False,
61
+ ) -> None:
62
+ """Check commits (or a pending message) and exit 0 (pass), 1 (blocked) or 2 (error)."""
63
+ with handled_errors():
64
+ repository = Repository.discover()
65
+ analyzer, loaded = load_analyzer(repository, config_path=config)
66
+ if message_file is not None:
67
+ try:
68
+ reports = [analyze_message_file(repository, analyzer, message_file)]
69
+ except FileNotFoundError:
70
+ fail(f"message file not found: {message_file}")
71
+ except UnsafeInputError as exc:
72
+ fail(f"cannot read message file: {exc}")
73
+ target = f"message-file:{message_file}"
74
+ else:
75
+ reports = analyze_revisions(
76
+ repository,
77
+ analyzer,
78
+ revision_range,
79
+ max_commits=max_commits,
80
+ trigger=ScanTrigger.CHECK,
81
+ )
82
+ target = revision_range
83
+ report = build_report(
84
+ reports,
85
+ repository=repository,
86
+ target=target,
87
+ trigger=ScanTrigger.CHECK,
88
+ config=loaded,
89
+ )
90
+ if output_format is OutputFormat.JSON:
91
+ rendered = render_json(report)
92
+ elif verbose:
93
+ rendered = render_scan_text(report)
94
+ else:
95
+ rendered = render_check_text(report)
96
+
97
+ if not quiet:
98
+ info(rendered)
99
+ if report.action is Action.BLOCK:
100
+ raise typer.Exit(code=int(ExitCode.BLOCKED))