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
syncade/synthesis.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Pydantic v2 models for the cold synthesizer's output.
|
|
2
|
+
|
|
3
|
+
The synthesizer consolidates reviewer outputs into one
|
|
4
|
+
:class:`SynthesizerOutput`. It does not see the diff or producer narrative; its
|
|
5
|
+
inputs are structured reviewer outputs only. It cannot invent findings; every
|
|
6
|
+
:class:`ConsolidatedFinding` must trace to at least one original reviewer
|
|
7
|
+
finding via :class:`FindingProvenance`. The final ship/no-ship verdict is
|
|
8
|
+
computed mechanically from this output, not by an LLM judgment field.
|
|
9
|
+
|
|
10
|
+
Key schema-level invariants:
|
|
11
|
+
|
|
12
|
+
- **Cannot invent findings.** ``ConsolidatedFinding.provenance`` is
|
|
13
|
+
required and ``min_length=1`` — a finding with no provenance is an
|
|
14
|
+
invented finding, schema-rejected.
|
|
15
|
+
- **Cannot deactivate unanimous blockers.** If two or more distinct
|
|
16
|
+
reviewers flagged the same consolidated concern AT
|
|
17
|
+
``severity="blocker"``, the synthesizer cannot dismiss it OR downgrade it
|
|
18
|
+
off ``severity="blocker"``. Coverage is counted over the blocker-severity
|
|
19
|
+
provenance entries only, so merging in a third, lower-severity entry
|
|
20
|
+
cannot disarm the guard. This is the hard guardrail from the design
|
|
21
|
+
discussion: two independent blind reviewers reaching blocker on the same
|
|
22
|
+
concern is the strongest signal we get; overriding it would cost more than
|
|
23
|
+
trusting it. Enforced in the schema
|
|
24
|
+
(``_validate_unanimous_blocker_not_deactivated``) rather than only in the
|
|
25
|
+
prompt, so a conforming :class:`SynthesizerOutput` cannot violate it.
|
|
26
|
+
- **Dismissal rationale required when dismissed.** Forces the
|
|
27
|
+
synthesizer to write down WHY it ruled a finding out, instead of
|
|
28
|
+
silently dropping it.
|
|
29
|
+
- **Severity-update rationale required when the synthesizer overrode
|
|
30
|
+
every reviewer.** Moving off one reviewer's call when another agrees
|
|
31
|
+
is judgment within reviewer signal; moving off all reviewers' calls
|
|
32
|
+
is independent judgment and needs explicit reason.
|
|
33
|
+
- **Whitespace-only string rejection** on every always-required string
|
|
34
|
+
field. ``Field(min_length=1)`` alone is insufficient: pure-whitespace
|
|
35
|
+
values pass length but defeat the field's purpose.
|
|
36
|
+
|
|
37
|
+
:func:`parse_synthesizer_output` turns raw stdout into a typed
|
|
38
|
+
:class:`SynthesizerOutput`. It reuses :mod:`syncade.findings_json` so
|
|
39
|
+
verdict-block selection — fence authority, code-sample masking, unmatched-brace
|
|
40
|
+
tolerance, CRLF handling, duplicate-key rejection — is identical to reviewer
|
|
41
|
+
parsing and cannot drift from it.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
47
|
+
|
|
48
|
+
from syncade.findings import Severity
|
|
49
|
+
from syncade.findings_json import decode_and_validate
|
|
50
|
+
from syncade.synthesis_clusters import (
|
|
51
|
+
RootCauseCluster,
|
|
52
|
+
validate_clusters_against_findings,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# get_synthesizer_schema_string moved to synthesis_schema; redundant-alias
|
|
56
|
+
# re-export (X as X) marks it intentional so it survives ruff's F401 autofix —
|
|
57
|
+
# syncade.synthesis.get_synthesizer_schema_string stays importable.
|
|
58
|
+
from syncade.synthesis_repair import _repair_known_model_deviations
|
|
59
|
+
from syncade.synthesis_schema import get_synthesizer_schema_string as get_synthesizer_schema_string
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SynthesizerOutputError(Exception):
|
|
63
|
+
"""Raised when synthesizer stdout can't be parsed as a :class:`SynthesizerOutput`.
|
|
64
|
+
|
|
65
|
+
Distinct from :class:`syncade.findings.ReviewerOutputError` so the
|
|
66
|
+
orchestrator's exit-code logic can distinguish "reviewer parse
|
|
67
|
+
failed" from "synthesizer parse failed" in error messaging. Both
|
|
68
|
+
still map to exit 70 (``REVIEWER_OUTPUT_UNPARSEABLE``) — same
|
|
69
|
+
operational fix path (inspect the raw .stdout, tighten the prompt
|
|
70
|
+
if needed) — but the message names which phase so the user knows
|
|
71
|
+
which file to open.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FindingProvenance(BaseModel):
|
|
76
|
+
"""Per-reviewer attribution for a :class:`ConsolidatedFinding`.
|
|
77
|
+
|
|
78
|
+
Records which reviewer flagged a given concern, with the index
|
|
79
|
+
into that reviewer's original ``findings`` list, the severity the
|
|
80
|
+
reviewer assigned, and the reviewer's verbatim one-line
|
|
81
|
+
description of the finding. Preserving the original description
|
|
82
|
+
lets the operator see how each reviewer framed the same concern —
|
|
83
|
+
useful when the two reviewers diverge on what they think the
|
|
84
|
+
problem actually is.
|
|
85
|
+
|
|
86
|
+
``original_index`` is informational provenance — it lets the
|
|
87
|
+
operator (or a future tool) jump from a consolidated finding back
|
|
88
|
+
to the source reviewer's parsed.json. It is NOT validated against
|
|
89
|
+
the actual size of the source reviewer's findings list, which
|
|
90
|
+
would require cross-input plumbing this module deliberately
|
|
91
|
+
avoids; that check lives in the orchestrator if it lives anywhere.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
model_config = ConfigDict(extra="forbid")
|
|
95
|
+
|
|
96
|
+
reviewer_name: str = Field(..., min_length=1)
|
|
97
|
+
original_severity: Severity
|
|
98
|
+
original_index: int = Field(..., ge=0)
|
|
99
|
+
# NO length or non-blank constraint, deliberately (PR-h-field-01, dogfood round 4).
|
|
100
|
+
# Every provenance entry passes through
|
|
101
|
+
# `synthesizer.validation._validate_provenance_against_reviewers`, which OVERWRITES this
|
|
102
|
+
# field with the source reviewer's own text. Whatever the model wrote here is discarded,
|
|
103
|
+
# so validating it rejects runs over a value that was never going to be used: a blank or
|
|
104
|
+
# whitespace-only quote aborted the whole run at exit 70 — the exact failure class the
|
|
105
|
+
# repair exists to eliminate, surviving through the schema instead of the validator.
|
|
106
|
+
# The guarantee did not weaken; it moved from "the model must write it correctly" to
|
|
107
|
+
# "syncade copies it from the source", which is strictly stronger.
|
|
108
|
+
original_description: str
|
|
109
|
+
|
|
110
|
+
@field_validator("reviewer_name")
|
|
111
|
+
@classmethod
|
|
112
|
+
def _validate_reviewer_name_nonblank(cls, v: str) -> str:
|
|
113
|
+
"""``reviewer_name`` must contain at least one non-whitespace
|
|
114
|
+
character.
|
|
115
|
+
|
|
116
|
+
Whitespace-only validation: ``min_length=1`` accepts
|
|
117
|
+
whitespace-only values like ``" "`` which would pass the
|
|
118
|
+
length check but carry no provenance information.
|
|
119
|
+
"""
|
|
120
|
+
if not v.strip():
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"reviewer_name must contain non-whitespace content; got "
|
|
123
|
+
"an all-whitespace value which provides no provenance "
|
|
124
|
+
"attribution"
|
|
125
|
+
)
|
|
126
|
+
return v
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def distinct_reviewer_count(provenance: list[FindingProvenance]) -> int:
|
|
130
|
+
"""Number of DISTINCT ``reviewer_name``s in a ``provenance`` list.
|
|
131
|
+
|
|
132
|
+
Single source of truth for "how many reviewers actually raised this
|
|
133
|
+
finding". Used by BOTH the unanimous-blocker deactivation guard
|
|
134
|
+
(:meth:`ConsolidatedFinding._validate_unanimous_blocker_not_deactivated`)
|
|
135
|
+
and the findings.md consensus renderer
|
|
136
|
+
(:func:`syncade.persistence._markdown._consensus_lines`), so the two
|
|
137
|
+
views of consensus cannot drift: a single reviewer listed twice is
|
|
138
|
+
ONE reviewer, never a unanimous blocker.
|
|
139
|
+
"""
|
|
140
|
+
return len({p.reviewer_name for p in provenance})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ConsolidatedFinding(BaseModel):
|
|
144
|
+
"""One concern in the synthesizer's consolidated finding set.
|
|
145
|
+
|
|
146
|
+
Built by merging one or more reviewers' original findings into a single
|
|
147
|
+
entry keyed on the underlying concern (different reviewers may word the same
|
|
148
|
+
issue differently). ``provenance`` carries the per-reviewer attribution;
|
|
149
|
+
``severity`` is the synthesizer's final call,
|
|
150
|
+
possibly differing from one or more reviewers'
|
|
151
|
+
``original_severity``.
|
|
152
|
+
|
|
153
|
+
Validators (all ``@model_validator(mode="after")``):
|
|
154
|
+
|
|
155
|
+
- :meth:`_validate_dismissal_rationale_present_when_dismissed` —
|
|
156
|
+
if ``dismissed=True``, ``dismissal_rationale`` must be a
|
|
157
|
+
non-whitespace string. Forces the synthesizer to record WHY a
|
|
158
|
+
finding was ruled out.
|
|
159
|
+
- :meth:`_validate_unanimous_blocker_not_deactivated` — if two or more
|
|
160
|
+
DISTINCT reviewers flagged the concern at
|
|
161
|
+
``original_severity="blocker"`` (counted over the blocker-severity
|
|
162
|
+
entries, so an extra lower-severity entry cannot disarm the
|
|
163
|
+
guard), the finding cannot be deactivated — neither dismissed
|
|
164
|
+
nor downgraded off ``"blocker"`` (finding A2). This is the hard
|
|
165
|
+
schema-level guardrail: two blind reviewers reaching blocker on
|
|
166
|
+
the same concern is the strongest signal we get; the
|
|
167
|
+
synthesizer cannot silently override that.
|
|
168
|
+
- :meth:`_validate_severity_change_rationale` — if ``severity``
|
|
169
|
+
differs from every ``original_severity`` in ``provenance`` (the
|
|
170
|
+
synthesizer moved off ALL reviewers' calls),
|
|
171
|
+
``severity_change_rationale`` is required.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="forbid")
|
|
175
|
+
|
|
176
|
+
description: str = Field(..., min_length=1)
|
|
177
|
+
file: str | None = None
|
|
178
|
+
severity: Severity
|
|
179
|
+
provenance: list[FindingProvenance] = Field(..., min_length=1)
|
|
180
|
+
dismissed: bool = False
|
|
181
|
+
dismissal_rationale: str | None = None
|
|
182
|
+
severity_change_rationale: str | None = None
|
|
183
|
+
|
|
184
|
+
@field_validator("description")
|
|
185
|
+
@classmethod
|
|
186
|
+
def _validate_description_nonblank(cls, v: str) -> str:
|
|
187
|
+
"""``description`` must contain at least one non-whitespace
|
|
188
|
+
character. Whitespace-only validation — a pure-whitespace
|
|
189
|
+
description renders as an empty bullet in ``findings.md``
|
|
190
|
+
and carries no information.
|
|
191
|
+
"""
|
|
192
|
+
if not v.strip():
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"description must contain non-whitespace content; got "
|
|
195
|
+
"an all-whitespace value which carries no description "
|
|
196
|
+
"of the consolidated finding"
|
|
197
|
+
)
|
|
198
|
+
return v
|
|
199
|
+
|
|
200
|
+
# Validator order matters for the operator's error-message experience. The
|
|
201
|
+
# structural-impossibility check for unanimous-blocker deactivation fires
|
|
202
|
+
# first; if dismissal is structurally allowed, we then check rationale.
|
|
203
|
+
|
|
204
|
+
@model_validator(mode="after")
|
|
205
|
+
def _validate_unanimous_blocker_not_deactivated(self) -> ConsolidatedFinding:
|
|
206
|
+
"""A unanimous reviewer blocker cannot be DEACTIVATED — neither
|
|
207
|
+
dismissed NOR downgraded off ``severity="blocker"`` (finding A2).
|
|
208
|
+
|
|
209
|
+
Two independent blind reviewers reaching 'blocker' on the same concern is
|
|
210
|
+
the strongest signal we get; the synthesizer's consolidation pass cannot
|
|
211
|
+
override it. Both deactivation paths slip it past the mechanical verdict
|
|
212
|
+
(``has_active_blocker`` counts only non-dismissed 'blocker' findings) — a
|
|
213
|
+
false SHIP — so both are schema-rejected.
|
|
214
|
+
|
|
215
|
+
Keys on DISTINCT reviewers among the BLOCKER-severity provenance
|
|
216
|
+
entries — not on every entry being a blocker. That distinction is
|
|
217
|
+
load-bearing (2026-07-27 audit rank 1 / A C-01 / B C1): the guard
|
|
218
|
+
used to require ``all(p.original_severity == "blocker")`` over raw
|
|
219
|
+
provenance, so appending one non-blocker entry from an already-
|
|
220
|
+
counted reviewer — ``[r1:blocker, r2:blocker, r1:minor]`` — silently
|
|
221
|
+
disabled it and let a two-reviewer blocker be dismissed into a
|
|
222
|
+
false SHIP. Merging a reviewer's lower-severity note about the same
|
|
223
|
+
concern is normal, model-reachable consolidation behavior, so the
|
|
224
|
+
predicate must be monotone: extra provenance can only ever ADD
|
|
225
|
+
coverage, never remove it.
|
|
226
|
+
|
|
227
|
+
Distinctness still comes from the shared ``distinct_reviewer_count``
|
|
228
|
+
helper (one reviewer listed twice isn't unanimity); the findings.md
|
|
229
|
+
consensus renderer calls the same helper over UNFILTERED provenance
|
|
230
|
+
because it answers a different question — who *raised* the finding,
|
|
231
|
+
at any severity. Provenance ``original_severity`` is cross-checked
|
|
232
|
+
truthful in ``synthesizer/validation.py``, so the schema can trust
|
|
233
|
+
it here. Runs first among the model validators so the
|
|
234
|
+
structural-impossibility error fires before the rationale checks.
|
|
235
|
+
"""
|
|
236
|
+
blocker_provenance = [p for p in self.provenance if p.original_severity == "blocker"]
|
|
237
|
+
distinct_reviewers = distinct_reviewer_count(blocker_provenance)
|
|
238
|
+
if distinct_reviewers < 2:
|
|
239
|
+
return self
|
|
240
|
+
if self.dismissed or self.severity != "blocker":
|
|
241
|
+
how = "dismiss" if self.dismissed else f"downgrade to severity={self.severity!r}"
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"cannot {how} a finding flagged at severity='blocker' by "
|
|
244
|
+
f"{distinct_reviewers} reviewers (unanimous-blocker rule): two or "
|
|
245
|
+
"more independent blind reviewers reaching 'blocker' is the "
|
|
246
|
+
"strongest available signal; the synthesizer cannot override it "
|
|
247
|
+
"(neither dismissal nor downgrade off 'blocker'). Provenance: "
|
|
248
|
+
+ ", ".join(f"{p.reviewer_name}@blocker" for p in blocker_provenance)
|
|
249
|
+
)
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
@model_validator(mode="after")
|
|
253
|
+
def _validate_dismissal_rationale_present_when_dismissed(self) -> ConsolidatedFinding:
|
|
254
|
+
"""When ``dismissed=True``, ``dismissal_rationale`` must contain
|
|
255
|
+
non-whitespace content.
|
|
256
|
+
|
|
257
|
+
The synthesizer's prompt instructs it to provide rationale, but
|
|
258
|
+
a model that silently drops the rationale would otherwise pass
|
|
259
|
+
validation. Whitespace-only is also rejected so the field
|
|
260
|
+
carries actual narrative the operator can audit.
|
|
261
|
+
|
|
262
|
+
Runs after the unanimous-blocker check so the
|
|
263
|
+
operator sees the structural-impossibility error first when
|
|
264
|
+
both rules apply.
|
|
265
|
+
"""
|
|
266
|
+
if self.dismissed:
|
|
267
|
+
if self.dismissal_rationale is None or not self.dismissal_rationale.strip():
|
|
268
|
+
raise ValueError(
|
|
269
|
+
"dismissal_rationale is required (and must contain "
|
|
270
|
+
"non-whitespace content) when dismissed=True; got "
|
|
271
|
+
f"{self.dismissal_rationale!r}. Dismissing a finding "
|
|
272
|
+
"without rationale leaves the operator no way to "
|
|
273
|
+
"audit the synthesizer's decision."
|
|
274
|
+
)
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
@model_validator(mode="after")
|
|
278
|
+
def _validate_severity_change_rationale(self) -> ConsolidatedFinding:
|
|
279
|
+
"""When ``severity`` differs from EVERY
|
|
280
|
+
``original_severity`` in ``provenance``,
|
|
281
|
+
``severity_change_rationale`` is required (and must be
|
|
282
|
+
non-whitespace).
|
|
283
|
+
|
|
284
|
+
The synthesizer is allowed to override individual reviewer
|
|
285
|
+
severity calls without justification when at least one
|
|
286
|
+
reviewer agrees with the final severity — that's normal
|
|
287
|
+
disagreement-arbitration. Moving off all reviewers' calls is
|
|
288
|
+
independent judgment and needs explicit rationale so the
|
|
289
|
+
operator can audit it.
|
|
290
|
+
"""
|
|
291
|
+
original_severities = {p.original_severity for p in self.provenance}
|
|
292
|
+
if self.severity in original_severities:
|
|
293
|
+
return self
|
|
294
|
+
if self.severity_change_rationale is None or not self.severity_change_rationale.strip():
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"severity_change_rationale is required when severity "
|
|
297
|
+
f"({self.severity!r}) differs from every reviewer's "
|
|
298
|
+
f"original_severity ({sorted(original_severities)!r}); the "
|
|
299
|
+
"synthesizer moved off ALL reviewers' calls and needs to "
|
|
300
|
+
"record why"
|
|
301
|
+
)
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class SynthesizerOutput(BaseModel):
|
|
306
|
+
"""Top-level output of one synthesizer subprocess run.
|
|
307
|
+
|
|
308
|
+
Holds the consolidated finding set and a headline
|
|
309
|
+
:attr:`synthesis_summary` narrating how the consolidation went: how many
|
|
310
|
+
findings merged, how many were dismissed, and where reviewers disagreed.
|
|
311
|
+
|
|
312
|
+
There is intentionally no ``verdict`` field. The verdict is a
|
|
313
|
+
mechanical function of ``consolidated_findings``: any non-dismissed blocker
|
|
314
|
+
means NO-SHIP (exit 30); otherwise SHIP (exit 0). Adding a verdict
|
|
315
|
+
field here would re-introduce LLM judgment at the verdict step,
|
|
316
|
+
which is exactly what this design moves away from.
|
|
317
|
+
|
|
318
|
+
Empty ``consolidated_findings`` is valid: it means both reviewers
|
|
319
|
+
surfaced nothing and the synthesizer has nothing to consolidate.
|
|
320
|
+
:attr:`synthesis_summary` is still required so even the empty case carries a
|
|
321
|
+
narrative. The :attr:`root_cause_clusters` field is an optional descriptive-only
|
|
322
|
+
grouping of consolidated findings that share a file, each grounded by a
|
|
323
|
+
verbatim quote from a reviewer's original text. It is **advisory**
|
|
324
|
+
— clusters never reach the mechanical verdict (``_compute_exit_code``
|
|
325
|
+
reads :func:`has_active_blocker` only) — and authors no cause or fix.
|
|
326
|
+
Defaults to ``[]``; the schema and cluster models live in
|
|
327
|
+
:mod:`syncade.synthesis_clusters`. Cross-checks that need
|
|
328
|
+
``consolidated_findings`` run in :meth:`_validate_root_cause_clusters`.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
model_config = ConfigDict(extra="forbid")
|
|
332
|
+
|
|
333
|
+
consolidated_findings: list[ConsolidatedFinding] = Field(default_factory=list)
|
|
334
|
+
synthesis_summary: str = Field(..., min_length=1)
|
|
335
|
+
root_cause_clusters: list[RootCauseCluster] = Field(default_factory=list)
|
|
336
|
+
|
|
337
|
+
@field_validator("synthesis_summary")
|
|
338
|
+
@classmethod
|
|
339
|
+
def _validate_synthesis_summary_nonblank(cls, v: str) -> str:
|
|
340
|
+
"""``synthesis_summary`` must contain at least one
|
|
341
|
+
non-whitespace character. Whitespace-only validation.
|
|
342
|
+
"""
|
|
343
|
+
if not v.strip():
|
|
344
|
+
raise ValueError(
|
|
345
|
+
"synthesis_summary must contain non-whitespace content; "
|
|
346
|
+
"got an all-whitespace value which provides no narrative "
|
|
347
|
+
"about how the consolidation went"
|
|
348
|
+
)
|
|
349
|
+
return v
|
|
350
|
+
|
|
351
|
+
@model_validator(mode="after")
|
|
352
|
+
def _validate_root_cause_clusters(self) -> SynthesizerOutput:
|
|
353
|
+
"""Run the cluster cross-checks that need the consolidated-finding
|
|
354
|
+
list (in-range member indices, disjoint clusters, ``anchor_file`` ==
|
|
355
|
+
each member's ``.file``).
|
|
356
|
+
|
|
357
|
+
Delegated to :func:`syncade.synthesis_clusters.validate_clusters_against_findings`
|
|
358
|
+
so the bulk of the cluster logic stays in its companion module; a
|
|
359
|
+
violation raises ``ValueError`` → ``ValidationError`` → exit 70, the
|
|
360
|
+
same path as every other synth-output schema failure. The empty default
|
|
361
|
+
(``[]``) is a no-op.
|
|
362
|
+
"""
|
|
363
|
+
validate_clusters_against_findings(self.root_cause_clusters, self.consolidated_findings)
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def has_active_blocker(output: SynthesizerOutput) -> bool:
|
|
368
|
+
"""Return True iff ``output.consolidated_findings`` contains any
|
|
369
|
+
non-dismissed finding with ``severity == "blocker"``.
|
|
370
|
+
|
|
371
|
+
This is the mechanical verdict's substrate: any non-dismissed blocker means
|
|
372
|
+
NO-SHIP (30), else SHIP (0), and ``persist_findings_md`` renders the
|
|
373
|
+
operator-facing Verdict line from the same condition. Centralized
|
|
374
|
+
here so the two callers can't drift — a future update to "what
|
|
375
|
+
counts as an active blocker" (e.g. an additional "active" flag
|
|
376
|
+
on ConsolidatedFinding) lands in one place.
|
|
377
|
+
|
|
378
|
+
Dismissed blockers do not count, by design: dismissal-with-
|
|
379
|
+
rationale is the synthesizer's bounded-judgment surface for
|
|
380
|
+
ruling out false positives. The schema's unanimous-blocker rule
|
|
381
|
+
ensures a dismissed blocker is always single-reviewer.
|
|
382
|
+
"""
|
|
383
|
+
return any(f.severity == "blocker" and not f.dismissed for f in output.consolidated_findings)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _validate_synthesizer_object(parsed: object) -> SynthesizerOutput:
|
|
387
|
+
"""Validate an already-decoded verdict object, strictly first and then
|
|
388
|
+
through the known-deviation repair list.
|
|
389
|
+
|
|
390
|
+
Raises :class:`pydantic.ValidationError` when both passes fail, so
|
|
391
|
+
:func:`~syncade.findings_json.decode_and_validate` reports it exactly as it
|
|
392
|
+
reports every other actor's schema rejection. The repair pass is a fixed
|
|
393
|
+
LIST of information-free provider deviations
|
|
394
|
+
(:func:`_repair_known_model_deviations`), not a tolerance — any other
|
|
395
|
+
deviation still fails.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
return SynthesizerOutput.model_validate(parsed)
|
|
399
|
+
except ValidationError:
|
|
400
|
+
repaired = _repair_known_model_deviations(parsed)
|
|
401
|
+
if repaired is parsed:
|
|
402
|
+
raise
|
|
403
|
+
return SynthesizerOutput.model_validate(repaired)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def parse_synthesizer_output(raw: str) -> SynthesizerOutput:
|
|
407
|
+
"""Parse a synthesizer's raw stdout text into a :class:`SynthesizerOutput`.
|
|
408
|
+
|
|
409
|
+
Selects exactly ONE verdict block via
|
|
410
|
+
:func:`syncade.findings_json._decode_verdict_object` — the last
|
|
411
|
+
``json``/unlabeled fence, or the whole response when no fence is present —
|
|
412
|
+
and validates it, strictly first and then through the known-deviation
|
|
413
|
+
repair list. If that block does not decode or does not validate, this
|
|
414
|
+
raises; it never falls back to an earlier block that happens to validate
|
|
415
|
+
(see :mod:`syncade.findings_json`).
|
|
416
|
+
|
|
417
|
+
Raises :class:`SynthesizerOutputError` rather than
|
|
418
|
+
:class:`ReviewerOutputError` so the orchestrator's error messaging can name
|
|
419
|
+
the phase the operator needs to debug, even though both map to exit 70.
|
|
420
|
+
"""
|
|
421
|
+
return decode_and_validate(
|
|
422
|
+
raw,
|
|
423
|
+
validate=_validate_synthesizer_object,
|
|
424
|
+
error=SynthesizerOutputError,
|
|
425
|
+
label="synthesizer",
|
|
426
|
+
model_name="SynthesizerOutput",
|
|
427
|
+
artifact="synthesizer.stdout in the round directory",
|
|
428
|
+
)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Root-cause cluster schema for the cold synthesizer.
|
|
2
|
+
|
|
3
|
+
Descriptive-only, zero-invention grouping: the synth groups ≥2 consolidated
|
|
4
|
+
findings that share a file and cites a **verbatim** quote from each member's
|
|
5
|
+
reviewer-original text. It authors **no cause and no fix** — it groups and
|
|
6
|
+
quotes; the producer infers the cause from the reviewers' own words. Clusters
|
|
7
|
+
are advisory: they never reach the mechanical verdict
|
|
8
|
+
(:func:`syncade.orchestrator.verdict._compute_exit_code` reads
|
|
9
|
+
:func:`syncade.synthesis.has_active_blocker` only).
|
|
10
|
+
|
|
11
|
+
This lives in its own module rather than ``synthesis.py`` (already at the
|
|
12
|
+
~500-LOC cap — see the design / the LOC discipline). ``synthesis.py`` imports
|
|
13
|
+
:class:`RootCauseCluster` for the ``SynthesizerOutput.root_cause_clusters``
|
|
14
|
+
field and calls :func:`validate_clusters_against_findings` from a delegating
|
|
15
|
+
``model_validator`` (the cross-checks need the ``consolidated_findings`` list,
|
|
16
|
+
which lives on ``SynthesizerOutput``). The verbatim-quote-against-reviewer-source
|
|
17
|
+
check lives in :mod:`syncade.synthesizer.validation` (it needs the input
|
|
18
|
+
``ReviewerOutput`` set).
|
|
19
|
+
|
|
20
|
+
**Cannot-invent, strengthened.** A cluster's only content is a grouping
|
|
21
|
+
(checkable: shared ``anchor_file``) + verbatim quotes (checkable: real
|
|
22
|
+
substrings of the reviewers' original findings) + an optional ``label`` that
|
|
23
|
+
must itself be a verbatim substring of one of the quotes. No field carries
|
|
24
|
+
authored causal prose.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from syncade.synthesis import ConsolidatedFinding
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClusterMemberEvidence(BaseModel):
|
|
38
|
+
"""One member finding's verbatim grounding in a root-cause cluster.
|
|
39
|
+
|
|
40
|
+
``finding_index`` indexes into ``SynthesizerOutput.consolidated_findings``;
|
|
41
|
+
``quote`` is a VERBATIM substring of that member's reviewer-original finding
|
|
42
|
+
text (validated against the input ``ReviewerOutput``s in
|
|
43
|
+
:mod:`syncade.synthesizer.validation` — NOT the synth's consolidated
|
|
44
|
+
rephrasing).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
finding_index: int = Field(..., ge=0)
|
|
50
|
+
quote: str = Field(..., min_length=1)
|
|
51
|
+
|
|
52
|
+
@field_validator("quote")
|
|
53
|
+
@classmethod
|
|
54
|
+
def _validate_quote_nonblank(cls, v: str) -> str:
|
|
55
|
+
"""A whitespace-only quote is a substring of almost anything and
|
|
56
|
+
grounds nothing — reject it."""
|
|
57
|
+
if not v.strip():
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"quote must contain non-whitespace content; an all-whitespace quote grounds nothing"
|
|
60
|
+
)
|
|
61
|
+
return v
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RootCauseCluster(BaseModel):
|
|
65
|
+
"""A descriptive-only grouping of ≥2 consolidated findings sharing a file.
|
|
66
|
+
|
|
67
|
+
Authors no cause and no fix. ``label``, if present, must be a verbatim
|
|
68
|
+
substring of one of the evidence quotes (never authored prose).
|
|
69
|
+
|
|
70
|
+
Self-contained validators run here; the cross-checks that need the
|
|
71
|
+
``consolidated_findings`` list (member indices in range, disjoint across
|
|
72
|
+
clusters, ``anchor_file`` == each member's ``.file``) run in
|
|
73
|
+
:func:`validate_clusters_against_findings`, invoked from a
|
|
74
|
+
``SynthesizerOutput`` ``model_validator``.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(extra="forbid")
|
|
78
|
+
|
|
79
|
+
member_finding_indices: list[int] = Field(..., min_length=2)
|
|
80
|
+
anchor_file: str = Field(..., min_length=1)
|
|
81
|
+
evidence: list[ClusterMemberEvidence] = Field(..., min_length=2)
|
|
82
|
+
label: str | None = None
|
|
83
|
+
|
|
84
|
+
@field_validator("anchor_file")
|
|
85
|
+
@classmethod
|
|
86
|
+
def _validate_anchor_file_nonblank(cls, v: str) -> str:
|
|
87
|
+
if not v.strip():
|
|
88
|
+
raise ValueError("anchor_file must contain non-whitespace content")
|
|
89
|
+
return v
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _validate_self_consistency(self) -> RootCauseCluster:
|
|
93
|
+
"""Self-contained cluster invariants (no consolidated_findings needed):
|
|
94
|
+
|
|
95
|
+
- member indices unique within the cluster;
|
|
96
|
+
- exactly one ``evidence`` per member (evidence ``finding_index`` set
|
|
97
|
+
equals the member-index set);
|
|
98
|
+
- ``label``, if present, is non-blank AND a verbatim substring of one
|
|
99
|
+
of the evidence quotes (a quote excerpt, not authored prose).
|
|
100
|
+
"""
|
|
101
|
+
members = self.member_finding_indices
|
|
102
|
+
if len(set(members)) != len(members):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"member_finding_indices must be unique within a cluster; got {members}"
|
|
105
|
+
)
|
|
106
|
+
ev_indices = [e.finding_index for e in self.evidence]
|
|
107
|
+
if len(set(ev_indices)) != len(ev_indices):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"evidence finding_index values must be unique within a cluster; got {ev_indices}"
|
|
110
|
+
)
|
|
111
|
+
if set(ev_indices) != set(members):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"evidence must have exactly one entry per member finding: the "
|
|
114
|
+
f"evidence finding_index set {sorted(set(ev_indices))} must equal the "
|
|
115
|
+
f"member_finding_indices set {sorted(set(members))}"
|
|
116
|
+
)
|
|
117
|
+
if self.label is not None:
|
|
118
|
+
if not self.label.strip():
|
|
119
|
+
raise ValueError("label, when present, must contain non-whitespace content")
|
|
120
|
+
if not any(self.label in e.quote for e in self.evidence):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"label {self.label!r}, when present, must be a verbatim substring "
|
|
123
|
+
"of one of the cluster's evidence quotes — it is a quote excerpt, "
|
|
124
|
+
"not authored prose"
|
|
125
|
+
)
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_clusters_against_findings(
|
|
130
|
+
clusters: list[RootCauseCluster],
|
|
131
|
+
consolidated_findings: list[ConsolidatedFinding],
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Cross-checks that need the ``consolidated_findings`` list.
|
|
134
|
+
|
|
135
|
+
Invoked from a ``SynthesizerOutput`` ``@model_validator(mode="after")`` so a
|
|
136
|
+
violation raises ``ValueError`` → ``ValidationError`` → exit 70 (same path as
|
|
137
|
+
the other synth-output schema failures). Invariants:
|
|
138
|
+
|
|
139
|
+
1. every ``member_finding_indices`` value is in range ``[0, len(findings))``;
|
|
140
|
+
2. findings are disjoint across clusters (each finding in ≤1 cluster);
|
|
141
|
+
3. ``anchor_file`` equals the ``.file`` of every member finding.
|
|
142
|
+
|
|
143
|
+
(≥2 members + one-evidence-per-member + label-is-a-quote are self-contained
|
|
144
|
+
on :class:`RootCauseCluster`; verbatim-quote-vs-reviewer-source is in
|
|
145
|
+
:mod:`syncade.synthesizer.validation`.)
|
|
146
|
+
|
|
147
|
+
A finding with ``file=None`` cannot be clustered: ``anchor_file`` is a
|
|
148
|
+
required non-blank string, so the check #3 equality fails — the intended
|
|
149
|
+
precision floor (a cluster requires a shared file).
|
|
150
|
+
"""
|
|
151
|
+
n = len(consolidated_findings)
|
|
152
|
+
seen: set[int] = set()
|
|
153
|
+
for ci, cluster in enumerate(clusters):
|
|
154
|
+
for idx in cluster.member_finding_indices:
|
|
155
|
+
if not (0 <= idx < n):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"root_cause_clusters[{ci}] member_finding_indices contains {idx}, "
|
|
158
|
+
f"out of range for {n} consolidated finding(s)"
|
|
159
|
+
)
|
|
160
|
+
if idx in seen:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"root_cause_clusters[{ci}] member finding {idx} already belongs to "
|
|
163
|
+
"another cluster; clusters must be disjoint (each finding in at most "
|
|
164
|
+
"one cluster)"
|
|
165
|
+
)
|
|
166
|
+
seen.add(idx)
|
|
167
|
+
for idx in cluster.member_finding_indices:
|
|
168
|
+
member_file = consolidated_findings[idx].file
|
|
169
|
+
if member_file != cluster.anchor_file:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"root_cause_clusters[{ci}] anchor_file {cluster.anchor_file!r} does "
|
|
172
|
+
f"not match the file of member finding {idx} ({member_file!r}); a "
|
|
173
|
+
"cluster requires a shared file (every member must be in anchor_file)"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cluster_schema_fragment() -> str:
|
|
178
|
+
"""The ``root_cause_clusters`` block for the synthesizer prompt's JSON-schema
|
|
179
|
+
skeleton (concatenated into
|
|
180
|
+
:func:`syncade.synthesis.get_synthesizer_schema_string`).
|
|
181
|
+
|
|
182
|
+
Descriptive-only: the inline comments stress group-and-quote, no authored
|
|
183
|
+
cause/fix, and that clusters never affect the verdict.
|
|
184
|
+
"""
|
|
185
|
+
return (
|
|
186
|
+
' "root_cause_clusters": [ // OPTIONAL, default []; ADVISORY only — '
|
|
187
|
+
"never affects the verdict\n"
|
|
188
|
+
" {\n"
|
|
189
|
+
' "member_finding_indices": [int, int], // >=2 indices into '
|
|
190
|
+
"consolidated_findings; same file; disjoint across clusters\n"
|
|
191
|
+
' "anchor_file": "path", // MUST equal the .file of every member finding\n'
|
|
192
|
+
' "evidence": [ // exactly one per member\n'
|
|
193
|
+
" {\n"
|
|
194
|
+
' "finding_index": int, // a member index\n'
|
|
195
|
+
' "quote": "string" // VERBATIM substring of that member\'s '
|
|
196
|
+
"reviewer-original finding text — do NOT paraphrase\n"
|
|
197
|
+
" }\n"
|
|
198
|
+
" ],\n"
|
|
199
|
+
' "label": "string"|null // OPTIONAL; if present, a verbatim substring '
|
|
200
|
+
"of one quote — NOT an authored cause or fix\n"
|
|
201
|
+
" }\n"
|
|
202
|
+
" ]"
|
|
203
|
+
)
|