holt-cli 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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/discover.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""User-directed discovery: source candidates, screen them for free, analyse survivors.
|
|
2
|
+
|
|
3
|
+
The claim discipline is the whole point of this module:
|
|
4
|
+
|
|
5
|
+
* **We claim the filter.** Screening applies the same rules as `verdict.py` —
|
|
6
|
+
rubber-stamp (validated out of sample: specificity 0.83 on a pool drawn after
|
|
7
|
+
the rule was written), hostile, slow-response against the user's stated day
|
|
8
|
+
budget, and the outsider-merge floor. On the trap repositories the rules
|
|
9
|
+
reject 4 of 5 in every recorded run.
|
|
10
|
+
* **We do not claim the sourcing or the ordering.** Candidates come from GitHub
|
|
11
|
+
repository search and the output says so. Rows come out in screening order.
|
|
12
|
+
* Screening runs at reduced crawl depth (the newest page of pull-request
|
|
13
|
+
threads) so its numbers are noisier than the benchmark's; survivors are
|
|
14
|
+
re-crawled at full depth before anything is asserted about them.
|
|
15
|
+
|
|
16
|
+
The structural fact that makes screening free: `verdict.py` needs exactly one
|
|
17
|
+
model-derived input, `repo_kind`. Every other rule is arithmetic over crawled
|
|
18
|
+
signals. So screening runs **no model at all**; the one thing it cannot do is
|
|
19
|
+
tell a registry from a software project, and the full analysis of the survivors
|
|
20
|
+
is where that gets caught.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Callable, Iterable
|
|
30
|
+
|
|
31
|
+
from holt import model
|
|
32
|
+
from holt.agent import landing as landing_mod
|
|
33
|
+
from holt.agent.findings import Findings
|
|
34
|
+
from holt.agent.pipeline import analyze
|
|
35
|
+
from holt.agent.signals import Signals, build_threads, compute
|
|
36
|
+
from holt.agent.verdict import classify
|
|
37
|
+
from holt.evidence.fixtures import FixtureProvider, write_fixture
|
|
38
|
+
from holt.evidence.provider import EvidenceProvider
|
|
39
|
+
from holt.profile import CONTRIBUTION_AREAS, Profile
|
|
40
|
+
from holt.report import Verdict
|
|
41
|
+
from holt.types import EvidenceRecord, Window
|
|
42
|
+
|
|
43
|
+
DISCOVER_ROOT = Path("fixtures/discover")
|
|
44
|
+
DISCOVER_TRAJECTORIES = "discover"
|
|
45
|
+
|
|
46
|
+
# Screening reads one page of pull-request threads; the full analysis reads up
|
|
47
|
+
# to eight. Both numbers are printed, not implied.
|
|
48
|
+
SCREEN_PAGES = 1
|
|
49
|
+
FULL_PAGES = 8
|
|
50
|
+
|
|
51
|
+
# Sourcing recency: a repository nobody pushed to in this window has no fresh
|
|
52
|
+
# threads to screen. A sourcing choice, disclosed in the printed query.
|
|
53
|
+
RECENT_PUSH_DAYS = 60
|
|
54
|
+
|
|
55
|
+
CAT_ARCHIVED = "archived"
|
|
56
|
+
CAT_NO_LANDING = "nobody outside has landed work in"
|
|
57
|
+
CAT_SLOW = "replies too slow for the day budget"
|
|
58
|
+
CAT_RUBBER_STAMP = "work merged without review (the rubber-stamp rule)"
|
|
59
|
+
CAT_HOSTILE = "outsider attempts went unanswered"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class Candidate:
|
|
64
|
+
slug: str
|
|
65
|
+
description: str | None = None
|
|
66
|
+
stars: int = 0
|
|
67
|
+
language: str | None = None
|
|
68
|
+
pushed_at: str | None = None
|
|
69
|
+
|
|
70
|
+
def as_dict(self) -> dict[str, Any]:
|
|
71
|
+
return {"slug": self.slug, "description": self.description, "stars": self.stars,
|
|
72
|
+
"language": self.language, "pushed_at": self.pushed_at}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True)
|
|
76
|
+
class Screened:
|
|
77
|
+
candidate: Candidate
|
|
78
|
+
verdict: Verdict
|
|
79
|
+
trace: list[str]
|
|
80
|
+
signals: Signals
|
|
81
|
+
category: str | None # None means the candidate survived
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_queries(profile: Profile, as_of: datetime) -> list[str]:
|
|
85
|
+
"""One search query per language: GitHub ANDs repeated `language:` qualifiers,
|
|
86
|
+
so two languages in one query would match nothing."""
|
|
87
|
+
pushed = (as_of - timedelta(days=RECENT_PUSH_DAYS)).date().isoformat()
|
|
88
|
+
base = [f"topic:{t}" for t in profile.topics]
|
|
89
|
+
base += [f"pushed:>{pushed}", "archived:false", "fork:false", "stars:>=10"]
|
|
90
|
+
if not profile.languages:
|
|
91
|
+
return [" ".join(base)]
|
|
92
|
+
return [" ".join([f"language:{lang}", *base]) for lang in profile.languages]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def source(transport, profile: Profile, as_of: datetime, limit: int) -> tuple[list[Candidate], list[str]]:
|
|
96
|
+
"""Candidates from GitHub repository search. Sourcing only — no claim."""
|
|
97
|
+
queries = build_queries(profile, as_of)
|
|
98
|
+
per_query = max(1, limit // len(queries))
|
|
99
|
+
seen: set[str] = set()
|
|
100
|
+
out: list[Candidate] = []
|
|
101
|
+
for q in queries:
|
|
102
|
+
taken = 0
|
|
103
|
+
for node in transport.search_repositories(q, max_pages=(per_query // 25) + 1):
|
|
104
|
+
slug = node["nameWithOwner"]
|
|
105
|
+
if slug in seen or node.get("isArchived") or node.get("isFork"):
|
|
106
|
+
continue
|
|
107
|
+
seen.add(slug)
|
|
108
|
+
out.append(Candidate(
|
|
109
|
+
slug=slug,
|
|
110
|
+
description=node.get("description"),
|
|
111
|
+
stars=node.get("stargazerCount") or 0,
|
|
112
|
+
language=((node.get("primaryLanguage") or {}).get("name")),
|
|
113
|
+
pushed_at=node.get("pushedAt"),
|
|
114
|
+
))
|
|
115
|
+
taken += 1
|
|
116
|
+
if taken >= per_query:
|
|
117
|
+
break
|
|
118
|
+
return out, queries
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def screen_records(candidate: Candidate, records: list[EvidenceRecord],
|
|
122
|
+
days: int) -> Screened:
|
|
123
|
+
"""The free pass. Arithmetic over crawled signals; no model is called, so
|
|
124
|
+
`repo_kind` is unknown here and the kind rules cannot fire."""
|
|
125
|
+
signals = compute(build_threads(records))
|
|
126
|
+
findings = Findings()
|
|
127
|
+
for r in records:
|
|
128
|
+
if r.evidence_id.endswith(":meta"):
|
|
129
|
+
findings.add("is_archived", bool(r.payload.get("is_archived")),
|
|
130
|
+
(r.evidence_id,))
|
|
131
|
+
break
|
|
132
|
+
verdict, trace = classify(findings, signals, contributor_days=days)
|
|
133
|
+
return Screened(candidate, verdict, trace, signals, _categorise(verdict, trace))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _categorise(verdict: Verdict, trace: list[str]) -> str | None:
|
|
137
|
+
"""Bucket a rejection for the summary. Keyed to the wording `verdict.py`
|
|
138
|
+
emits; a test walks every bucket so a rewording fails loudly here."""
|
|
139
|
+
if verdict is Verdict.VIABLE:
|
|
140
|
+
return None
|
|
141
|
+
joined = " ".join(trace)
|
|
142
|
+
if "archived" in joined:
|
|
143
|
+
return CAT_ARCHIVED
|
|
144
|
+
if "waved through unread" in joined:
|
|
145
|
+
return CAT_RUBBER_STAMP
|
|
146
|
+
if "exceeds the" in joined:
|
|
147
|
+
return CAT_SLOW
|
|
148
|
+
if "drew no response" in joined:
|
|
149
|
+
return CAT_HOSTILE
|
|
150
|
+
return CAT_NO_LANDING
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PrefetchedProvider(EvidenceProvider):
|
|
154
|
+
"""Serves records already fetched, so live discovery crawls each survivor
|
|
155
|
+
once instead of once for the fixture and once for the analysis."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, window: Window, cutoff: datetime,
|
|
158
|
+
records: list[EvidenceRecord]) -> None:
|
|
159
|
+
super().__init__(window, cutoff)
|
|
160
|
+
self._records = records
|
|
161
|
+
self._by_id = {r.evidence_id: r for r in records}
|
|
162
|
+
|
|
163
|
+
def _fetch_raw(self, request: str, /, **params: object) -> Iterable[EvidenceRecord]:
|
|
164
|
+
return self._records
|
|
165
|
+
|
|
166
|
+
def _resolve_raw(self, evidence_id: str) -> EvidenceRecord | None:
|
|
167
|
+
return self._by_id.get(evidence_id)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def manifest_path(name: str) -> Path:
|
|
171
|
+
return DISCOVER_ROOT / f"{name}.json"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def screen_root(name: str) -> Path:
|
|
175
|
+
return DISCOVER_ROOT / name / "screen"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def full_root(name: str) -> Path:
|
|
179
|
+
return DISCOVER_ROOT / name / "full"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def trajectory_for(slug: str) -> Path:
|
|
183
|
+
return model.TRAJECTORY_DIR / DISCOVER_TRAJECTORIES / (slug.replace("/", "__") + ".jsonl")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(slots=True)
|
|
187
|
+
class ScreenedStep:
|
|
188
|
+
"""One candidate's turn through screening, reported as it lands."""
|
|
189
|
+
|
|
190
|
+
index: int
|
|
191
|
+
total: int
|
|
192
|
+
candidate: Candidate
|
|
193
|
+
result: Screened | None = None
|
|
194
|
+
#: Set when the candidate could not be read at all. Never a rejection —
|
|
195
|
+
#: "we could not look" and "we looked and it failed" are different answers.
|
|
196
|
+
error: str | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(slots=True)
|
|
200
|
+
class LiveSearch:
|
|
201
|
+
"""A live search, cut at the point where a caller wants to see progress.
|
|
202
|
+
|
|
203
|
+
`source_live` is the one repository-search call, and carries everything
|
|
204
|
+
needed to say what was searched for. `screen` is the free pass over the
|
|
205
|
+
candidates, yielded one at a time so an interface can draw a row as it
|
|
206
|
+
lands rather than after the last one does.
|
|
207
|
+
|
|
208
|
+
Both `run_live` and the terminal interface walk this, so what survives is
|
|
209
|
+
decided in one place. Nothing here decides it: `screen_records` does.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
profile: Profile
|
|
213
|
+
as_of: datetime
|
|
214
|
+
queries: list[str]
|
|
215
|
+
candidates: list[Candidate]
|
|
216
|
+
transport: Any
|
|
217
|
+
#: Session name to write fixtures under, or None. Recording is the caller's
|
|
218
|
+
#: choice and changes nothing about what is screened.
|
|
219
|
+
record: str | None = None
|
|
220
|
+
|
|
221
|
+
def screen(self, should_stop: Callable[[], bool] = lambda: False
|
|
222
|
+
) -> Iterable[ScreenedStep]:
|
|
223
|
+
"""Screen each candidate at reduced crawl depth. No model is called.
|
|
224
|
+
|
|
225
|
+
Stops between candidates when `should_stop` says so, which is how an
|
|
226
|
+
interface cancels a sweep without waiting for the rest of it.
|
|
227
|
+
"""
|
|
228
|
+
from holt.evidence.github_graphql import LiveGitHubProvider
|
|
229
|
+
|
|
230
|
+
total = len(self.candidates)
|
|
231
|
+
for index, cand in enumerate(self.candidates, 1):
|
|
232
|
+
if should_stop():
|
|
233
|
+
return
|
|
234
|
+
provider = LiveGitHubProvider(Window.PRE_T, cutoff=self.as_of,
|
|
235
|
+
transport=self.transport,
|
|
236
|
+
max_pages=SCREEN_PAGES)
|
|
237
|
+
try:
|
|
238
|
+
records = provider.fetch(cand.slug)
|
|
239
|
+
except Exception as err: # a dead candidate must not abort the sweep
|
|
240
|
+
yield ScreenedStep(index, total, cand, error=str(err))
|
|
241
|
+
continue
|
|
242
|
+
if self.record:
|
|
243
|
+
write_fixture(cand.slug, Window.PRE_T, records,
|
|
244
|
+
root=screen_root(self.record), cutoff=self.as_of)
|
|
245
|
+
yield ScreenedStep(index, total, cand,
|
|
246
|
+
result=screen_records(cand, records,
|
|
247
|
+
self.profile.days))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def source_live(profile: Profile, limit: int = 25, *,
|
|
251
|
+
as_of: datetime | None = None, transport: Any = None,
|
|
252
|
+
record: str | None = None) -> LiveSearch:
|
|
253
|
+
"""GitHub repository search for the stated profile. Sourcing only.
|
|
254
|
+
|
|
255
|
+
One network call, before any screening, so a caller can say what it
|
|
256
|
+
searched for and how many it found while the slow half is still ahead.
|
|
257
|
+
"""
|
|
258
|
+
from holt.evidence.github_graphql import GitHubGraphQL
|
|
259
|
+
|
|
260
|
+
as_of = as_of or datetime.now(UTC)
|
|
261
|
+
transport = transport or GitHubGraphQL()
|
|
262
|
+
candidates, queries = source(transport, profile, as_of, limit)
|
|
263
|
+
return LiveSearch(profile=profile, as_of=as_of, queries=queries,
|
|
264
|
+
candidates=candidates, transport=transport, record=record)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def contribution_notes(landing: landing_mod.Landing,
|
|
268
|
+
contributions: list[str]) -> list[str]:
|
|
269
|
+
"""Where the kind of work the user wants to do has actually merged.
|
|
270
|
+
|
|
271
|
+
Matched against directories where outsider work landed, for the
|
|
272
|
+
contribution types that map to directories. A count of this sample, not a
|
|
273
|
+
promise.
|
|
274
|
+
"""
|
|
275
|
+
notes: list[str] = []
|
|
276
|
+
for want in contributions:
|
|
277
|
+
hints = CONTRIBUTION_AREAS.get(want)
|
|
278
|
+
if not hints: # "code" and unmappable answers annotate nothing
|
|
279
|
+
continue
|
|
280
|
+
hits = [a for a in landing.landed
|
|
281
|
+
if any(seg in hints for seg in a.path.lower().split("/"))]
|
|
282
|
+
if hits:
|
|
283
|
+
listed = ", ".join(f"`{a.path}` ({a.landed} merged)" for a in hits)
|
|
284
|
+
notes.append(f"{want}: outsider work has merged in {listed}")
|
|
285
|
+
else:
|
|
286
|
+
notes.append(f"{want}: no outsider merge in a matching directory "
|
|
287
|
+
"in this sample")
|
|
288
|
+
return notes
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(slots=True)
|
|
292
|
+
class SurvivorRow:
|
|
293
|
+
slug: str
|
|
294
|
+
verdict: str
|
|
295
|
+
landed: str
|
|
296
|
+
reply: str
|
|
297
|
+
why: str
|
|
298
|
+
notes: list[str]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def analyse_survivor(slug: str, provider: EvidenceProvider, client,
|
|
302
|
+
days: int, as_of: datetime) -> SurvivorRow:
|
|
303
|
+
records = provider.fetch(slug)
|
|
304
|
+
assessment, trace = analyze(slug, provider, client,
|
|
305
|
+
contributor_days=days, as_of=as_of)
|
|
306
|
+
signals = trace.signals
|
|
307
|
+
landing = landing_mod.compute(build_threads(records))
|
|
308
|
+
why = assessment.rules[0] if assessment.rules else "no rule fired"
|
|
309
|
+
return SurvivorRow(
|
|
310
|
+
slug=slug,
|
|
311
|
+
verdict=assessment.verdict.value,
|
|
312
|
+
landed=f"{signals.outsider_merged}/{signals.outsider_threads}",
|
|
313
|
+
reply=(f"{signals.median_first_response_hours:.1f}h"
|
|
314
|
+
if signals.median_first_response_hours is not None else "never"),
|
|
315
|
+
why=why if len(why) <= 58 else why[:57].rstrip(" ,;:") + "…",
|
|
316
|
+
notes=[], # filled by the caller, which knows the profile
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def render(profile: Profile, queries: list[str], screened: list[Screened],
|
|
321
|
+
rows: list[SurvivorRow], *, replayed: bool, as_of: datetime,
|
|
322
|
+
skipped: list[str], unanalysed: int,
|
|
323
|
+
unanalysable: list[str] | None = None) -> str:
|
|
324
|
+
lines = [f"# Discover — {profile.describe()}", ""]
|
|
325
|
+
if replayed:
|
|
326
|
+
lines += ["> Replaying a recorded discovery session captured "
|
|
327
|
+
f"{as_of.date().isoformat()}. No network, no model calls; "
|
|
328
|
+
"the query below is the recorded one, not a fresh search.", ""]
|
|
329
|
+
lines += [f"Candidates come from GitHub repository search — "
|
|
330
|
+
f"{'; '.join(f'`{q}`' for q in queries)} — in search order. "
|
|
331
|
+
"Holt claims the screening below, not the sourcing and not the "
|
|
332
|
+
"ordering.", ""]
|
|
333
|
+
|
|
334
|
+
rejected = [s for s in screened if s.category]
|
|
335
|
+
survivors = [s for s in screened if not s.category]
|
|
336
|
+
lines.append(
|
|
337
|
+
f"Screened {len(screened)} candidates against the newest page of each "
|
|
338
|
+
"repository's pull-request threads — arithmetic only, no model calls, "
|
|
339
|
+
"so these numbers are noisier than a full analysis. "
|
|
340
|
+
f"Rejected {len(rejected)}:"
|
|
341
|
+
)
|
|
342
|
+
lines.append("")
|
|
343
|
+
by_cat: dict[str, int] = {}
|
|
344
|
+
for s in rejected:
|
|
345
|
+
by_cat[s.category] = by_cat.get(s.category, 0) + 1
|
|
346
|
+
for cat, n in sorted(by_cat.items(), key=lambda kv: -kv[1]):
|
|
347
|
+
label = cat.replace("the day budget", f"a {profile.days}-day budget")
|
|
348
|
+
lines.append(f"- {n} {label}")
|
|
349
|
+
if skipped:
|
|
350
|
+
lines.append(f"- {len(skipped)} could not be screened "
|
|
351
|
+
f"({', '.join(skipped)})")
|
|
352
|
+
lines.append("")
|
|
353
|
+
|
|
354
|
+
if not survivors:
|
|
355
|
+
lines += ["No candidate survived screening. Widen the search or the "
|
|
356
|
+
"day budget and try again.", ""]
|
|
357
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
358
|
+
|
|
359
|
+
lines.append(f"Analysed {len(rows)} of {len(survivors)} survivors at full "
|
|
360
|
+
"crawl depth. Rows are in screening order, not ranked.")
|
|
361
|
+
if unanalysed:
|
|
362
|
+
lines.append(f"{unanalysed} survivor(s) not analysed "
|
|
363
|
+
"(over the --max-analyze cap); nothing is claimed about them.")
|
|
364
|
+
if unanalysable:
|
|
365
|
+
lines.append(f"{len(unanalysable)} survivor(s) could not be analysed "
|
|
366
|
+
f"({', '.join(unanalysable)}); nothing is claimed about them.")
|
|
367
|
+
lines.append("")
|
|
368
|
+
|
|
369
|
+
headers = ("repository", "verdict", "outsiders in", "first reply", "why")
|
|
370
|
+
table = [(r.slug, r.verdict, r.landed, r.reply, r.why) for r in rows]
|
|
371
|
+
widths = [max(len(str(row[i])) for row in (*table, headers)) for i in range(5)]
|
|
372
|
+
|
|
373
|
+
def line(cells) -> str:
|
|
374
|
+
return "| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(cells)) + " |"
|
|
375
|
+
|
|
376
|
+
lines.append(line(headers))
|
|
377
|
+
lines.append("|" + "|".join("-" * (w + 2) for w in widths) + "|")
|
|
378
|
+
for r, row in zip(rows, table):
|
|
379
|
+
lines.append(line(row))
|
|
380
|
+
for note in r.notes:
|
|
381
|
+
lines.append(f"| ↳ {note}")
|
|
382
|
+
lines += ["", "`outsiders in` counts pull requests merged from people with "
|
|
383
|
+
"no prior merge here, over the number who tried.",
|
|
384
|
+
"Run `holt analyze <repo>` for the evidence behind any row."]
|
|
385
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def run_replay(name: str, days: int | None = None,
|
|
389
|
+
max_analyze: int = 8) -> str:
|
|
390
|
+
"""Re-run a recorded discovery session with no credentials and no spend.
|
|
391
|
+
|
|
392
|
+
The day budget may be changed: screening and the verdict re-run for free,
|
|
393
|
+
which is the same zero-model-call reparameterisation `--days` gives
|
|
394
|
+
`holt analyze`.
|
|
395
|
+
"""
|
|
396
|
+
manifest = json.loads(manifest_path(name).read_text())
|
|
397
|
+
as_of = datetime.fromisoformat(manifest["as_of"])
|
|
398
|
+
profile = Profile(**manifest["profile"])
|
|
399
|
+
if days:
|
|
400
|
+
profile.days = days
|
|
401
|
+
|
|
402
|
+
candidates = [Candidate(**c) for c in manifest["candidates"]]
|
|
403
|
+
screened, skipped = [], []
|
|
404
|
+
screen_provider = FixtureProvider(Window.PRE_T, root=screen_root(name), cutoff=as_of)
|
|
405
|
+
for cand in candidates:
|
|
406
|
+
try:
|
|
407
|
+
records = screen_provider.fetch(cand.slug)
|
|
408
|
+
except FileNotFoundError:
|
|
409
|
+
skipped.append(cand.slug)
|
|
410
|
+
continue
|
|
411
|
+
screened.append(screen_records(cand, records, profile.days))
|
|
412
|
+
|
|
413
|
+
survivors = [s for s in screened if not s.category]
|
|
414
|
+
rows: list[SurvivorRow] = []
|
|
415
|
+
unanalysable: list[str] = []
|
|
416
|
+
for s in survivors[:max_analyze]:
|
|
417
|
+
provider = FixtureProvider(Window.PRE_T, root=full_root(name), cutoff=as_of)
|
|
418
|
+
try:
|
|
419
|
+
client = model.ReplayModel(trajectory_for(s.candidate.slug))
|
|
420
|
+
except FileNotFoundError:
|
|
421
|
+
unanalysable.append(s.candidate.slug)
|
|
422
|
+
continue
|
|
423
|
+
row = analyse_survivor(s.candidate.slug, provider, client, profile.days, as_of)
|
|
424
|
+
records = provider.fetch(s.candidate.slug)
|
|
425
|
+
row.notes = contribution_notes(
|
|
426
|
+
landing_mod.compute(build_threads(records)), profile.contributions)
|
|
427
|
+
rows.append(row)
|
|
428
|
+
|
|
429
|
+
return render(profile, manifest["queries"], screened, rows, replayed=True,
|
|
430
|
+
as_of=as_of, skipped=skipped,
|
|
431
|
+
unanalysed=max(0, len(survivors) - max_analyze),
|
|
432
|
+
unanalysable=unanalysable)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def run_live(profile: Profile, limit: int = 25, max_analyze: int = 8,
|
|
436
|
+
record: str | None = None,
|
|
437
|
+
progress: Callable[[str], None] = lambda s: None) -> str:
|
|
438
|
+
"""Live discovery. With `record`, every fetch and every model call is
|
|
439
|
+
written down so the session replays byte-for-byte with no credentials."""
|
|
440
|
+
from holt.evidence.github_graphql import LiveGitHubProvider
|
|
441
|
+
|
|
442
|
+
search = source_live(profile, limit, record=record)
|
|
443
|
+
as_of, transport, queries = search.as_of, search.transport, search.queries
|
|
444
|
+
candidates = search.candidates
|
|
445
|
+
progress(f"sourced {len(candidates)} candidates from GitHub repository search")
|
|
446
|
+
|
|
447
|
+
screened, skipped = [], []
|
|
448
|
+
for step in search.screen():
|
|
449
|
+
if step.result is None:
|
|
450
|
+
skipped.append(step.candidate.slug)
|
|
451
|
+
progress(f"[{step.index}/{step.total}] {step.candidate.slug}: "
|
|
452
|
+
f"skipped ({step.error})")
|
|
453
|
+
continue
|
|
454
|
+
screened.append(step.result)
|
|
455
|
+
progress(f"[{step.index}/{step.total}] {step.candidate.slug}: "
|
|
456
|
+
f"{step.result.category or 'survived screening'}")
|
|
457
|
+
|
|
458
|
+
survivors = [s for s in screened if not s.category]
|
|
459
|
+
rows: list[SurvivorRow] = []
|
|
460
|
+
unanalysable: list[str] = []
|
|
461
|
+
for s in survivors[:max_analyze]:
|
|
462
|
+
slug = s.candidate.slug
|
|
463
|
+
provider = LiveGitHubProvider(Window.PRE_T, cutoff=as_of,
|
|
464
|
+
transport=transport, max_pages=FULL_PAGES)
|
|
465
|
+
try:
|
|
466
|
+
records = provider.fetch(slug)
|
|
467
|
+
except Exception as err:
|
|
468
|
+
unanalysable.append(slug)
|
|
469
|
+
progress(f"analyse {slug}: skipped ({err})")
|
|
470
|
+
continue
|
|
471
|
+
if record:
|
|
472
|
+
write_fixture(slug, Window.PRE_T, records,
|
|
473
|
+
root=full_root(record), cutoff=as_of)
|
|
474
|
+
cached = PrefetchedProvider(Window.PRE_T, as_of, records)
|
|
475
|
+
client = model.live_client(trajectory_for(slug))
|
|
476
|
+
row = analyse_survivor(slug, cached, client, profile.days, as_of)
|
|
477
|
+
row.notes = contribution_notes(
|
|
478
|
+
landing_mod.compute(build_threads(records)), profile.contributions)
|
|
479
|
+
rows.append(row)
|
|
480
|
+
progress(f"analysed {slug}: {row.verdict} (${client.usage.cost_usd:.4f})")
|
|
481
|
+
|
|
482
|
+
if record:
|
|
483
|
+
manifest_path(record).parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
manifest_path(record).write_text(json.dumps({
|
|
485
|
+
"name": record,
|
|
486
|
+
"queries": queries,
|
|
487
|
+
"as_of": as_of.isoformat(),
|
|
488
|
+
"captured_at": as_of.isoformat(),
|
|
489
|
+
"profile": {"languages": profile.languages, "topics": profile.topics,
|
|
490
|
+
"contributions": profile.contributions, "days": profile.days},
|
|
491
|
+
"candidates": [c.as_dict() for c in candidates],
|
|
492
|
+
"analysed": [r.slug for r in rows],
|
|
493
|
+
}, indent=1, sort_keys=True) + "\n")
|
|
494
|
+
|
|
495
|
+
return render(profile, queries, screened, rows, replayed=False, as_of=as_of,
|
|
496
|
+
skipped=skipped, unanalysed=max(0, len(survivors) - max_analyze),
|
|
497
|
+
unanalysable=unanalysable)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Committed evidence, so the eval runs with no token and no rate limits.
|
|
2
|
+
|
|
3
|
+
Fixtures are the reason a judge can reproduce the headline number from a clean
|
|
4
|
+
clone with no credentials. They are also where the holdout is easiest to break
|
|
5
|
+
by accident — a hand-edited file, a re-capture against the wrong window — so the
|
|
6
|
+
loader re-asserts the date bounds on every read rather than trusting the file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from holt.evidence.provider import EvidenceProvider
|
|
19
|
+
from holt.evidence.redact import redact_payload
|
|
20
|
+
from holt.types import T_CUTOFF, EvidenceRecord, Window
|
|
21
|
+
|
|
22
|
+
FIXTURE_ROOT = Path("fixtures")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def slug_to_filename(repo_slug: str) -> str:
|
|
26
|
+
return repo_slug.replace("/", "__") + ".json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_to_dict(record: EvidenceRecord) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"evidence_id": record.evidence_id,
|
|
32
|
+
"source": record.source,
|
|
33
|
+
"url": record.url,
|
|
34
|
+
"timestamp": record.timestamp.isoformat(),
|
|
35
|
+
"payload": record.payload,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_from_dict(data: dict[str, Any]) -> EvidenceRecord:
|
|
40
|
+
return EvidenceRecord(
|
|
41
|
+
evidence_id=data["evidence_id"],
|
|
42
|
+
source=data["source"],
|
|
43
|
+
url=data["url"],
|
|
44
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
45
|
+
payload=data["payload"],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def content_hash(records: Iterable[EvidenceRecord]) -> str:
|
|
50
|
+
"""Stable over evidence content, independent of capture order or metadata."""
|
|
51
|
+
blob = json.dumps(
|
|
52
|
+
sorted((record_to_dict(r) for r in records), key=lambda d: d["evidence_id"]),
|
|
53
|
+
sort_keys=True,
|
|
54
|
+
separators=(",", ":"),
|
|
55
|
+
)
|
|
56
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def redact_records(records: Iterable[EvidenceRecord]) -> tuple[list[EvidenceRecord], int]:
|
|
60
|
+
"""Strip third-party credentials from evidence on its way to disk.
|
|
61
|
+
|
|
62
|
+
Public issues contain leaked keys. Reading them is unavoidable; shipping them
|
|
63
|
+
in a committed fixture is a choice, and this is the point where we decline it.
|
|
64
|
+
"""
|
|
65
|
+
out, removed = [], 0
|
|
66
|
+
for record in records:
|
|
67
|
+
payload, hits = redact_payload(record.payload)
|
|
68
|
+
removed += hits
|
|
69
|
+
out.append(
|
|
70
|
+
record if not hits else EvidenceRecord(
|
|
71
|
+
evidence_id=record.evidence_id,
|
|
72
|
+
source=record.source,
|
|
73
|
+
url=record.url,
|
|
74
|
+
timestamp=record.timestamp,
|
|
75
|
+
payload=payload,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return out, removed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_fixture(
|
|
82
|
+
repo_slug: str,
|
|
83
|
+
window: Window,
|
|
84
|
+
records: Iterable[EvidenceRecord],
|
|
85
|
+
root: Path = FIXTURE_ROOT,
|
|
86
|
+
cutoff: datetime = T_CUTOFF,
|
|
87
|
+
) -> Path:
|
|
88
|
+
# Scrubbed before the hash is taken, so the committed hash describes the
|
|
89
|
+
# committed bytes and a later re-capture of the same evidence reproduces it.
|
|
90
|
+
records, removed = redact_records(records)
|
|
91
|
+
path = root / window.value / slug_to_filename(repo_slug)
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path.write_text(
|
|
94
|
+
json.dumps(
|
|
95
|
+
{
|
|
96
|
+
"repo": repo_slug,
|
|
97
|
+
"window": window.value,
|
|
98
|
+
"cutoff": cutoff.isoformat(),
|
|
99
|
+
"captured_at": datetime.now(UTC).isoformat(),
|
|
100
|
+
"content_sha256": content_hash(records),
|
|
101
|
+
"credentials_redacted": removed,
|
|
102
|
+
"records": [record_to_dict(r) for r in records],
|
|
103
|
+
},
|
|
104
|
+
indent=1,
|
|
105
|
+
sort_keys=True,
|
|
106
|
+
)
|
|
107
|
+
+ "\n"
|
|
108
|
+
)
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FixtureProvider(EvidenceProvider):
|
|
113
|
+
"""Reads committed evidence. No network, no token, no rate limit."""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
window: Window,
|
|
118
|
+
root: Path = FIXTURE_ROOT,
|
|
119
|
+
cutoff: datetime = T_CUTOFF,
|
|
120
|
+
) -> None:
|
|
121
|
+
super().__init__(window, cutoff)
|
|
122
|
+
self.root = Path(root)
|
|
123
|
+
self._loaded: dict[str, EvidenceRecord] = {}
|
|
124
|
+
|
|
125
|
+
def path_for(self, repo_slug: str) -> Path:
|
|
126
|
+
return self.root / self.window.value / slug_to_filename(repo_slug)
|
|
127
|
+
|
|
128
|
+
def _fetch_raw(self, request: str, /, **params: object) -> Iterable[EvidenceRecord]:
|
|
129
|
+
path = self.path_for(request)
|
|
130
|
+
if not path.exists():
|
|
131
|
+
raise FileNotFoundError(
|
|
132
|
+
f"No {self.window.value} fixture for {request} at {path}. "
|
|
133
|
+
"Capture it in live mode first; fixture mode never reaches the network."
|
|
134
|
+
)
|
|
135
|
+
data = json.loads(path.read_text())
|
|
136
|
+
|
|
137
|
+
if data["window"] != self.window.value:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"{path} holds {data['window']} evidence but was opened as "
|
|
140
|
+
f"{self.window.value}; the holdout sides must not be mixed"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
records = [record_from_dict(r) for r in data["records"]]
|
|
144
|
+
expected = data.get("content_sha256")
|
|
145
|
+
if expected and (actual := content_hash(records)) != expected:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"{path} content hash mismatch: recorded {expected[:12]}, "
|
|
148
|
+
f"computed {actual[:12]}. The fixture was edited after capture."
|
|
149
|
+
)
|
|
150
|
+
self._loaded.update({r.evidence_id: r for r in records})
|
|
151
|
+
return records
|
|
152
|
+
|
|
153
|
+
def _resolve_raw(self, evidence_id: str) -> EvidenceRecord | None:
|
|
154
|
+
return self._loaded.get(evidence_id)
|