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.
Files changed (65) hide show
  1. holt/__init__.py +0 -0
  2. holt/agent/__init__.py +0 -0
  3. holt/agent/entry.py +86 -0
  4. holt/agent/findings.py +49 -0
  5. holt/agent/landing.py +154 -0
  6. holt/agent/pipeline.py +244 -0
  7. holt/agent/progression.py +408 -0
  8. holt/agent/signals.py +220 -0
  9. holt/agent/stages.py +533 -0
  10. holt/agent/verdict.py +226 -0
  11. holt/agent/verify.py +140 -0
  12. holt/baseline.py +89 -0
  13. holt/baseline_matched.py +116 -0
  14. holt/cli.py +616 -0
  15. holt/discover.py +497 -0
  16. holt/evidence/__init__.py +3 -0
  17. holt/evidence/fixtures.py +154 -0
  18. holt/evidence/github_graphql.py +538 -0
  19. holt/evidence/provider.py +77 -0
  20. holt/evidence/redact.py +79 -0
  21. holt/issues.py +41 -0
  22. holt/model.py +516 -0
  23. holt/profile.py +126 -0
  24. holt/report.py +157 -0
  25. holt/tui/__init__.py +0 -0
  26. holt/tui/animation.py +84 -0
  27. holt/tui/app.py +294 -0
  28. holt/tui/clipboard.py +89 -0
  29. holt/tui/commands.py +134 -0
  30. holt/tui/discovery.py +305 -0
  31. holt/tui/env.py +49 -0
  32. holt/tui/events.py +245 -0
  33. holt/tui/mascot.py +121 -0
  34. holt/tui/models.py +590 -0
  35. holt/tui/observe.py +297 -0
  36. holt/tui/screens/__init__.py +35 -0
  37. holt/tui/screens/assessment.py +337 -0
  38. holt/tui/screens/confirm.py +62 -0
  39. holt/tui/screens/discover.py +444 -0
  40. holt/tui/screens/home.py +519 -0
  41. holt/tui/screens/inspector.py +106 -0
  42. holt/tui/screens/live.py +335 -0
  43. holt/tui/screens/models.py +393 -0
  44. holt/tui/screens/next_steps.py +425 -0
  45. holt/tui/screens/profile.py +129 -0
  46. holt/tui/session.py +711 -0
  47. holt/tui/store.py +458 -0
  48. holt/tui/theme.py +479 -0
  49. holt/tui/visual.py +33 -0
  50. holt/tui/widgets/__init__.py +0 -0
  51. holt/tui/widgets/candidates.py +78 -0
  52. holt/tui/widgets/claims.py +59 -0
  53. holt/tui/widgets/disclosure.py +121 -0
  54. holt/tui/widgets/evidence.py +121 -0
  55. holt/tui/widgets/masthead.py +122 -0
  56. holt/tui/widgets/recent.py +167 -0
  57. holt/tui/widgets/scrolling.py +38 -0
  58. holt/tui/widgets/stages.py +232 -0
  59. holt/types.py +48 -0
  60. holt_cli-0.1.0.dist-info/METADATA +198 -0
  61. holt_cli-0.1.0.dist-info/RECORD +65 -0
  62. holt_cli-0.1.0.dist-info/WHEEL +4 -0
  63. holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  65. 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,3 @@
1
+ from holt.evidence.provider import ContaminationError, EvidenceProvider
2
+
3
+ __all__ = ["ContaminationError", "EvidenceProvider"]
@@ -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)