dos-kernel 0.22.0__py3-none-win_amd64.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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
dos/commit_audit.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""commit-audit — does a commit's *claim* match what its *diff* actually did?
|
|
2
|
+
|
|
3
|
+
The kernel's whole thesis is **byte-author ≠ claimant**: the words in a commit
|
|
4
|
+
*subject* are authored by whoever wrote the message (forgeable); the *files the
|
|
5
|
+
commit touched* are authored by the commit machinery itself (not forgeable). That
|
|
6
|
+
split has nothing to do with whether a **human** or an **agent** wrote the
|
|
7
|
+
message — so this verdict is **author-neutral.** A person who writes
|
|
8
|
+
`fix: resolve the auth race` in a commit that touches only `README.md` has made a
|
|
9
|
+
claim their own diff cannot witness, exactly as an agent's `--allow-empty
|
|
10
|
+
"phase shipped"` does (docs/206 §5 E3, `benchmark/fleet_horizon/forge.py`).
|
|
11
|
+
|
|
12
|
+
Unlike `oracle.is_shipped`, this needs **no plan, no phase, no DOS vocabulary** —
|
|
13
|
+
it reads a single commit and grades the *relationship between the claim's KIND and
|
|
14
|
+
the diff's SHAPE*. That makes it the universal, zero-config form of the floor: a
|
|
15
|
+
`git log` audit / pre-commit lint / CI gate any git project can run.
|
|
16
|
+
|
|
17
|
+
It is PURE (`classify(CommitClaim, DiffFacts, policy) -> ClaimVerdict`): the git
|
|
18
|
+
read happens at the caller boundary (the `dos commit-audit` CLI / `audit_commit`
|
|
19
|
+
reader below), exactly like `liveness.classify` over `git_delta` — the arbiter
|
|
20
|
+
discipline. And it is **advisory**: it reports a rung, it does not block. A
|
|
21
|
+
doc-only "fix" is *sometimes* legitimate (a comment fix); the verdict says
|
|
22
|
+
"the claim rests on the message text, not the diff," and the consumer decides.
|
|
23
|
+
|
|
24
|
+
## What it can witness soundly (the non-forgeable facts)
|
|
25
|
+
|
|
26
|
+
1. **EMPTY_CLAIM** — the subject uses an *effect verb* ("fix/add/implement/
|
|
27
|
+
remove/optimize/…") but the commit touched **zero source files** (it is empty,
|
|
28
|
+
or touches only docs/config/generated/binary paths). The message claims a code
|
|
29
|
+
change; the diff shows none. Sound: "touched 0 source files" is a fact about
|
|
30
|
+
the commit, not the author's word.
|
|
31
|
+
2. **TEST_CLAIM_NO_TEST** — the subject claims tests ("add tests", "tests pass",
|
|
32
|
+
"fix the failing test") but the diff touches **no test file**, or **net-deletes**
|
|
33
|
+
test lines (the delete-the-assertion shape). Sound from the diff alone.
|
|
34
|
+
3. **the forgeability RUNG of every commit** — `diff-witnessed` (the diff touches
|
|
35
|
+
source the subject plausibly refers to) vs `subject-only` (the claim rests on
|
|
36
|
+
message text alone). The generalization of `oracle._grade_grep_source` to "does
|
|
37
|
+
the diff corroborate the subject."
|
|
38
|
+
|
|
39
|
+
A claim that STRUCTURALLY scopes itself is witnessed by its own location kind:
|
|
40
|
+
`docs: …` by a doc file, and a CI-scoped claim (`ci: …` / `fix(ci): …`, the
|
|
41
|
+
conventional-commit type/scope) by a canonical CI-config path
|
|
42
|
+
(`.github/workflows/*.yml` & friends — `_is_ci_config`). The scope names where
|
|
43
|
+
the claimed effect LIVES, so the natural diff is corroboration, not contradiction
|
|
44
|
+
— without this, every honest `fix(ci):` touching only its workflow read as
|
|
45
|
+
"code claim, no source" (the 5b2b940/e5debd1 over-fire). The suppression needs
|
|
46
|
+
the CONJUNCTION (ci-shaped claim AND ci-config diff); either alone changes
|
|
47
|
+
nothing, so the widening is monotone fire-reducing.
|
|
48
|
+
|
|
49
|
+
## What it CANNOT witness — and so ABSTAINS on (state it; do not pretend)
|
|
50
|
+
|
|
51
|
+
- **Correctness.** A real fix to the *wrong* bug touches source and passes here.
|
|
52
|
+
This grades *did the diff do the KIND of thing claimed*, never *was it right*
|
|
53
|
+
(Wall 3, `project-dos-wall-presence-not-goal`).
|
|
54
|
+
- **Issue resolution.** "closes #42" needs an issue→files map this module does not
|
|
55
|
+
have; it never claims to verify the *content* of an issue reference.
|
|
56
|
+
- **A subject with no checkable claim** ("wip", "misc", "address review") →
|
|
57
|
+
`NO_CLAIM`, abstain. Most commits are honest and uncheckable; the verdict only
|
|
58
|
+
*fires* where a concrete claim and a contradicting diff coexist.
|
|
59
|
+
|
|
60
|
+
The conservative direction is load-bearing: a false WARN on a legitimate doc-only
|
|
61
|
+
fix is annoying, so the verb taxonomy is deliberately tight and the source/non-
|
|
62
|
+
source split errs toward calling things source (fewer false EMPTY_CLAIM fires).
|
|
63
|
+
"""
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import dataclasses
|
|
67
|
+
import re
|
|
68
|
+
import subprocess
|
|
69
|
+
from enum import Enum
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
|
|
72
|
+
_GIT_TIMEOUT_S = 15
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ClaimKind(Enum):
|
|
76
|
+
"""The KIND of claim a commit subject makes — what we can check the diff against."""
|
|
77
|
+
CODE_EFFECT = "code_effect" # fix/add/implement/remove/refactor/optimize/…
|
|
78
|
+
TEST = "test" # add/fix tests, "tests pass/green"
|
|
79
|
+
DOC = "doc" # docs/comment/typo/readme — a doc claim
|
|
80
|
+
NONE = "none" # wip/misc/merge/bump — no checkable behavioral claim
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Witness(Enum):
|
|
84
|
+
"""How the diff stands relative to the claim."""
|
|
85
|
+
DIFF_WITNESSED = "diff-witnessed" # the diff corroborates the claim (non-forgeable)
|
|
86
|
+
SUBJECT_ONLY = "subject-only" # the claim rests on message text alone (forgeable)
|
|
87
|
+
ABSTAIN = "abstain" # no checkable claim, or no evidence either way
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Verdict(Enum):
|
|
91
|
+
OK = "OK" # claim witnessed by the diff (or honestly doc-only)
|
|
92
|
+
CLAIM_UNWITNESSED = "CLAIM_UNWITNESSED" # a concrete claim the diff contradicts
|
|
93
|
+
ABSTAIN = "ABSTAIN" # no checkable claim
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Effect verbs that assert a *code* change. Tight on purpose (false WARNs are the
|
|
97
|
+
# cost). Matched at the START of the subject (conventional-commit style) OR as a
|
|
98
|
+
# leading word, case-insensitive. `refactor`/`rename`/`move` are CODE_EFFECT too
|
|
99
|
+
# but are guarded specially below (they legitimately touch many files / can be
|
|
100
|
+
# pure-move with no net line change).
|
|
101
|
+
_CODE_VERBS = (
|
|
102
|
+
"fix", "fixes", "fixed", "add", "adds", "added", "implement", "implements",
|
|
103
|
+
"implemented", "remove", "removes", "removed", "delete", "optimize",
|
|
104
|
+
"optimise", "refactor", "rename", "move", "support", "handle", "resolve",
|
|
105
|
+
"resolves", "patch", "correct", "introduce", "enable", "disable", "drop",
|
|
106
|
+
"migrate", "upgrade", "downgrade", "wire", "hook",
|
|
107
|
+
)
|
|
108
|
+
# Verbs/markers that claim TESTS specifically.
|
|
109
|
+
_TEST_MARKERS = (
|
|
110
|
+
"test", "tests", "testing", "unit test", "unit tests", "coverage",
|
|
111
|
+
"assertion", "assertions", "spec", "specs",
|
|
112
|
+
)
|
|
113
|
+
_TEST_PASS_PHRASES = (
|
|
114
|
+
"tests pass", "test pass", "tests green", "all tests", "passing tests",
|
|
115
|
+
"tests passing", "green build", "fix the test", "fix failing test",
|
|
116
|
+
"fix the failing test", "fixes the test",
|
|
117
|
+
)
|
|
118
|
+
# Doc-only claim markers (a subject that HONESTLY scopes itself to docs).
|
|
119
|
+
_DOC_MARKERS = (
|
|
120
|
+
"doc", "docs", "documentation", "readme", "comment", "comments", "typo",
|
|
121
|
+
"changelog", "wording", "rephrase", "clarify",
|
|
122
|
+
)
|
|
123
|
+
# Subjects with no checkable behavioral claim → NO_CLAIM (abstain).
|
|
124
|
+
_NOCLAIM_MARKERS = (
|
|
125
|
+
"wip", "misc", "cleanup", "chore", "bump", "version", "release", "merge",
|
|
126
|
+
"revert", "format", "lint", "whitespace", "style", "address review",
|
|
127
|
+
"review feedback", "nit", "nits", "rename variable",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Source-file suffixes — the universal "this is code" set. Erring toward INCLUSION
|
|
131
|
+
# (more suffixes = fewer false EMPTY_CLAIM fires on a real code change in an exotic
|
|
132
|
+
# language). A commit that touches one of these counts as touching source.
|
|
133
|
+
_SOURCE_SUFFIXES = (
|
|
134
|
+
".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".go", ".rs",
|
|
135
|
+
".java", ".kt", ".kts", ".scala", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp",
|
|
136
|
+
".hh", ".cs", ".rb", ".php", ".swift", ".m", ".mm", ".sh", ".bash", ".zsh",
|
|
137
|
+
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
|
|
138
|
+
".pl", ".pm", ".lua", ".r", ".jl", ".dart", ".ex", ".exs", ".erl", ".clj",
|
|
139
|
+
".cljs", ".hs", ".ml", ".fs", ".vb", ".sql", ".proto", ".tf", ".gradle",
|
|
140
|
+
".cmake", ".mk", ".vue", ".svelte", ".elm", ".nim", ".zig", ".v",
|
|
141
|
+
)
|
|
142
|
+
# Non-source: docs, config, data, binaries, lockfiles. A commit touching ONLY
|
|
143
|
+
# these (and no _SOURCE_SUFFIXES file) has not touched code.
|
|
144
|
+
_DOC_SUFFIXES = (".md", ".rst", ".txt", ".adoc", ".org")
|
|
145
|
+
_BINARY_OR_DATA_SUFFIXES = (
|
|
146
|
+
".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".ico", ".woff", ".woff2",
|
|
147
|
+
".ttf", ".zip", ".tar", ".gz", ".tgz", ".whl", ".egg", ".so", ".dll",
|
|
148
|
+
".dylib", ".exe", ".o", ".a", ".jar", ".class", ".bin", ".lock", ".sum",
|
|
149
|
+
".csv", ".tsv", ".parquet",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
_TEST_PATH_RE = re.compile(
|
|
153
|
+
r"(^|/)(tests?|spec|specs|__tests__)(/|$)|"
|
|
154
|
+
r"(^|/)(test_[^/]+|[^/]+_test|[^/]+\.test|[^/]+\.spec)\.[A-Za-z0-9]+$",
|
|
155
|
+
re.IGNORECASE,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# CI-config locations — the canonical places CI behavior is DECLARED. A ci-scoped
|
|
159
|
+
# claim (see `_scope_is_ci`) is witnessed by a diff touching one of these, the way
|
|
160
|
+
# a doc-scoped claim is witnessed by a doc file. CLOSED set, matched structurally
|
|
161
|
+
# (the workflows dir prefix + a yaml suffix, or an exact well-known filename) —
|
|
162
|
+
# never a substring, never "any .yml" (a stray config.yml is not CI).
|
|
163
|
+
_CI_WORKFLOW_DIR = ".github/workflows/"
|
|
164
|
+
_CI_CONFIG_FILES = (
|
|
165
|
+
".gitlab-ci.yml", ".travis.yml", "azure-pipelines.yml", "appveyor.yml",
|
|
166
|
+
".appveyor.yml", ".circleci/config.yml", "bitbucket-pipelines.yml",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclasses.dataclass(frozen=True)
|
|
171
|
+
class CommitClaim:
|
|
172
|
+
"""The claim side: what the commit SUBJECT asserts (author-authored, forgeable)."""
|
|
173
|
+
sha: str
|
|
174
|
+
subject: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclasses.dataclass(frozen=True)
|
|
178
|
+
class DiffFacts:
|
|
179
|
+
"""The witness side: what the commit's DIFF actually did (machinery-authored).
|
|
180
|
+
|
|
181
|
+
`files` is the set of repo-relative paths the commit touched. `is_empty` is
|
|
182
|
+
True for a `--allow-empty` commit (zero files). `test_lines_added` /
|
|
183
|
+
`test_lines_removed` are the net test-file line deltas when known (−1/−1 means
|
|
184
|
+
"not computed"; the verdict then falls back to test-file presence alone).
|
|
185
|
+
"""
|
|
186
|
+
files: tuple[str, ...]
|
|
187
|
+
is_empty: bool
|
|
188
|
+
test_lines_added: int = -1
|
|
189
|
+
test_lines_removed: int = -1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclasses.dataclass(frozen=True)
|
|
193
|
+
class ClaimPolicy:
|
|
194
|
+
"""Knobs (all conservative by default). Kept tiny — the point is zero-config."""
|
|
195
|
+
# Treat a commit touching only docs as a doc claim even if the verb is a code
|
|
196
|
+
# verb? Off by default = we DO fire EMPTY_CLAIM on `fix: ...` touching only a
|
|
197
|
+
# .md (the common "claimed code, edited a doc" case). A repo that commits code
|
|
198
|
+
# fixes as markdown (rare) can turn this on to silence it.
|
|
199
|
+
docs_satisfy_code_claim: bool = False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
DEFAULT_POLICY = ClaimPolicy()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclasses.dataclass(frozen=True)
|
|
206
|
+
class ClaimVerdict:
|
|
207
|
+
"""The graded relationship between a commit's claim and its diff."""
|
|
208
|
+
sha: str
|
|
209
|
+
verdict: Verdict
|
|
210
|
+
claim_kind: ClaimKind
|
|
211
|
+
witness: Witness
|
|
212
|
+
reason: str
|
|
213
|
+
# the files that made the witness call (touched source, tests, or CI config),
|
|
214
|
+
# for an honest, inspectable verdict — never just a boolean.
|
|
215
|
+
source_files: tuple[str, ...] = ()
|
|
216
|
+
test_files: tuple[str, ...] = ()
|
|
217
|
+
ci_files: tuple[str, ...] = ()
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict:
|
|
220
|
+
return {
|
|
221
|
+
"sha": self.sha,
|
|
222
|
+
"verdict": self.verdict.value,
|
|
223
|
+
"claim_kind": self.claim_kind.value,
|
|
224
|
+
"witness": self.witness.value,
|
|
225
|
+
"reason": self.reason,
|
|
226
|
+
"source_files": list(self.source_files),
|
|
227
|
+
"test_files": list(self.test_files),
|
|
228
|
+
"ci_files": list(self.ci_files),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Pure helpers — classify the claim, classify the diff. No I/O.
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _norm(path: str) -> str:
|
|
238
|
+
return path.replace("\\", "/").strip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_source(path: str) -> bool:
|
|
242
|
+
p = _norm(path).lower()
|
|
243
|
+
if p.endswith(_SOURCE_SUFFIXES):
|
|
244
|
+
return True
|
|
245
|
+
if p.endswith(_DOC_SUFFIXES) or p.endswith(_BINARY_OR_DATA_SUFFIXES):
|
|
246
|
+
return False
|
|
247
|
+
# Unknown suffix / extensionless (Makefile, Dockerfile, a bare script): treat
|
|
248
|
+
# as source. Erring toward source keeps EMPTY_CLAIM conservative.
|
|
249
|
+
base = p.rsplit("/", 1)[-1]
|
|
250
|
+
if base in ("makefile", "dockerfile", "rakefile", "gemfile", "procfile"):
|
|
251
|
+
return True
|
|
252
|
+
if "." not in base:
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _is_test(path: str) -> bool:
|
|
258
|
+
return bool(_TEST_PATH_RE.search(_norm(path)))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _is_ci_config(path: str) -> bool:
|
|
262
|
+
p = _norm(path).lower()
|
|
263
|
+
if p.startswith(_CI_WORKFLOW_DIR) and p.endswith((".yml", ".yaml")):
|
|
264
|
+
return True
|
|
265
|
+
return p in _CI_CONFIG_FILES
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _leading_words(subject: str) -> list[str]:
|
|
269
|
+
"""The words that can LEAD a claim — at most one per meaningful segment. Pure.
|
|
270
|
+
|
|
271
|
+
A commit subject puts the verb in one of two places:
|
|
272
|
+
* the conventional-commit TYPE — `fix(parser): …` / `fix: …` → verb `fix`;
|
|
273
|
+
* the REMAINDER after a scope/path prefix that has no verb of its own —
|
|
274
|
+
`benchmark/foo: implement X` / `mcp: add Y` → verb `implement`/`add`. DOS's
|
|
275
|
+
own `path/scope: <verb> …` grammar lives here, and the old parser missed it
|
|
276
|
+
(it only read the pre-colon head), so every scoped subject false-ABSTAINed.
|
|
277
|
+
|
|
278
|
+
Returns the FIRST word of the prefix AND the FIRST word of the remainder (when
|
|
279
|
+
the subject is `<scope-shaped-prefix>: <rest>`), else just the subject's first
|
|
280
|
+
word. Returning only the *leading* word of each segment — not a window — is what
|
|
281
|
+
keeps the widening conservative: a verb BURIED later (`kernel: report the fix
|
|
282
|
+
status` → leads are `kernel`, `report`; `fix` is not a lead) does NOT fire. Only
|
|
283
|
+
a verb that genuinely begins the type or the scoped message is seen.
|
|
284
|
+
"""
|
|
285
|
+
s = subject.strip().lower()
|
|
286
|
+
leads: list[str] = []
|
|
287
|
+
if ":" in s:
|
|
288
|
+
head, rest = s.split(":", 1)
|
|
289
|
+
if len(head) < 30 and " " not in head.strip():
|
|
290
|
+
hw = re.findall(r"[a-z][a-z']*", head)
|
|
291
|
+
rw = re.findall(r"[a-z][a-z']*", rest)
|
|
292
|
+
if hw:
|
|
293
|
+
leads.append(hw[0])
|
|
294
|
+
if rw:
|
|
295
|
+
leads.append(rw[0])
|
|
296
|
+
return leads
|
|
297
|
+
w = re.findall(r"[a-z][a-z']*", s)
|
|
298
|
+
return w[:1]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _phrase_window(subject: str, n: int = 8) -> str:
|
|
302
|
+
"""The lowercased first-n-words string, for multi-word marker matching
|
|
303
|
+
(`unit test`, `tests pass`) that is not position-locked to a single lead."""
|
|
304
|
+
s = subject.strip().lower()
|
|
305
|
+
# drop a scope-shaped prefix so "tests pass" in the remainder is reached
|
|
306
|
+
if ":" in s:
|
|
307
|
+
head, rest = s.split(":", 1)
|
|
308
|
+
if len(head) < 30 and " " not in head.strip():
|
|
309
|
+
s = rest.strip()
|
|
310
|
+
words = re.findall(r"[a-z][a-z']*", s)[:n]
|
|
311
|
+
return " ".join(words)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def classify_claim(subject: str) -> ClaimKind:
|
|
315
|
+
"""What KIND of claim does this subject make? Pure, conservative.
|
|
316
|
+
|
|
317
|
+
Order matters: an explicit no-claim marker wins (so `chore: bump deps` is
|
|
318
|
+
NONE, not CODE_EFFECT on `bump`); then a TEST claim; then a DOC claim; then a
|
|
319
|
+
CODE_EFFECT verb; else NONE.
|
|
320
|
+
"""
|
|
321
|
+
s = subject.strip().lower()
|
|
322
|
+
if not s:
|
|
323
|
+
return ClaimKind.NONE
|
|
324
|
+
# Merge/revert/bump/wip → no checkable behavioral claim. Match WHOLE WORDS, never
|
|
325
|
+
# substrings: a substring match made "u**nit** tests" hit the `nit` marker (and
|
|
326
|
+
# would make "con**version**" hit `version`, "life**style**" hit `style`). Build
|
|
327
|
+
# the subject's word set; single-word markers match a word, multi-word markers
|
|
328
|
+
# ("address review") still match as a phrase.
|
|
329
|
+
word_set = set(re.findall(r"[a-z][a-z']*", s))
|
|
330
|
+
single = {m for m in _NOCLAIM_MARKERS if " " not in m}
|
|
331
|
+
multi = [m for m in _NOCLAIM_MARKERS if " " in m]
|
|
332
|
+
if (word_set & single) or any(m in s for m in multi):
|
|
333
|
+
return ClaimKind.NONE
|
|
334
|
+
leads = _leading_words(s) # ≤1 verb-candidate per segment (conservative)
|
|
335
|
+
window = _phrase_window(s) # the remainder, for multi-word marker phrases
|
|
336
|
+
# TEST claim: a "tests pass" phrase, a test marker LEADING a segment, or a
|
|
337
|
+
# test noun in the first few words of the message ("add tests", "write specs",
|
|
338
|
+
# "unit test the engine") — checked BEFORE the code verb so `add tests` is a
|
|
339
|
+
# TEST claim, not a generic CODE_EFFECT (they differ: the test path also gates
|
|
340
|
+
# the delete-the-assertion shape).
|
|
341
|
+
if any(p in s for p in _TEST_PASS_PHRASES):
|
|
342
|
+
return ClaimKind.TEST
|
|
343
|
+
if any(w in _TEST_MARKERS for w in leads):
|
|
344
|
+
return ClaimKind.TEST
|
|
345
|
+
window_words = window.split()
|
|
346
|
+
if any(w in ("test", "tests", "testing", "spec", "specs", "coverage")
|
|
347
|
+
for w in window_words[:3]):
|
|
348
|
+
return ClaimKind.TEST
|
|
349
|
+
# DOC claim: a doc marker LEADING a segment (honestly scoped to docs), OR the
|
|
350
|
+
# scope-prefix itself NAMES a doc target (`CLAUDE.md: add a glossary`,
|
|
351
|
+
# `README: …`, `docs/206: …`). A `<doc-file>: <action verb> …` subject is a
|
|
352
|
+
# documentation edit even though the verb is `add`/`update` — scoping to a doc
|
|
353
|
+
# file is itself the honest "this is docs" signal, so it must not read as an
|
|
354
|
+
# unwitnessed CODE_EFFECT (the e2d5aa9/b2f58fb/f4a36aa real-history finding).
|
|
355
|
+
if any(w in _DOC_MARKERS for w in leads) or _scope_is_doc(s):
|
|
356
|
+
return ClaimKind.DOC
|
|
357
|
+
# CODE_EFFECT: a code verb LEADING the type or the scoped message.
|
|
358
|
+
if any(w in _CODE_VERBS for w in leads):
|
|
359
|
+
return ClaimKind.CODE_EFFECT
|
|
360
|
+
return ClaimKind.NONE
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _scope_is_doc(subject: str) -> bool:
|
|
364
|
+
"""True iff a scope-shaped prefix (`<prefix>: …`) names a documentation target —
|
|
365
|
+
a doc suffix (`.md`/`.rst`/…), a known doc file (`CLAUDE.md`/`README`/`LICENSE`),
|
|
366
|
+
or a `docs/` path. Pure."""
|
|
367
|
+
s = subject.strip().lower()
|
|
368
|
+
if ":" not in s:
|
|
369
|
+
return False
|
|
370
|
+
head = s.split(":", 1)[0].strip()
|
|
371
|
+
if " " in head or len(head) >= 40:
|
|
372
|
+
return False
|
|
373
|
+
if head.endswith(_DOC_SUFFIXES):
|
|
374
|
+
return True
|
|
375
|
+
if head.startswith(("docs/", "doc/")):
|
|
376
|
+
return True
|
|
377
|
+
base = head.rsplit("/", 1)[-1]
|
|
378
|
+
return base in ("readme", "claude.md", "claude", "license", "contributing",
|
|
379
|
+
"changelog", "authors", "notice")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_CC_HEAD_RE = re.compile(r"^([a-z][a-z0-9_-]*)(?:\(([^)]*)\))?!?$")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _scope_is_ci(subject: str) -> bool:
|
|
386
|
+
"""True iff the conventional-commit head names CI as its TYPE or SCOPE —
|
|
387
|
+
`ci: …`, `ci(test): …`, `fix(ci): …`. STRUCTURAL token equality on the parsed
|
|
388
|
+
head, never a word-search: "fix the CI cache" in prose does not engage, nor
|
|
389
|
+
does a scope merely containing the letters (`fix(circus):` is not CI). Pure."""
|
|
390
|
+
s = subject.strip().lower()
|
|
391
|
+
if ":" not in s:
|
|
392
|
+
return False
|
|
393
|
+
m = _CC_HEAD_RE.match(s.split(":", 1)[0].strip())
|
|
394
|
+
if not m:
|
|
395
|
+
return False
|
|
396
|
+
return m.group(1) == "ci" or m.group(2) == "ci"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _is_pure_move_or_rename(subject: str) -> bool:
|
|
400
|
+
"""A rename/move legitimately touches many files with little net line change —
|
|
401
|
+
do not fire EMPTY_CLAIM/UNWITNESSED on it from line deltas alone."""
|
|
402
|
+
words = _leading_words(subject)
|
|
403
|
+
return any(w in ("rename", "move", "moved", "renamed") for w in words)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def classify(claim: CommitClaim, diff: DiffFacts,
|
|
407
|
+
policy: ClaimPolicy = DEFAULT_POLICY) -> ClaimVerdict:
|
|
408
|
+
"""THE verdict: does the commit's claim match its diff? Pure.
|
|
409
|
+
|
|
410
|
+
Author-neutral, plan-free. Fires `CLAIM_UNWITNESSED` only where a concrete
|
|
411
|
+
claim and a contradicting diff coexist; `ABSTAIN`s on everything else (most
|
|
412
|
+
commits). The witness rung (`diff-witnessed`/`subject-only`) is reported on
|
|
413
|
+
every commit that makes a code/test claim, so even an `OK` carries its rung.
|
|
414
|
+
"""
|
|
415
|
+
kind = classify_claim(claim.subject)
|
|
416
|
+
source_files = tuple(f for f in diff.files if _is_source(f))
|
|
417
|
+
test_files = tuple(f for f in diff.files if _is_test(f))
|
|
418
|
+
ci_files = tuple(f for f in diff.files if _is_ci_config(f))
|
|
419
|
+
# A ci-scoped claim is witnessed by the CI config itself — the scope names
|
|
420
|
+
# where the claimed effect lives, so the workflows diff is corroboration, not
|
|
421
|
+
# contradiction. CONJUNCTIVE on purpose: the shaped claim alone (a ci scope
|
|
422
|
+
# over a README diff) or the path alone (a bare `fix:` over a workflows diff)
|
|
423
|
+
# changes nothing, so this can only suppress an over-fire, never add one.
|
|
424
|
+
ci_witnessed = bool(ci_files) and _scope_is_ci(claim.subject)
|
|
425
|
+
|
|
426
|
+
if kind is ClaimKind.NONE:
|
|
427
|
+
return ClaimVerdict(
|
|
428
|
+
sha=claim.sha, verdict=Verdict.ABSTAIN, claim_kind=kind,
|
|
429
|
+
witness=Witness.ABSTAIN,
|
|
430
|
+
reason="subject makes no checkable code/test claim",
|
|
431
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
432
|
+
|
|
433
|
+
if kind is ClaimKind.DOC:
|
|
434
|
+
# A doc claim is witnessed by ANY touched file (docs count); it never
|
|
435
|
+
# over-claims code, so it is OK as long as it is not empty.
|
|
436
|
+
if diff.is_empty:
|
|
437
|
+
return ClaimVerdict(
|
|
438
|
+
sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
|
|
439
|
+
witness=Witness.SUBJECT_ONLY,
|
|
440
|
+
reason="doc claim but the commit is empty (touched nothing)",
|
|
441
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
442
|
+
return ClaimVerdict(
|
|
443
|
+
sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
|
|
444
|
+
witness=Witness.DIFF_WITNESSED,
|
|
445
|
+
reason="doc claim, the commit touches files (doc scope, no code over-claim)",
|
|
446
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
447
|
+
|
|
448
|
+
if kind is ClaimKind.TEST:
|
|
449
|
+
# A test claim must touch a test file. Net-deleting test lines while
|
|
450
|
+
# claiming "tests pass/green" is the delete-the-assertion shape.
|
|
451
|
+
net_test_delta_known = (diff.test_lines_added >= 0
|
|
452
|
+
and diff.test_lines_removed >= 0)
|
|
453
|
+
net_deleted = (net_test_delta_known
|
|
454
|
+
and diff.test_lines_removed > diff.test_lines_added)
|
|
455
|
+
if not test_files:
|
|
456
|
+
return ClaimVerdict(
|
|
457
|
+
sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
|
|
458
|
+
witness=Witness.SUBJECT_ONLY,
|
|
459
|
+
reason="claims tests but the diff touches no test file",
|
|
460
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
461
|
+
if net_deleted and not _is_pure_move_or_rename(claim.subject):
|
|
462
|
+
return ClaimVerdict(
|
|
463
|
+
sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
|
|
464
|
+
witness=Witness.SUBJECT_ONLY,
|
|
465
|
+
reason=(f"claims tests but net-DELETES test lines "
|
|
466
|
+
f"(+{diff.test_lines_added}/-{diff.test_lines_removed}) "
|
|
467
|
+
"— the delete-the-assertion shape"),
|
|
468
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
469
|
+
return ClaimVerdict(
|
|
470
|
+
sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
|
|
471
|
+
witness=Witness.DIFF_WITNESSED,
|
|
472
|
+
reason="test claim witnessed by a touched test file",
|
|
473
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
474
|
+
|
|
475
|
+
# kind is CODE_EFFECT
|
|
476
|
+
if source_files:
|
|
477
|
+
return ClaimVerdict(
|
|
478
|
+
sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
|
|
479
|
+
witness=Witness.DIFF_WITNESSED,
|
|
480
|
+
reason="code-effect claim witnessed by a touched source file",
|
|
481
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
482
|
+
if ci_witnessed:
|
|
483
|
+
return ClaimVerdict(
|
|
484
|
+
sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
|
|
485
|
+
witness=Witness.DIFF_WITNESSED,
|
|
486
|
+
reason=("ci-scoped claim witnessed by the CI config it names "
|
|
487
|
+
"(the scope is where the claimed effect lives)"),
|
|
488
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
489
|
+
# No source touched. Empty, or docs/config/binary only → the claim rests on the
|
|
490
|
+
# subject text alone. (A pure rename can land here with renames git records as
|
|
491
|
+
# add+delete pairs — but those ARE source files, so source_files is non-empty;
|
|
492
|
+
# the move guard is for the line-delta path, not this presence check.)
|
|
493
|
+
if diff.is_empty:
|
|
494
|
+
why = "code-effect claim but the commit is EMPTY (touched no files)"
|
|
495
|
+
elif policy.docs_satisfy_code_claim:
|
|
496
|
+
return ClaimVerdict(
|
|
497
|
+
sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
|
|
498
|
+
witness=Witness.DIFF_WITNESSED,
|
|
499
|
+
reason="code claim; docs_satisfy_code_claim policy accepts doc-only diff",
|
|
500
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
501
|
+
else:
|
|
502
|
+
touched = ", ".join(diff.files[:4]) or "(nothing)"
|
|
503
|
+
why = (f"code-effect claim but the diff touches no SOURCE file "
|
|
504
|
+
f"(only: {touched}) — the claim rests on the subject text")
|
|
505
|
+
return ClaimVerdict(
|
|
506
|
+
sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
|
|
507
|
+
witness=Witness.SUBJECT_ONLY, reason=why,
|
|
508
|
+
source_files=source_files, test_files=test_files, ci_files=ci_files)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
# Sweep summary — the "how honest are this repo's commit messages?" rate. Pure.
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def sweep_summary(verdicts: list[ClaimVerdict]) -> dict:
|
|
517
|
+
"""Fold a list of `ClaimVerdict`s into the drift RATE + a by-claim-kind grid.
|
|
518
|
+
|
|
519
|
+
The headline `drift_rate` is `unwitnessed / checkable`, where *checkable* =
|
|
520
|
+
commits that made a concrete code/test/doc claim (everything that did NOT
|
|
521
|
+
ABSTAIN). Abstains are excluded from the denominator on purpose: a `wip`/`merge`
|
|
522
|
+
commit makes no claim to be honest or dishonest about, so counting it would dilute
|
|
523
|
+
the rate toward zero and hide real drift. We report both denominators so the
|
|
524
|
+
reader sees how much of the history was checkable at all.
|
|
525
|
+
|
|
526
|
+
This is a RATE over real history, not a per-commit gate — the docs/179 caution
|
|
527
|
+
applies: it re-describes the corpus it is given (it mints no new label), so it is
|
|
528
|
+
a description of THIS repo's commit hygiene, not a generalization.
|
|
529
|
+
"""
|
|
530
|
+
total = len(verdicts)
|
|
531
|
+
unwitnessed = [v for v in verdicts if v.verdict is Verdict.CLAIM_UNWITNESSED]
|
|
532
|
+
witnessed = [v for v in verdicts if v.verdict is Verdict.OK]
|
|
533
|
+
abstained = [v for v in verdicts if v.verdict is Verdict.ABSTAIN]
|
|
534
|
+
checkable = len(unwitnessed) + len(witnessed)
|
|
535
|
+
|
|
536
|
+
by_kind: dict[str, dict[str, int]] = {}
|
|
537
|
+
for v in verdicts:
|
|
538
|
+
k = v.claim_kind.value
|
|
539
|
+
row = by_kind.setdefault(k, {"unwitnessed": 0, "witnessed": 0, "abstain": 0})
|
|
540
|
+
if v.verdict is Verdict.CLAIM_UNWITNESSED:
|
|
541
|
+
row["unwitnessed"] += 1
|
|
542
|
+
elif v.verdict is Verdict.OK:
|
|
543
|
+
row["witnessed"] += 1
|
|
544
|
+
else:
|
|
545
|
+
row["abstain"] += 1
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
"commits": total,
|
|
549
|
+
"checkable": checkable, # made a concrete claim (the denominator)
|
|
550
|
+
"abstained": len(abstained), # no checkable claim
|
|
551
|
+
"witnessed": len(witnessed),
|
|
552
|
+
"unwitnessed": len(unwitnessed),
|
|
553
|
+
"drift_rate": (len(unwitnessed) / checkable) if checkable else 0.0,
|
|
554
|
+
"by_kind": by_kind,
|
|
555
|
+
# the actual offenders, so the rate is inspectable, never just a number
|
|
556
|
+
"unwitnessed_shas": [v.sha for v in unwitnessed],
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ---------------------------------------------------------------------------
|
|
561
|
+
# Boundary I/O — read a commit's subject + diff facts from git. NOT pure.
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
#
|
|
564
|
+
# The git reads happen HERE (the `git_delta`/`liveness` discipline); `classify`
|
|
565
|
+
# above never shells out. `root` is passed EXPLICITLY (never the process-global
|
|
566
|
+
# config) so a long-lived caller — the MCP server, a CI runner — gets the right
|
|
567
|
+
# tree. Every failure degrades to a safe value (an empty/abstaining read), never a
|
|
568
|
+
# crash: a non-git dir, a missing binary, a bad ref, a timeout.
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _git(root: Path | str, *args: str) -> tuple[int, str]:
|
|
572
|
+
try:
|
|
573
|
+
r = subprocess.run(["git", "-C", str(root), *args],
|
|
574
|
+
capture_output=True, text=True, check=False,
|
|
575
|
+
timeout=_GIT_TIMEOUT_S)
|
|
576
|
+
except (OSError, subprocess.SubprocessError):
|
|
577
|
+
return 1, ""
|
|
578
|
+
return r.returncode, r.stdout
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def read_commit(ref: str, *, root: Path | str) -> tuple[CommitClaim, DiffFacts] | None:
|
|
582
|
+
"""Read one commit's subject + diff facts from git at ``ref`` over ``root``.
|
|
583
|
+
|
|
584
|
+
Returns ``(CommitClaim, DiffFacts)`` ready for `classify`, or ``None`` if the
|
|
585
|
+
ref can't be read (non-git dir, bad ref, git missing). Uses ``--numstat`` over
|
|
586
|
+
the first parent so a merge commit reports its own delta; binary files report
|
|
587
|
+
``-``/``-`` numstat and are counted as touched files but contribute no line
|
|
588
|
+
delta.
|
|
589
|
+
"""
|
|
590
|
+
rc, sha = _git(root, "rev-parse", "--short", ref)
|
|
591
|
+
if rc != 0 or not sha.strip():
|
|
592
|
+
return None
|
|
593
|
+
sha = sha.strip()
|
|
594
|
+
rc, subject = _git(root, "log", "-1", "--pretty=format:%s", ref)
|
|
595
|
+
if rc != 0:
|
|
596
|
+
return None
|
|
597
|
+
subject = subject.strip()
|
|
598
|
+
|
|
599
|
+
# numstat: "<added>\t<removed>\t<path>" per file; binary → "-\t-\t<path>".
|
|
600
|
+
rc, numstat = _git(root, "show", "--numstat", "--format=", "--no-renames", ref)
|
|
601
|
+
files: list[str] = []
|
|
602
|
+
test_add = test_rem = 0
|
|
603
|
+
have_test_delta = False
|
|
604
|
+
if rc == 0:
|
|
605
|
+
for line in numstat.splitlines():
|
|
606
|
+
line = line.rstrip("\n")
|
|
607
|
+
if not line.strip():
|
|
608
|
+
continue
|
|
609
|
+
parts = line.split("\t")
|
|
610
|
+
if len(parts) != 3:
|
|
611
|
+
continue
|
|
612
|
+
added_s, removed_s, path = parts
|
|
613
|
+
path = path.strip()
|
|
614
|
+
if not path:
|
|
615
|
+
continue
|
|
616
|
+
files.append(path)
|
|
617
|
+
if _is_test(path) and added_s != "-" and removed_s != "-":
|
|
618
|
+
try:
|
|
619
|
+
test_add += int(added_s)
|
|
620
|
+
test_rem += int(removed_s)
|
|
621
|
+
have_test_delta = True
|
|
622
|
+
except ValueError:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
diff = DiffFacts(
|
|
626
|
+
files=tuple(files),
|
|
627
|
+
is_empty=(len(files) == 0),
|
|
628
|
+
test_lines_added=(test_add if have_test_delta else -1),
|
|
629
|
+
test_lines_removed=(test_rem if have_test_delta else -1),
|
|
630
|
+
)
|
|
631
|
+
return CommitClaim(sha=sha, subject=subject), diff
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def audit_commit(ref: str = "HEAD", *, root: Path | str = ".",
|
|
635
|
+
policy: ClaimPolicy = DEFAULT_POLICY) -> ClaimVerdict | None:
|
|
636
|
+
"""Read commit ``ref`` and return its `ClaimVerdict`, or ``None`` if unreadable.
|
|
637
|
+
|
|
638
|
+
The one-call convenience the CLI / a pre-commit hook / the MCP tool use.
|
|
639
|
+
"""
|
|
640
|
+
got = read_commit(ref, root=root)
|
|
641
|
+
if got is None:
|
|
642
|
+
return None
|
|
643
|
+
claim, diff = got
|
|
644
|
+
return classify(claim, diff, policy)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def audit_range(rev_range: str, *, root: Path | str = ".",
|
|
648
|
+
policy: ClaimPolicy = DEFAULT_POLICY,
|
|
649
|
+
limit: int = 500) -> list[ClaimVerdict]:
|
|
650
|
+
"""Audit every commit in a `git log` range (e.g. ``origin/main..HEAD``).
|
|
651
|
+
|
|
652
|
+
Returns newest-first `ClaimVerdict`s, capped at ``limit`` so a huge range
|
|
653
|
+
can't hang an audit. Empty list on any read failure (the fail-safe floor).
|
|
654
|
+
"""
|
|
655
|
+
rc, out = _git(root, "log", f"-{int(limit)}", "--pretty=format:%H", rev_range)
|
|
656
|
+
if rc != 0:
|
|
657
|
+
return []
|
|
658
|
+
verdicts: list[ClaimVerdict] = []
|
|
659
|
+
for sha in out.splitlines():
|
|
660
|
+
sha = sha.strip()
|
|
661
|
+
if not sha:
|
|
662
|
+
continue
|
|
663
|
+
v = audit_commit(sha, root=root, policy=policy)
|
|
664
|
+
if v is not None:
|
|
665
|
+
verdicts.append(v)
|
|
666
|
+
return verdicts
|