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,460 @@
1
+ """Producer / spec-auditor / spec-drafter test doubles.
2
+
3
+ :class:`FakeProducerAdapter` (the :class:`~syncade.adapters.producer.ProducerAdapter`
4
+ double, with the optional fixture-commit that drives the orchestrator's SHA-based
5
+ stall detection), :class:`FakeAuditorAdapter`, and :class:`FakeDrafterAdapter`,
6
+ plus their canned-output defaults. Re-exported from ``fake.py``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from syncade.adapters.base import Invocation
17
+ from syncade.adapters.producer import ProducerOutput
18
+ from syncade.config import ProducerConfig, ReviewerConfig
19
+ from syncade.process import SubprocessResult
20
+ from syncade.worktree_env import worktree_scoped_env
21
+
22
+ from .fake_common import _noop_argv
23
+
24
+ if TYPE_CHECKING:
25
+ from syncade.spec_audit import SpecAuditOutput
26
+ from syncade.spec_draft import SpecDraftOutput
27
+
28
+
29
+ def _default_canned_producer_output() -> ProducerOutput:
30
+ """The conventional "I fixed it" narrative used when callers don't
31
+ supply a ``canned_output``. Returned as a fresh instance each
32
+ call so mutation by one test doesn't leak into the next.
33
+
34
+ Empty / minimal narrative is intentional — the orchestrator's
35
+ stall detection is SHA-based, not narrative-based, so test
36
+ cases that exercise "producer committed" need a non-empty SHA
37
+ move on the worktree but can leave the narrative spartan.
38
+ """
39
+ return ProducerOutput(narrative_text="fake producer canned: addressed the findings")
40
+
41
+
42
+ class FakeProducerAdapter:
43
+ """In-memory test double for :class:`ProducerAdapter`.
44
+
45
+ Configured at construction time. The test double can optionally
46
+ write a fixture commit to the producer worktree on
47
+ ``build_invocation`` so the orchestrator's stall detection
48
+ sees the worktree HEAD move — without this, every fake-
49
+ backed orchestrator test would have to manually script the
50
+ worktree's git state and ``run_producer``'s "did HEAD move?"
51
+ check would always report "stalled".
52
+
53
+ The fixture commit is written by ``build_invocation``, not by
54
+ ``parse_output``, because the orchestrator dispatches the
55
+ subprocess BETWEEN those calls. Pre-dispatch the worktree is
56
+ at the round-start SHA; post-dispatch (where the real
57
+ producer would have made its edits) the worktree should have
58
+ new commits. Mimicking that in build_invocation produces the
59
+ same SHA-move signal :func:`syncade.producer.run_producer`
60
+ detects without ever spawning a subprocess.
61
+
62
+ Args:
63
+ canned_output: The :class:`ProducerOutput` to return from
64
+ ``parse_output``. Defaults to a short canned narrative.
65
+ canned_exception: If supplied, ``parse_output`` raises this
66
+ instead of returning. Use to exercise the producer
67
+ module's failure paths
68
+ (:class:`~syncade.adapters.base.ReviewerInvocationError`
69
+ for subprocess-side failure → exit 40;
70
+ :class:`~syncade.findings.ReviewerOutputError` for
71
+ unparseable producer output, also exit 40 since the
72
+ producer has no separate parse-failure exit code).
73
+ record_invocations: If True (default), every
74
+ ``build_invocation`` call's ``(producer_config,
75
+ worktree_path, prompt)`` tuple is appended to
76
+ ``self.invocations``.
77
+ commit_message: When non-None, ``build_invocation`` writes
78
+ a fixture commit with this subject to the worktree. The
79
+ commit creates a file ``.syncade-fake-producer-N``
80
+ (numbered per call to allow multiple commits) and
81
+ stages + commits it. When ``None`` (default), no commit
82
+ is written — the worktree HEAD stays at the round-start
83
+ SHA, which simulates the producer-stall path in
84
+ orchestrator tests.
85
+ canned_auth_exception: If supplied, ``check_auth`` raises
86
+ this. Same auth-fail simulation pattern as
87
+ :class:`FakeAdapter`.
88
+ """
89
+
90
+ name = "fake-producer"
91
+
92
+ def __init__(
93
+ self,
94
+ canned_output: ProducerOutput | None = None,
95
+ canned_exception: Exception | None = None,
96
+ record_invocations: bool = True,
97
+ commit_message: str | None = None,
98
+ canned_auth_exception: Exception | None = None,
99
+ ) -> None:
100
+ self.canned_output = (
101
+ canned_output if canned_output is not None else _default_canned_producer_output()
102
+ )
103
+ self.canned_exception = canned_exception
104
+ self.canned_auth_exception = canned_auth_exception
105
+ self.record_invocations = record_invocations
106
+ self.commit_message = commit_message
107
+ self.invocations: list[tuple[ProducerConfig, Path, str]] = []
108
+ self.check_auth_calls = 0
109
+ self.parse_output_calls = 0
110
+ # Counter used to name fixture commits uniquely when
111
+ # build_invocation runs more than once against the same
112
+ # worktree (which is the multi-commit producer scenario).
113
+ self._fixture_commit_index = 0
114
+ # Producer dispatch is single-threaded per round (one
115
+ # producer subprocess at a time), so the lock the reviewer
116
+ # fake carries isn't strictly needed here — but it's cheap
117
+ # and protects against a future caller that drives multiple
118
+ # FakeProducerAdapter instances concurrently in one test.
119
+ self._lock = threading.Lock()
120
+
121
+ def build_invocation(
122
+ self,
123
+ producer_config: ProducerConfig,
124
+ worktree_path: Path,
125
+ prompt: str,
126
+ ) -> Invocation:
127
+ """Record the call, optionally write a fixture commit to the
128
+ worktree, and return an :class:`Invocation` pointing at a
129
+ no-op shell command.
130
+
131
+ The fixture-commit branch is what lets orchestrator tests
132
+ exercise the producer-committed path without spawning a
133
+ real subprocess: when ``commit_message`` is set,
134
+ ``build_invocation`` writes a fixture file under the
135
+ worktree (``.syncade-fake-producer-<N>``) and runs
136
+ ``git add`` + ``git commit -m <message>`` from the
137
+ worktree. The HEAD-moves-vs-stays signal
138
+ :func:`syncade.producer.run_producer` reads is then the
139
+ same shape a real producer would produce.
140
+
141
+ When ``commit_message`` is None, no commit is written —
142
+ the worktree stays at the round-start SHA, which is the
143
+ stall path.
144
+
145
+ Raises:
146
+ subprocess.CalledProcessError: If the fixture-commit
147
+ ``git`` calls fail (no git on PATH, the worktree
148
+ isn't a git working tree, etc.). The fake raises
149
+ rather than swallowing so the test failure surface
150
+ is legible — these failures indicate a broken
151
+ test fixture, not a real-world condition the
152
+ production code path would encounter.
153
+ """
154
+ if self.record_invocations:
155
+ with self._lock:
156
+ self.invocations.append((producer_config, worktree_path, prompt))
157
+ if self.commit_message is not None:
158
+ self._write_fixture_commit(worktree_path)
159
+ return Invocation(
160
+ argv=_noop_argv(),
161
+ cwd=worktree_path,
162
+ # mirror the real producer adapters' worktree-scoped env.
163
+ env=worktree_scoped_env(worktree_path),
164
+ stdin_text=None,
165
+ timeout_seconds=None,
166
+ )
167
+
168
+ def _write_fixture_commit(self, worktree_path: Path) -> None:
169
+ """Write a fixture commit on top of the worktree's current HEAD.
170
+
171
+ Used to simulate a real producer making a commit during its
172
+ subprocess run. The fixture file's basename includes a
173
+ per-call index so multiple ``build_invocation`` calls
174
+ against the same worktree produce multiple distinct
175
+ commits (the multi-commit producer scenario described in
176
+ the producer prompt template).
177
+ """
178
+ import subprocess
179
+
180
+ with self._lock:
181
+ idx = self._fixture_commit_index
182
+ self._fixture_commit_index += 1
183
+ fixture_file = worktree_path / f".syncade-fake-producer-{idx}"
184
+ fixture_file.write_text(
185
+ f"FakeProducerAdapter fixture commit {idx} — produced by tests\n",
186
+ encoding="utf-8",
187
+ )
188
+ # The fake's two git invocations bypass syncade.process so the
189
+ # test double stays out of the production subprocess machinery.
190
+ # Failures bubble as CalledProcessError, which the test surface
191
+ # treats as a broken fixture (see docstring).
192
+ subprocess.run(
193
+ ["git", "add", fixture_file.name],
194
+ cwd=worktree_path,
195
+ check=True,
196
+ capture_output=True,
197
+ text=True,
198
+ encoding="utf-8",
199
+ errors="replace",
200
+ )
201
+ # Allow the test's commit to land regardless of the
202
+ # repo-level user.email/user.name config (which may be
203
+ # absent in CI containers); --author embeds the metadata
204
+ # without touching repo state. The author string mimics the
205
+ # real-producer convention so commit-history inspection in
206
+ # tests reads naturally.
207
+ commit_msg = self.commit_message or "fix: fake producer commit"
208
+ subprocess.run(
209
+ [
210
+ "git",
211
+ "-c",
212
+ "user.email=fake-producer@syncade.test",
213
+ "-c",
214
+ "user.name=Fake Producer",
215
+ "commit",
216
+ "-m",
217
+ commit_msg,
218
+ ],
219
+ cwd=worktree_path,
220
+ check=True,
221
+ capture_output=True,
222
+ text=True,
223
+ encoding="utf-8",
224
+ errors="replace",
225
+ )
226
+
227
+ def parse_output(self, result: SubprocessResult) -> ProducerOutput:
228
+ """Return the canned output, or raise the canned exception.
229
+
230
+ Increments ``self.parse_output_calls`` so tests that need
231
+ to assert "this code path was reached" can do so directly.
232
+ The ``result`` argument is ignored — same pattern as
233
+ :class:`FakeAdapter.parse_output`.
234
+ """
235
+ with self._lock:
236
+ self.parse_output_calls += 1
237
+ if self.canned_exception is not None:
238
+ raise self.canned_exception
239
+ return self.canned_output
240
+
241
+ def check_auth(self) -> None:
242
+ """Record the call and raise the canned auth exception if set."""
243
+ with self._lock:
244
+ self.check_auth_calls += 1
245
+ if self.canned_auth_exception is not None:
246
+ raise self.canned_auth_exception
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Spec auditor test double
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _default_canned_audit_output() -> SpecAuditOutput:
255
+ """The conventional zero-finding READY verdict used when callers
256
+ don't supply a ``canned_output``. Returned as a fresh instance each
257
+ call so mutation by one test doesn't leak into the next.
258
+
259
+ Imported lazily to avoid a circular import — spec_audit imports from
260
+ prompts, which is clean, but making this module unconditionally import
261
+ from spec_audit at load time would add an extra layer to the import
262
+ graph.
263
+ """
264
+ from syncade.spec_audit import SpecAuditOutput
265
+
266
+ return SpecAuditOutput(
267
+ verdict="READY",
268
+ findings=[],
269
+ summary="fake auditor canned READY",
270
+ priority_order=[],
271
+ coverage_gaps=[],
272
+ dismissed_concerns=[],
273
+ )
274
+
275
+
276
+ class FakeAuditorAdapter:
277
+ """In-memory test double for the adapter surface that
278
+ :func:`syncade.spec_audit.run_spec_audit` depends on.
279
+
280
+ Mirrors :class:`FakeSynthesizerAdapter` — same two-method surface
281
+ (``build_invocation`` + ``extract_final_text``), same
282
+ "produce canned output as ``model_dump_json()`` so the real parser
283
+ path is exercised" strategy.
284
+
285
+ Args:
286
+ canned_output: The :class:`~syncade.spec_audit.SpecAuditOutput`
287
+ the fake produces when the audit runs successfully. Default:
288
+ a zero-finding READY verdict.
289
+ canned_exception: If supplied,
290
+ ``extract_final_text`` raises this instead of
291
+ returning text. Use to exercise the spec audit module's
292
+ failure paths — e.g. pass
293
+ :class:`~syncade.spec_audit.SpecAuditOutputError` to
294
+ simulate a parse failure (exit 70), or
295
+ :class:`~syncade.adapters.base.ReviewerInvocationError`
296
+ to simulate a codex subprocess failure (exit 40).
297
+ record_invocations: If True (default), every
298
+ ``build_invocation`` call's ``(reviewer_config,
299
+ worktree_path, prompt)`` tuple is appended to
300
+ ``self.invocations`` for test inspection.
301
+ """
302
+
303
+ name = "openai"
304
+ """Matches the codex provider key so the synthetic ReviewerConfig
305
+ :mod:`syncade.spec_audit` constructs validates cleanly inside
306
+ :meth:`CodexAdapter.build_invocation`."""
307
+
308
+ def __init__(
309
+ self,
310
+ canned_output: SpecAuditOutput | None = None,
311
+ canned_exception: Exception | None = None,
312
+ record_invocations: bool = True,
313
+ ) -> None:
314
+ self._canned_output_arg = canned_output
315
+ self.canned_exception = canned_exception
316
+ self.record_invocations = record_invocations
317
+ self.invocations: list[tuple[ReviewerConfig, Path, str]] = []
318
+ self.extract_calls = 0
319
+
320
+ @property
321
+ def canned_output(self) -> SpecAuditOutput:
322
+ if self._canned_output_arg is not None:
323
+ return self._canned_output_arg
324
+ return _default_canned_audit_output()
325
+
326
+ def build_invocation(
327
+ self,
328
+ reviewer_config: ReviewerConfig,
329
+ worktree_path: Path,
330
+ prompt: str,
331
+ ) -> Invocation:
332
+ """Record the call and return a no-op :class:`Invocation`.
333
+
334
+ ``worktree_path`` is the cold isolation workspace created by
335
+ :func:`syncade.spec_audit.run_spec_audit`, not repo_root.
336
+ """
337
+ if self.record_invocations:
338
+ self.invocations.append((reviewer_config, worktree_path, prompt))
339
+ return Invocation(
340
+ argv=_noop_argv(),
341
+ cwd=worktree_path,
342
+ env=dict(os.environ),
343
+ stdin_text=None,
344
+ timeout_seconds=None,
345
+ )
346
+
347
+ def extract_final_text(
348
+ self,
349
+ result: SubprocessResult,
350
+ *,
351
+ empty_output_exception_class: type[Exception],
352
+ ) -> str:
353
+ """Return the canned output as JSON text, or raise the canned exception.
354
+
355
+ Produces text via ``model_dump_json()`` so
356
+ :func:`syncade.spec_audit.parse_spec_audit_output` is exercised
357
+ end-to-end in tests. ``empty_output_exception_class`` is
358
+ accepted for protocol symmetry but ignored.
359
+ """
360
+ del result, empty_output_exception_class
361
+ self.extract_calls += 1
362
+ if self.canned_exception is not None:
363
+ raise self.canned_exception
364
+ return self.canned_output.model_dump_json()
365
+
366
+
367
+ def _default_canned_draft_output() -> SpecDraftOutput:
368
+ """The conventional minimal one-criterion draft used when callers don't supply
369
+ a ``canned_output``. Fresh instance each call (no cross-test mutation). Lazy
370
+ import to keep this module off spec_draft at load time (same rationale as the
371
+ auditor default)."""
372
+ from syncade.spec_draft import Criterion, SpecDraftOutput
373
+
374
+ return SpecDraftOutput(
375
+ proposal="fake drafter canned: a small update the user asked for",
376
+ acceptance_criteria=[Criterion(text="the asked-for behavior works", origin="transcribed")],
377
+ deltas=[],
378
+ assumptions=[],
379
+ )
380
+
381
+
382
+ class FakeDrafterAdapter:
383
+ """In-memory test double for the adapter surface that
384
+ :func:`syncade.spec_draft.run_spec_draft` depends on.
385
+
386
+ Mirrors :class:`FakeAuditorAdapter` (same two-method surface, same
387
+ "produce canned output as ``model_dump_json()`` so the real parser runs"
388
+ strategy), plus a ``canned_text`` escape hatch for exercising the parse-failure
389
+ path with arbitrary malformed stdout.
390
+
391
+ Args:
392
+ canned_output: The :class:`~syncade.spec_draft.SpecDraftOutput` produced on
393
+ success (default: a minimal one-criterion draft).
394
+ canned_text: If supplied, ``extract_final_text`` returns this
395
+ RAW string instead of ``canned_output.model_dump_json()`` — use to feed
396
+ malformed text and exercise the exit-70 parse-failure path.
397
+ canned_exception: If supplied, ``extract_final_text`` raises
398
+ it (e.g. ``ReviewerInvocationError`` → exit-40 subprocess failure).
399
+ record_invocations: If True (default), each ``build_invocation`` call's
400
+ ``(reviewer_config, worktree_path, prompt)`` is appended to
401
+ ``self.invocations``.
402
+ """
403
+
404
+ name = "openai"
405
+ """Matches the codex provider key so the synthetic ReviewerConfig validates."""
406
+
407
+ def __init__(
408
+ self,
409
+ canned_output: SpecDraftOutput | None = None,
410
+ canned_text: str | None = None,
411
+ canned_exception: Exception | None = None,
412
+ record_invocations: bool = True,
413
+ ) -> None:
414
+ self._canned_output_arg = canned_output
415
+ self.canned_text = canned_text
416
+ self.canned_exception = canned_exception
417
+ self.record_invocations = record_invocations
418
+ self.invocations: list[tuple[ReviewerConfig, Path, str]] = []
419
+ self.extract_calls = 0
420
+
421
+ @property
422
+ def canned_output(self) -> SpecDraftOutput:
423
+ if self._canned_output_arg is not None:
424
+ return self._canned_output_arg
425
+ return _default_canned_draft_output()
426
+
427
+ def build_invocation(
428
+ self,
429
+ reviewer_config: ReviewerConfig,
430
+ worktree_path: Path,
431
+ prompt: str,
432
+ ) -> Invocation:
433
+ """Record the call and return a no-op :class:`Invocation`. ``worktree_path``
434
+ is the drafter's cold isolation workspace, not repo_root."""
435
+ if self.record_invocations:
436
+ self.invocations.append((reviewer_config, worktree_path, prompt))
437
+ return Invocation(
438
+ argv=_noop_argv(),
439
+ cwd=worktree_path,
440
+ env=dict(os.environ),
441
+ stdin_text=None,
442
+ timeout_seconds=None,
443
+ )
444
+
445
+ def extract_final_text(
446
+ self,
447
+ result: SubprocessResult,
448
+ *,
449
+ empty_output_exception_class: type[Exception],
450
+ ) -> str:
451
+ """Return the canned text/output, or raise the canned exception.
452
+ ``empty_output_exception_class`` is accepted for protocol symmetry but
453
+ ignored (pass a ``canned_exception`` to simulate that case)."""
454
+ del result, empty_output_exception_class
455
+ self.extract_calls += 1
456
+ if self.canned_exception is not None:
457
+ raise self.canned_exception
458
+ if self.canned_text is not None:
459
+ return self.canned_text
460
+ return self.canned_output.model_dump_json()