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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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()
|