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,247 @@
|
|
|
1
|
+
"""Git operations with and without CommitGuard hooks.
|
|
2
|
+
|
|
3
|
+
Two otherwise identical repositories are created: one plain, one with the
|
|
4
|
+
CommitGuard hooks (``pre-commit``, ``commit-msg``, ``pre-push``) installed by
|
|
5
|
+
the production installer. The same Git commands run against both, repeatedly,
|
|
6
|
+
and the difference in wall-clock time is the hook overhead.
|
|
7
|
+
|
|
8
|
+
The benchmark also records what happened (exit codes), because the timings are
|
|
9
|
+
only meaningful if the hooks actually enforced:
|
|
10
|
+
|
|
11
|
+
* a clean commit is allowed with and without hooks;
|
|
12
|
+
* an AI co-authored commit is blocked by the hooks;
|
|
13
|
+
* ``git commit --no-verify`` bypasses the local hooks (measured, not assumed);
|
|
14
|
+
* a push of a violating commit is blocked by ``pre-push``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict
|
|
22
|
+
|
|
23
|
+
from commitguard.git.hooks import install_hooks
|
|
24
|
+
from commitguard.git.repository import Repository
|
|
25
|
+
from commitguard.research.gitenv import Workspace, workspace
|
|
26
|
+
from commitguard.research.metrics import LatencySummary
|
|
27
|
+
|
|
28
|
+
BENCHMARK_VERSION = "1.0.0"
|
|
29
|
+
DEFAULT_REPETITIONS = 20
|
|
30
|
+
|
|
31
|
+
CLEAN = "feat: add session rotation\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
|
|
32
|
+
AI = "feat: add payment service\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Scenario(BaseModel):
|
|
36
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
hooks_installed: bool
|
|
41
|
+
exit_codes: dict[str, int]
|
|
42
|
+
latency: LatencySummary
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Overhead(BaseModel):
|
|
46
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
47
|
+
|
|
48
|
+
operation: str
|
|
49
|
+
without_hooks_p50_ms: float
|
|
50
|
+
with_hooks_p50_ms: float
|
|
51
|
+
overhead_p50_ms: float
|
|
52
|
+
overhead_ratio: float | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Observation(BaseModel):
|
|
56
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
57
|
+
|
|
58
|
+
check: str
|
|
59
|
+
expected: str
|
|
60
|
+
observed: str
|
|
61
|
+
matches: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HooksResult(BaseModel):
|
|
65
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
66
|
+
|
|
67
|
+
benchmark: str = "hooks"
|
|
68
|
+
benchmark_version: str = BENCHMARK_VERSION
|
|
69
|
+
repetitions: int
|
|
70
|
+
python: str
|
|
71
|
+
scenarios: tuple[Scenario, ...]
|
|
72
|
+
overhead: tuple[Overhead, ...]
|
|
73
|
+
observations: tuple[Observation, ...]
|
|
74
|
+
notes: tuple[str, ...]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _measure(
|
|
78
|
+
name: str,
|
|
79
|
+
description: str,
|
|
80
|
+
hooked: bool,
|
|
81
|
+
repetitions: int,
|
|
82
|
+
run: Callable[[int], tuple[float, int]],
|
|
83
|
+
) -> Scenario:
|
|
84
|
+
durations = []
|
|
85
|
+
codes: dict[str, int] = {}
|
|
86
|
+
for index in range(repetitions):
|
|
87
|
+
seconds, code = run(index)
|
|
88
|
+
durations.append(seconds)
|
|
89
|
+
codes[str(code)] = codes.get(str(code), 0) + 1
|
|
90
|
+
return Scenario(
|
|
91
|
+
name=name,
|
|
92
|
+
description=description,
|
|
93
|
+
hooks_installed=hooked,
|
|
94
|
+
exit_codes=codes,
|
|
95
|
+
latency=LatencySummary.from_seconds(durations),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _repository(space: Workspace, name: str, hooked: bool) -> tuple[Repository, str]:
|
|
100
|
+
remote = space.init(f"{name}-remote.git", bare=True)
|
|
101
|
+
path = space.init(name)
|
|
102
|
+
space.git(path, "remote", "add", "origin", str(remote))
|
|
103
|
+
space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", "chore: initial")
|
|
104
|
+
space.git(path, "push", "--quiet", "--no-verify", "origin", "main")
|
|
105
|
+
repository = Repository.discover(path)
|
|
106
|
+
if hooked:
|
|
107
|
+
install_hooks(repository, python=sys.executable)
|
|
108
|
+
return repository, name
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_hooks(repetitions: int = DEFAULT_REPETITIONS) -> HooksResult:
|
|
112
|
+
if repetitions < 1:
|
|
113
|
+
raise ValueError("repetitions must be at least 1")
|
|
114
|
+
with workspace() as space:
|
|
115
|
+
plain, _ = _repository(space, "plain", hooked=False)
|
|
116
|
+
hooked, _ = _repository(space, "hooked", hooked=True)
|
|
117
|
+
p, h = plain.root, hooked.root
|
|
118
|
+
|
|
119
|
+
def commit(path: Path, message: str, *extra: str) -> Callable[[int], tuple[float, int]]:
|
|
120
|
+
def run(_: int) -> tuple[float, int]:
|
|
121
|
+
seconds, result = space.timed_git(
|
|
122
|
+
path, "commit", "--quiet", "--allow-empty", *extra, "-m", message
|
|
123
|
+
)
|
|
124
|
+
return seconds, result.returncode
|
|
125
|
+
|
|
126
|
+
return run
|
|
127
|
+
|
|
128
|
+
def push(
|
|
129
|
+
label: str, path: Path, message: str, *extra: str
|
|
130
|
+
) -> Callable[[int], tuple[float, int]]:
|
|
131
|
+
def run(index: int) -> tuple[float, int]:
|
|
132
|
+
branch = f"{label}-{index}" # unique: a reused name could be rejected by the remote
|
|
133
|
+
# Start from the remote state so earlier scenarios cannot leak commits in.
|
|
134
|
+
space.git(path, "checkout", "--quiet", "-B", branch, "origin/main")
|
|
135
|
+
space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", message)
|
|
136
|
+
seconds, result = space.timed_git(path, "push", "--quiet", *extra, "origin", branch)
|
|
137
|
+
return seconds, result.returncode
|
|
138
|
+
|
|
139
|
+
return run
|
|
140
|
+
|
|
141
|
+
scenarios = (
|
|
142
|
+
_measure(
|
|
143
|
+
"commit-clean-plain",
|
|
144
|
+
"git commit, clean message, no hooks",
|
|
145
|
+
False,
|
|
146
|
+
repetitions,
|
|
147
|
+
commit(p, CLEAN),
|
|
148
|
+
),
|
|
149
|
+
_measure(
|
|
150
|
+
"commit-clean-hooked",
|
|
151
|
+
"git commit, clean message, CommitGuard hooks",
|
|
152
|
+
True,
|
|
153
|
+
repetitions,
|
|
154
|
+
commit(h, CLEAN),
|
|
155
|
+
),
|
|
156
|
+
_measure(
|
|
157
|
+
"commit-ai-hooked",
|
|
158
|
+
"git commit, AI co-author, CommitGuard hooks",
|
|
159
|
+
True,
|
|
160
|
+
repetitions,
|
|
161
|
+
commit(h, AI),
|
|
162
|
+
),
|
|
163
|
+
_measure(
|
|
164
|
+
"commit-ai-no-verify",
|
|
165
|
+
"git commit --no-verify, AI co-author, hooks installed",
|
|
166
|
+
True,
|
|
167
|
+
repetitions,
|
|
168
|
+
commit(h, AI, "--no-verify"),
|
|
169
|
+
),
|
|
170
|
+
_measure(
|
|
171
|
+
"push-clean-plain",
|
|
172
|
+
"git push of one new clean commit, no hooks",
|
|
173
|
+
False,
|
|
174
|
+
repetitions,
|
|
175
|
+
push("push-clean-plain", p, CLEAN),
|
|
176
|
+
),
|
|
177
|
+
_measure(
|
|
178
|
+
"push-clean-hooked",
|
|
179
|
+
"git push of one new clean commit, CommitGuard pre-push",
|
|
180
|
+
True,
|
|
181
|
+
repetitions,
|
|
182
|
+
push("push-clean-hooked", h, CLEAN),
|
|
183
|
+
),
|
|
184
|
+
_measure(
|
|
185
|
+
"push-ai-hooked",
|
|
186
|
+
"git push of one new AI co-authored commit, pre-push",
|
|
187
|
+
True,
|
|
188
|
+
repetitions,
|
|
189
|
+
push("push-ai-hooked", h, AI),
|
|
190
|
+
),
|
|
191
|
+
_measure(
|
|
192
|
+
"push-ai-no-verify",
|
|
193
|
+
"git push --no-verify of an AI co-authored commit",
|
|
194
|
+
True,
|
|
195
|
+
repetitions,
|
|
196
|
+
push("push-ai-no-verify", h, AI, "--no-verify"),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
by_name = {s.name: s for s in scenarios}
|
|
200
|
+
|
|
201
|
+
def overhead(operation: str, plain_name: str, hooked_name: str) -> Overhead:
|
|
202
|
+
base, with_hooks = by_name[plain_name].latency.p50_ms, by_name[hooked_name].latency.p50_ms
|
|
203
|
+
return Overhead(
|
|
204
|
+
operation=operation,
|
|
205
|
+
without_hooks_p50_ms=base,
|
|
206
|
+
with_hooks_p50_ms=with_hooks,
|
|
207
|
+
overhead_p50_ms=round(with_hooks - base, 3),
|
|
208
|
+
overhead_ratio=round(with_hooks / base, 2) if base > 0 else None,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def observe(check: str, scenario: str, expected_code: str) -> Observation:
|
|
212
|
+
codes = by_name[scenario].exit_codes
|
|
213
|
+
observed = ", ".join(f"exit {code} x{count}" for code, count in sorted(codes.items()))
|
|
214
|
+
matches = set(codes) == {expected_code} if expected_code == "0" else "0" not in codes
|
|
215
|
+
return Observation(
|
|
216
|
+
check=check,
|
|
217
|
+
expected=f"exit {'0' if expected_code == '0' else 'non-zero'} every time",
|
|
218
|
+
observed=observed,
|
|
219
|
+
matches=matches,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
observations = (
|
|
223
|
+
observe("clean commit allowed without hooks", "commit-clean-plain", "0"),
|
|
224
|
+
observe("clean commit allowed with hooks", "commit-clean-hooked", "0"),
|
|
225
|
+
observe("AI co-authored commit blocked by commit hooks", "commit-ai-hooked", "!0"),
|
|
226
|
+
observe("--no-verify bypasses local commit hooks", "commit-ai-no-verify", "0"),
|
|
227
|
+
observe("clean push allowed by pre-push", "push-clean-hooked", "0"),
|
|
228
|
+
observe("AI co-authored push blocked by pre-push", "push-ai-hooked", "!0"),
|
|
229
|
+
observe("--no-verify bypasses the local pre-push hook", "push-ai-no-verify", "0"),
|
|
230
|
+
)
|
|
231
|
+
return HooksResult(
|
|
232
|
+
repetitions=repetitions,
|
|
233
|
+
python=sys.executable,
|
|
234
|
+
scenarios=scenarios,
|
|
235
|
+
overhead=(
|
|
236
|
+
overhead("git commit (clean)", "commit-clean-plain", "commit-clean-hooked"),
|
|
237
|
+
overhead("git push (clean, one commit)", "push-clean-plain", "push-clean-hooked"),
|
|
238
|
+
),
|
|
239
|
+
observations=observations,
|
|
240
|
+
notes=(
|
|
241
|
+
"Each hook starts a Python interpreter; interpreter start-up is part of the "
|
|
242
|
+
"measured overhead.",
|
|
243
|
+
"Pushes go to a local bare repository, so network latency is excluded.",
|
|
244
|
+
"--no-verify results demonstrate that local hooks are advisory; server-side "
|
|
245
|
+
"enforcement is measured separately.",
|
|
246
|
+
),
|
|
247
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Measurement arithmetic: confusion matrices and latency statistics.
|
|
2
|
+
|
|
3
|
+
CommitGuard's detection is deterministic and rule-based. Precision, recall and
|
|
4
|
+
F1 are reported because they are the standard way to state how often a
|
|
5
|
+
classifier is right on a labelled set - not to suggest a statistical model.
|
|
6
|
+
Undefined ratios (a zero denominator) are ``None``, never a made-up 0 or 1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Confusion(BaseModel):
|
|
16
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
17
|
+
|
|
18
|
+
true_positive: int
|
|
19
|
+
false_positive: int
|
|
20
|
+
true_negative: int
|
|
21
|
+
false_negative: int
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def total(self) -> int:
|
|
25
|
+
return self.true_positive + self.false_positive + self.true_negative + self.false_negative
|
|
26
|
+
|
|
27
|
+
def rates(self) -> dict[str, float | None]:
|
|
28
|
+
tp, fp, tn, fn = (
|
|
29
|
+
self.true_positive,
|
|
30
|
+
self.false_positive,
|
|
31
|
+
self.true_negative,
|
|
32
|
+
self.false_negative,
|
|
33
|
+
)
|
|
34
|
+
precision = _ratio(tp, tp + fp)
|
|
35
|
+
recall = _ratio(tp, tp + fn)
|
|
36
|
+
f1 = (
|
|
37
|
+
None
|
|
38
|
+
if precision is None or recall is None or precision + recall == 0
|
|
39
|
+
else 2 * precision * recall / (precision + recall)
|
|
40
|
+
)
|
|
41
|
+
return {
|
|
42
|
+
"precision": precision,
|
|
43
|
+
"recall": recall,
|
|
44
|
+
"f1": f1,
|
|
45
|
+
"accuracy": _ratio(tp + tn, self.total),
|
|
46
|
+
"false_positive_rate": _ratio(fp, fp + tn),
|
|
47
|
+
"false_negative_rate": _ratio(fn, fn + tp),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ratio(numerator: int, denominator: int) -> float | None:
|
|
52
|
+
return None if denominator == 0 else numerator / denominator
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def percentile(sorted_values: Sequence[float], fraction: float) -> float:
|
|
56
|
+
"""Nearest-rank percentile of an ascending sequence (no interpolation)."""
|
|
57
|
+
if not sorted_values:
|
|
58
|
+
raise ValueError("no values")
|
|
59
|
+
rank = max(1, math.ceil(fraction * len(sorted_values)))
|
|
60
|
+
return sorted_values[rank - 1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LatencySummary(BaseModel):
|
|
64
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
65
|
+
|
|
66
|
+
samples: int
|
|
67
|
+
min_ms: float
|
|
68
|
+
mean_ms: float
|
|
69
|
+
p50_ms: float
|
|
70
|
+
p95_ms: float
|
|
71
|
+
p99_ms: float
|
|
72
|
+
max_ms: float
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_seconds(cls, durations: Sequence[float]) -> "LatencySummary":
|
|
76
|
+
values = sorted(d * 1000 for d in durations)
|
|
77
|
+
return cls(
|
|
78
|
+
samples=len(values),
|
|
79
|
+
min_ms=round(values[0], 4),
|
|
80
|
+
mean_ms=round(sum(values) / len(values), 4),
|
|
81
|
+
p50_ms=round(percentile(values, 0.50), 4),
|
|
82
|
+
p95_ms=round(percentile(values, 0.95), 4),
|
|
83
|
+
p99_ms=round(percentile(values, 0.99), 4),
|
|
84
|
+
max_ms=round(values[-1], 4),
|
|
85
|
+
)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Detection performance: latency, message-size scaling, CPU time and memory.
|
|
2
|
+
|
|
3
|
+
What is measured, and how:
|
|
4
|
+
|
|
5
|
+
* **wall-clock** - ``time.perf_counter`` around each ``Analyzer.analyze`` call;
|
|
6
|
+
* **CPU time** - ``time.process_time`` over a whole batch (user + system time of
|
|
7
|
+
this process);
|
|
8
|
+
* **Python allocations** - ``tracemalloc`` peak during a *separate* pass over the
|
|
9
|
+
same commits: memory allocated by the analysis itself, excluding the
|
|
10
|
+
interpreter's baseline. Timing passes run without ``tracemalloc``, which slows
|
|
11
|
+
Python code by roughly 2-3x;
|
|
12
|
+
* **peak RSS** - the process's maximum resident set size (``resource``; not
|
|
13
|
+
available on Windows, reported as ``None`` there). RSS is a process-lifetime
|
|
14
|
+
high-water mark, so it is reported once, after all scenarios.
|
|
15
|
+
|
|
16
|
+
Batches reuse one analyzer, as the hooks and the GitHub App do. A warm-up pass
|
|
17
|
+
runs first so imports and rule compilation are not attributed to the first
|
|
18
|
+
sample; rule loading is measured separately (``startup``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import gc
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import tracemalloc
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict
|
|
28
|
+
|
|
29
|
+
from commitguard.git.commit import Commit
|
|
30
|
+
from commitguard.policies.defaults import default_policy_set
|
|
31
|
+
from commitguard.provenance.author import Identity
|
|
32
|
+
from commitguard.research.metrics import LatencySummary
|
|
33
|
+
from commitguard.services.analysis import Analyzer
|
|
34
|
+
|
|
35
|
+
BENCHMARK_VERSION = "1.0.0"
|
|
36
|
+
BATCH_SIZES = (1, 10, 100, 1_000, 10_000)
|
|
37
|
+
MESSAGE_SIZES = (1_024, 10_240, 102_400, 1_048_576, 10_485_760)
|
|
38
|
+
|
|
39
|
+
HUMAN = Identity(name="Ada Lovelace", email="ada@example.com")
|
|
40
|
+
TRAILER = "Co-authored-by: Claude <noreply@anthropic.com>"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BatchResult(BaseModel):
|
|
44
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
45
|
+
|
|
46
|
+
commits: int
|
|
47
|
+
wall_ms: float
|
|
48
|
+
cpu_ms: float
|
|
49
|
+
commits_per_second: float
|
|
50
|
+
latency: LatencySummary
|
|
51
|
+
python_peak_allocated_bytes: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SizeResult(BaseModel):
|
|
55
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
message_bytes: int
|
|
58
|
+
repetitions: int
|
|
59
|
+
decision: str
|
|
60
|
+
latency: LatencySummary
|
|
61
|
+
cpu_ms_per_commit: float
|
|
62
|
+
python_peak_allocated_bytes: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PerformanceResult(BaseModel):
|
|
66
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
67
|
+
|
|
68
|
+
benchmark: str = "performance"
|
|
69
|
+
benchmark_version: str = BENCHMARK_VERSION
|
|
70
|
+
startup_ms: float
|
|
71
|
+
batches: tuple[BatchResult, ...]
|
|
72
|
+
message_sizes: tuple[SizeResult, ...]
|
|
73
|
+
peak_rss_bytes: int | None
|
|
74
|
+
notes: tuple[str, ...]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _mixed_commits(count: int) -> list[Commit]:
|
|
78
|
+
"""Realistic mix: 60% clean with human trailers, 35% AI co-author, 5% bot author."""
|
|
79
|
+
commits = []
|
|
80
|
+
for index in range(count):
|
|
81
|
+
kind = index % 20
|
|
82
|
+
if kind < 12:
|
|
83
|
+
message = f"fix: handle case {index}\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
|
|
84
|
+
author = HUMAN
|
|
85
|
+
elif kind < 19:
|
|
86
|
+
message = f"feat: add feature {index}\n\n{TRAILER}\n"
|
|
87
|
+
author = HUMAN
|
|
88
|
+
else:
|
|
89
|
+
message = "chore(deps): bump a dependency\n"
|
|
90
|
+
author = Identity(
|
|
91
|
+
name="dependabot[bot]", email="49699333+dependabot[bot]@users.noreply.github.com"
|
|
92
|
+
)
|
|
93
|
+
commits.append(Commit(author=author, committer=author, message=message))
|
|
94
|
+
return commits
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sized_commit(size: int) -> Commit:
|
|
98
|
+
"""A commit of about ``size`` bytes: wrapped prose, then an AI co-author trailer."""
|
|
99
|
+
line = "The storage layer keeps immutable versions of every published policy.\n"
|
|
100
|
+
body = (line * (size // len(line) + 1))[: max(0, size - len(TRAILER) - 40)]
|
|
101
|
+
return Commit(author=HUMAN, committer=HUMAN, message=f"docs: notes\n\n{body}\n\n{TRAILER}\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def peak_rss_bytes() -> int | None:
|
|
105
|
+
try:
|
|
106
|
+
import resource
|
|
107
|
+
except ImportError: # Windows
|
|
108
|
+
return None
|
|
109
|
+
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
110
|
+
return int(peak if sys.platform == "darwin" else peak * 1024) # macOS: bytes, Linux: KiB
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _allocation_peak(analyzer: Analyzer, commits: Sequence[Commit]) -> int:
|
|
114
|
+
gc.collect()
|
|
115
|
+
tracemalloc.start()
|
|
116
|
+
try:
|
|
117
|
+
for commit in commits:
|
|
118
|
+
analyzer.analyze(commit)
|
|
119
|
+
return tracemalloc.get_traced_memory()[1]
|
|
120
|
+
finally:
|
|
121
|
+
tracemalloc.stop()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _batch(analyzer: Analyzer, commits: Sequence[Commit]) -> BatchResult:
|
|
125
|
+
gc.collect()
|
|
126
|
+
durations = []
|
|
127
|
+
cpu_start = time.process_time()
|
|
128
|
+
wall_start = time.perf_counter()
|
|
129
|
+
for commit in commits:
|
|
130
|
+
begin = time.perf_counter()
|
|
131
|
+
analyzer.analyze(commit)
|
|
132
|
+
durations.append(time.perf_counter() - begin)
|
|
133
|
+
wall = time.perf_counter() - wall_start
|
|
134
|
+
cpu = time.process_time() - cpu_start
|
|
135
|
+
peak = _allocation_peak(analyzer, commits)
|
|
136
|
+
return BatchResult(
|
|
137
|
+
commits=len(commits),
|
|
138
|
+
wall_ms=round(wall * 1000, 3),
|
|
139
|
+
cpu_ms=round(cpu * 1000, 3),
|
|
140
|
+
commits_per_second=round(len(commits) / wall, 1) if wall > 0 else 0.0,
|
|
141
|
+
latency=LatencySummary.from_seconds(durations),
|
|
142
|
+
python_peak_allocated_bytes=peak,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _size(analyzer: Analyzer, size: int) -> SizeResult:
|
|
147
|
+
commit = _sized_commit(size)
|
|
148
|
+
repetitions = max(5, min(200, 50_000_000 // max(size, 1)))
|
|
149
|
+
gc.collect()
|
|
150
|
+
durations = []
|
|
151
|
+
decision = ""
|
|
152
|
+
cpu_start = time.process_time()
|
|
153
|
+
for _ in range(repetitions):
|
|
154
|
+
begin = time.perf_counter()
|
|
155
|
+
report = analyzer.analyze(commit)
|
|
156
|
+
durations.append(time.perf_counter() - begin)
|
|
157
|
+
decision = report.action.value
|
|
158
|
+
cpu = time.process_time() - cpu_start
|
|
159
|
+
peak = _allocation_peak(analyzer, [commit])
|
|
160
|
+
return SizeResult(
|
|
161
|
+
message_bytes=len(commit.message.encode("utf-8")),
|
|
162
|
+
repetitions=repetitions,
|
|
163
|
+
decision=decision,
|
|
164
|
+
latency=LatencySummary.from_seconds(durations),
|
|
165
|
+
cpu_ms_per_commit=round(cpu * 1000 / repetitions, 4),
|
|
166
|
+
python_peak_allocated_bytes=peak,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_performance(
|
|
171
|
+
*,
|
|
172
|
+
batch_sizes: Sequence[int] = BATCH_SIZES,
|
|
173
|
+
message_sizes: Sequence[int] = MESSAGE_SIZES,
|
|
174
|
+
) -> PerformanceResult:
|
|
175
|
+
started = time.perf_counter()
|
|
176
|
+
analyzer = Analyzer.create(default_policy_set())
|
|
177
|
+
startup = time.perf_counter() - started
|
|
178
|
+
for commit in _mixed_commits(200): # warm-up
|
|
179
|
+
analyzer.analyze(commit)
|
|
180
|
+
batches = tuple(_batch(analyzer, _mixed_commits(size)) for size in batch_sizes)
|
|
181
|
+
sizes = tuple(_size(analyzer, size) for size in message_sizes)
|
|
182
|
+
notes = (
|
|
183
|
+
"Commits are built in memory: this isolates detection and policy evaluation from Git I/O "
|
|
184
|
+
"(see the hooks and repository benchmarks for end-to-end Git timings).",
|
|
185
|
+
"startup_ms includes loading and compiling the built-in rules (cached per process).",
|
|
186
|
+
"Allocation peaks come from a separate tracemalloc pass; timings are measured without it.",
|
|
187
|
+
)
|
|
188
|
+
return PerformanceResult(
|
|
189
|
+
startup_ms=round(startup * 1000, 3),
|
|
190
|
+
batches=batches,
|
|
191
|
+
message_sizes=sizes,
|
|
192
|
+
peak_rss_bytes=peak_rss_bytes(),
|
|
193
|
+
notes=notes,
|
|
194
|
+
)
|