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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|