syncade 0.6.2__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 (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,310 @@
1
+ """Reviewer + synthesizer test doubles.
2
+
3
+ :class:`FakeAdapter` and :class:`FakeSynthesizerAdapter` cover the reviewer and
4
+ synthesizer adapter surfaces, plus their zero-finding canned-output defaults.
5
+ Re-exported from ``fake.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ from syncade.adapters.base import Invocation
15
+ from syncade.config import ReviewerConfig
16
+ from syncade.findings import ReviewerOutput
17
+ from syncade.process import SubprocessResult
18
+ from syncade.synthesis import SynthesizerOutput
19
+ from syncade.worktree_env import worktree_scoped_env
20
+
21
+ from .fake_common import _noop_argv
22
+
23
+
24
+ def _default_canned_output() -> ReviewerOutput:
25
+ """The conventional zero-finding SHIP verdict used when callers don't
26
+ supply a ``canned_output``. Returned as a fresh instance each call so
27
+ mutation by one test doesn't leak into the next.
28
+
29
+ The narrative-surface fields (``summary``, ``priority_order``,
30
+ ``coverage_gaps``, ``dismissed_concerns``) are required on
31
+ :class:`ReviewerOutput`, so the test default populates a minimal
32
+ valid combination: a non-empty ``summary``, an empty
33
+ ``priority_order`` (correct for zero findings), and empty
34
+ coverage/dismissal lists.
35
+ """
36
+ return ReviewerOutput(
37
+ verdict="SHIP",
38
+ findings=[],
39
+ summary="fake reviewer canned SHIP",
40
+ priority_order=[],
41
+ coverage_gaps=[],
42
+ dismissed_concerns=[],
43
+ )
44
+
45
+
46
+ class FakeAdapter:
47
+ """In-memory test double for :class:`ReviewerAdapter`.
48
+
49
+ Configured at construction time. After use, ``self.invocations``
50
+ holds every ``build_invocation`` call's arguments (if recording is
51
+ enabled), which dispatcher tests can inspect to assert correct
52
+ fan-out behavior.
53
+
54
+ Args:
55
+ canned_output: The :class:`ReviewerOutput` to return from
56
+ ``parse_output``. Defaults to a zero-finding SHIP verdict.
57
+ canned_exception: If supplied, ``parse_output`` raises this
58
+ instead of returning. Use to exercise dispatcher
59
+ failure-handling paths (for example reviewer invocation or output
60
+ parse errors).
61
+ record_invocations: If True (default), every ``build_invocation``
62
+ call is appended to ``self.invocations`` as a tuple of
63
+ ``(reviewer_config, worktree_path, prompt)``. Disable to
64
+ save memory in tests that fan out heavily.
65
+ canned_auth_exception: If supplied, ``check_auth`` raises this.
66
+ Use to test dispatcher auth-fail-fast behavior without
67
+ spawning a real subprocess. ``check_auth`` always
68
+ increments ``self.check_auth_calls`` regardless, so
69
+ dispatcher tests can assert the pre-flight phase ran.
70
+ """
71
+
72
+ name = "fake"
73
+
74
+ def __init__(
75
+ self,
76
+ canned_output: ReviewerOutput | None = None,
77
+ canned_exception: Exception | None = None,
78
+ record_invocations: bool = True,
79
+ canned_auth_exception: Exception | None = None,
80
+ ) -> None:
81
+ self.canned_output = (
82
+ canned_output if canned_output is not None else _default_canned_output()
83
+ )
84
+ self.canned_exception = canned_exception
85
+ self.canned_auth_exception = canned_auth_exception
86
+ self.record_invocations = record_invocations
87
+ self.invocations: list[tuple[ReviewerConfig, Path, str]] = []
88
+ self.check_auth_calls = 0
89
+ self.parse_output_calls = 0
90
+ # Guards check_auth_calls, parse_output_calls, and invocations
91
+ # against races when the dispatcher calls into this adapter
92
+ # from multiple worker threads. See the fake module docstring.
93
+ self._lock = threading.Lock()
94
+
95
+ def build_invocation(
96
+ self,
97
+ reviewer_config: ReviewerConfig,
98
+ worktree_path: Path,
99
+ prompt: str,
100
+ ) -> Invocation:
101
+ """Record the call and return a no-op shell :class:`Invocation`.
102
+
103
+ The returned Invocation has ``cwd = worktree_path`` and the
104
+ caller's environment so a dispatcher that actually executes it
105
+ still gets a clean exit-0 SubprocessResult on platforms where
106
+ ``/bin/true`` (or ``cmd /c exit 0``) exists.
107
+ """
108
+ if self.record_invocations:
109
+ with self._lock:
110
+ self.invocations.append((reviewer_config, worktree_path, prompt))
111
+ return Invocation(
112
+ argv=_noop_argv(),
113
+ cwd=worktree_path,
114
+ # mirror the real reviewer adapters' worktree-scoped env so
115
+ # this double stays faithful. (The synth/auditor fakes deliberately
116
+ # keep the bare env — their real counterparts run cold in isolated
117
+ # tempdirs, not worktrees.)
118
+ env=worktree_scoped_env(worktree_path),
119
+ stdin_text=None,
120
+ timeout_seconds=None,
121
+ )
122
+
123
+ def parse_output(self, result: SubprocessResult) -> ReviewerOutput:
124
+ """Return the canned output, or raise the canned exception.
125
+
126
+ Increments ``self.parse_output_calls`` so tests that need to
127
+ assert "this code path was (not) reached" — notably the
128
+ auth-fail-fast tests, which must verify that NO reviewer's
129
+ parse_output ran when pre-flight failed — can do so directly
130
+ rather than inferring it from ``invocations``.
131
+
132
+ The ``result`` argument is ignored entirely — that's the whole
133
+ point of the fake. If you need parse behavior that varies with
134
+ ``result``, write a real adapter or a custom subclass.
135
+ """
136
+ with self._lock:
137
+ self.parse_output_calls += 1
138
+ if self.canned_exception is not None:
139
+ raise self.canned_exception
140
+ return self.canned_output
141
+
142
+ def extract_final_text(
143
+ self,
144
+ result: SubprocessResult,
145
+ *,
146
+ empty_output_exception_class: type[Exception],
147
+ ) -> str:
148
+ """Return the canned output as JSON text, or raise the canned
149
+ exception.
150
+
151
+ Serializing via ``model_dump_json`` (rather than returning some
152
+ placeholder string) keeps the identity a real adapter guarantees:
153
+ ``parse_output(r)`` == ``parse_reviewer_output(extract_final_text(r))``.
154
+ A fake that broke that identity would let a caller pass tests against
155
+ the fake and fail against ``claude``.
156
+
157
+ ``empty_output_exception_class`` is accepted for protocol symmetry but
158
+ ignored — the fake never has empty output. To simulate that case, pass
159
+ the relevant error as ``canned_exception``.
160
+
161
+ Deliberately does NOT touch ``parse_output_calls``: the auth-fail-fast
162
+ dispatcher tests assert that counter is 0 to prove no reviewer parsed
163
+ anything, and a different method inflating it would quietly rot that
164
+ assertion.
165
+ """
166
+ del result, empty_output_exception_class # explicitly unused
167
+ if self.canned_exception is not None:
168
+ raise self.canned_exception
169
+ return self.canned_output.model_dump_json()
170
+
171
+ def check_auth(self) -> None:
172
+ """Record the call and raise the canned auth exception if set.
173
+
174
+ Increments ``self.check_auth_calls`` on every invocation so
175
+ dispatcher tests can assert the pre-flight phase ran exactly
176
+ once per adapter. If ``canned_auth_exception`` was supplied at
177
+ construction time, it's raised here — this is how dispatcher
178
+ tests simulate "one reviewer's auth is broken" without spawning
179
+ real subprocesses.
180
+ """
181
+ with self._lock:
182
+ self.check_auth_calls += 1
183
+ if self.canned_auth_exception is not None:
184
+ raise self.canned_auth_exception
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Synthesizer test double
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def _default_canned_synth_output() -> SynthesizerOutput:
193
+ """The conventional zero-finding SynthesizerOutput used when
194
+ callers don't supply a ``canned_output``. Returned as a fresh
195
+ instance each call so mutation by one test doesn't leak into the
196
+ next.
197
+ """
198
+ return SynthesizerOutput(
199
+ consolidated_findings=[],
200
+ synthesis_summary="fake synthesizer canned: nothing to consolidate",
201
+ )
202
+
203
+
204
+ class FakeSynthesizerAdapter:
205
+ """In-memory test double for the synthesizer codex adapter shape.
206
+
207
+ The synthesizer module's adapter contract is narrower than the
208
+ full :class:`~syncade.adapters.base.ReviewerAdapter` Protocol —
209
+ it only calls :meth:`build_invocation` and
210
+ :meth:`extract_final_text`. This fake implements that subset.
211
+
212
+ The fake's ``extract_final_text`` produces text that
213
+ will round-trip through :func:`syncade.synthesis.parse_synthesizer_output`
214
+ to yield ``canned_output``. That keeps the test exercising the
215
+ real parser path (so a regression in
216
+ ``parse_synthesizer_output`` surfaces in tests that USE
217
+ ``FakeSynthesizerAdapter`` even though they aren't reaching for a
218
+ parser-specific assertion).
219
+
220
+ Args:
221
+ canned_output: The :class:`SynthesizerOutput` the fake produces
222
+ when the synthesizer runs successfully. Default: an empty
223
+ consolidation with a non-empty summary string.
224
+ canned_exception: If supplied,
225
+ ``extract_final_text`` raises this instead of
226
+ returning text. Use to exercise the synthesizer module's
227
+ failure paths. Pass :class:`syncade.synthesis.SynthesizerOutputError`
228
+ to simulate a parse failure, or a reviewer invocation error to
229
+ simulate a codex subprocess failure.
230
+ record_invocations: If True (default), every
231
+ ``build_invocation`` call's ``(reviewer_config,
232
+ worktree_path, prompt)`` tuple is appended to
233
+ ``self.invocations`` for test inspection.
234
+ """
235
+
236
+ name = "openai" # so CodexAdapter.build_invocation's provider validation passes
237
+ """Matches the codex provider key used by the synthetic ReviewerConfig.
238
+
239
+ The fake adapter does not need this attribute itself; keeping it aligned
240
+ with ``CodexAdapter`` makes test debugging simpler.
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ canned_output: SynthesizerOutput | None = None,
246
+ canned_exception: Exception | None = None,
247
+ record_invocations: bool = True,
248
+ ) -> None:
249
+ self.canned_output = (
250
+ canned_output if canned_output is not None else _default_canned_synth_output()
251
+ )
252
+ self.canned_exception = canned_exception
253
+ self.record_invocations = record_invocations
254
+ self.invocations: list[tuple[ReviewerConfig, Path, str]] = []
255
+ self.extract_calls = 0
256
+
257
+ def build_invocation(
258
+ self,
259
+ reviewer_config: ReviewerConfig,
260
+ worktree_path: Path,
261
+ prompt: str,
262
+ ) -> Invocation:
263
+ """Record the call and return an :class:`Invocation` pointing
264
+ at a no-op shell command so ``run_subprocess`` still produces a clean
265
+ exit-0 :class:`SubprocessResult`.
266
+
267
+ clarification: ``worktree_path`` is the **synthesizer's
268
+ cold-isolation tempdir workspace**, NOT ``repo_root``. As of
269
+ + , ``run_synthesizer`` provisions an isolated
270
+ tempdir, copies the PR doc into it, git-inits it, and passes
271
+ the WORKSPACE path here. Tests asserting on the recorded
272
+ path should check the ``syncade-synth-`` tempdir prefix
273
+ (see ``test_synthesizer_runs_in_isolated_tempdir_workspace``).
274
+ """
275
+ if self.record_invocations:
276
+ self.invocations.append((reviewer_config, worktree_path, prompt))
277
+ return Invocation(
278
+ argv=_noop_argv(),
279
+ cwd=worktree_path,
280
+ env=dict(os.environ),
281
+ stdin_text=None,
282
+ timeout_seconds=None,
283
+ )
284
+
285
+ def extract_final_text(
286
+ self,
287
+ result: SubprocessResult,
288
+ *,
289
+ empty_output_exception_class: type[Exception],
290
+ ) -> str:
291
+ """Return the canned output as JSON text, or raise the canned
292
+ exception.
293
+
294
+ The synthesizer module calls this immediately after
295
+ ``run_subprocess``; the returned text is then fed to
296
+ :func:`syncade.synthesis.parse_synthesizer_output`. Producing the canned
297
+ output via ``model_dump_json`` exercises the parser end-to-end.
298
+
299
+ ``empty_output_exception_class`` is accepted for protocol
300
+ symmetry with :meth:`CodexAdapter.extract_final_text`
301
+ but ignored — the fake never has an empty agent_message; if
302
+ the caller wants to simulate that case, they pass an instance
303
+ of :class:`SynthesizerOutputError` as ``canned_exception``
304
+ directly.
305
+ """
306
+ del result, empty_output_exception_class # explicitly unused
307
+ self.extract_calls += 1
308
+ if self.canned_exception is not None:
309
+ raise self.canned_exception
310
+ return self.canned_output.model_dump_json()