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,201 @@
|
|
|
1
|
+
"""findings.md headline-verdict derivation.
|
|
2
|
+
|
|
3
|
+
:func:`_compute_findings_md_verdict` derives the
|
|
4
|
+
findings.md headline (SHIP / NO-SHIP / ABORT) from the synth's consolidated
|
|
5
|
+
findings + the test-leg outcome; the matrix mirrors
|
|
6
|
+
:func:`syncade.orchestrator._compute_exit_code`'s test-leg branches so the two
|
|
7
|
+
surfaces always agree about the overall run outcome.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from syncade.dispatcher import DispatchResult
|
|
13
|
+
from syncade.synthesis import SynthesizerOutput, has_active_blocker
|
|
14
|
+
from syncade.test_runner import TestRunResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _compute_findings_md_verdict(
|
|
18
|
+
synth_output: SynthesizerOutput,
|
|
19
|
+
test_result: TestRunResult | None,
|
|
20
|
+
test_skip_reason: str | None = None,
|
|
21
|
+
blocking_check_results: list[TestRunResult] | None = None,
|
|
22
|
+
dispatch_result: DispatchResult | None = None,
|
|
23
|
+
) -> tuple[str, str]:
|
|
24
|
+
"""Derive the findings.md headline verdict from the synth's findings.
|
|
25
|
+
|
|
26
|
+
The result also depends on the test-leg
|
|
27
|
+
outcome, (when the leg was skipped) the skip reason, AND the
|
|
28
|
+
BLOCKING mechanical-check results.
|
|
29
|
+
|
|
30
|
+
Returns ``(label, qualifier)`` where ``label`` is the headline
|
|
31
|
+
("SHIP", "NO-SHIP", "ABORT") and ``qualifier`` is the
|
|
32
|
+
parenthetical explaining where the verdict came from.
|
|
33
|
+
|
|
34
|
+
The matrix mirrors
|
|
35
|
+
:func:`syncade.orchestrator._compute_exit_code`'s test-leg +
|
|
36
|
+
blocking-check branches exactly so the two surfaces always agree
|
|
37
|
+
about the overall run outcome. The test leg and the blocking
|
|
38
|
+
checks share one "mechanical gate" bucket; ``subprocess_error``
|
|
39
|
+
(ABORT, exit 40) outranks BOTH a synth blocker and a ``failed``
|
|
40
|
+
gate (NO-SHIP, exit 30), matching ``_compute_exit_code``'s
|
|
41
|
+
ordering — a gate that couldn't run leaves the round
|
|
42
|
+
indeterminate regardless of what the synth found:
|
|
43
|
+
|
|
44
|
+
+-----------------+--------------------------+-----------+
|
|
45
|
+
| synth blocker? | mechanical gate outcome | verdict |
|
|
46
|
+
+=================+==========================+===========+
|
|
47
|
+
| (any) | any gate subprocess_err | ABORT |
|
|
48
|
+
| yes | no gate subprocess_err | NO-SHIP |
|
|
49
|
+
| no | any gate failed | NO-SHIP |
|
|
50
|
+
| no | all gates passed/skipped | SHIP |
|
|
51
|
+
+-----------------+--------------------------+-----------+
|
|
52
|
+
|
|
53
|
+
A "mechanical gate" is the test leg OR a ``blocking`` check.
|
|
54
|
+
``advisory`` checks are never passed in (``blocking_check_results``
|
|
55
|
+
holds only blocking ones), so they are structurally unable to move
|
|
56
|
+
the verdict — exactly as they cannot reach ``_compute_exit_code``.
|
|
57
|
+
With no blocking checks the bucket is just the test leg:
|
|
58
|
+
|
|
59
|
+
+-----------------+----------------+----------------------+-----------+
|
|
60
|
+
| synth blocker? | test outcome | test_skip_reason | verdict |
|
|
61
|
+
+=================+================+======================+===========+
|
|
62
|
+
| yes (any) | (skipped) | (any) | NO-SHIP |
|
|
63
|
+
| no | passed | None | SHIP |
|
|
64
|
+
| no | failed | None | NO-SHIP |
|
|
65
|
+
| no | subprocess_err | None | ABORT |
|
|
66
|
+
| no | None (skipped) | test_command_unset | SHIP |
|
|
67
|
+
| no | None (skipped) | reviewer_failed * | NO-SHIP |
|
|
68
|
+
| no | None (skipped) | synth_failed * | NO-SHIP |
|
|
69
|
+
| no | None (skipped) | test_worktree_error | ABORT |
|
|
70
|
+
+-----------------+----------------+----------------------+-----------+
|
|
71
|
+
|
|
72
|
+
\\* These rows are theoretical — persist_findings_md is only
|
|
73
|
+
called on the synth-success-and-all-reviewers-succeeded path,
|
|
74
|
+
so test_skip_reason can practically only be
|
|
75
|
+
``test_command_unset`` or ``test_worktree_error`` here. The
|
|
76
|
+
matrix rows are listed for completeness / future-proofing.
|
|
77
|
+
|
|
78
|
+
The "ABORT" wording for subprocess_error AND
|
|
79
|
+
``test_worktree_error`` is intentional — both are
|
|
80
|
+
leg-couldn't-run states operationally distinct from "ship it"
|
|
81
|
+
AND from "don't ship it because of a real defect". The
|
|
82
|
+
operator's fix in both cases is environmental (PATH / binary
|
|
83
|
+
/ config / git state), not code.
|
|
84
|
+
"""
|
|
85
|
+
# Fold the test leg AND the blocking mechanical checks into one gate
|
|
86
|
+
# bucket, mirroring _compute_exit_code. subprocess_error (ABORT, exit 40)
|
|
87
|
+
# is the highest-precedence signal — a gate that couldn't run leaves the
|
|
88
|
+
# round indeterminate, so the operator fixes the environment first. With
|
|
89
|
+
# no blocking checks the bucket is just the test leg.
|
|
90
|
+
blocking = blocking_check_results or []
|
|
91
|
+
errored_checks = [c for c in blocking if c.outcome == "subprocess_error"]
|
|
92
|
+
failed_checks = [c for c in blocking if c.outcome == "failed"]
|
|
93
|
+
|
|
94
|
+
# ABORT — a mechanical gate couldn't run (its harness/binary broke). This
|
|
95
|
+
# is evaluated BEFORE the synth-blocker branch: checks run on synth-blocker
|
|
96
|
+
# rounds (the test leg does not), so a broken check harness can coincide
|
|
97
|
+
# with an active blocker, and an indeterminate gate outranks even NO-SHIP —
|
|
98
|
+
# exactly as _compute_exit_code maps a blocking subprocess_error to exit 40
|
|
99
|
+
# regardless of synth state. (test_result is None on blocker rounds, so its
|
|
100
|
+
# branch never fires there; the zero-checks ordering is unchanged.)
|
|
101
|
+
if test_result is not None and test_result.outcome == "subprocess_error":
|
|
102
|
+
return (
|
|
103
|
+
"ABORT",
|
|
104
|
+
"test re-run subprocess failed; verdict indeterminate until "
|
|
105
|
+
"the environment problem is fixed",
|
|
106
|
+
)
|
|
107
|
+
if errored_checks:
|
|
108
|
+
return (
|
|
109
|
+
"ABORT",
|
|
110
|
+
f"blocking check '{errored_checks[0].name}' subprocess failed; "
|
|
111
|
+
"verdict indeterminate until the environment problem is fixed",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Synth blocker wins over a gate that ran (failed/passed) — the test leg
|
|
115
|
+
# is skipped on synth-blocker paths anyway (see _compute_exit_code), so
|
|
116
|
+
# test_result is None here and only checks can be present.
|
|
117
|
+
if has_active_blocker(synth_output):
|
|
118
|
+
return ("NO-SHIP", "mechanical, from consolidated_findings")
|
|
119
|
+
|
|
120
|
+
# test-worktree-error is operationally an ABORT,
|
|
121
|
+
# not a SHIP. The leg couldn't be provisioned, so the test
|
|
122
|
+
# signal is indeterminate. exit_code on the orchestrator side
|
|
123
|
+
# is 60 (WORKTREE_ERROR), not 0 — findings.md saying SHIP
|
|
124
|
+
# would directly contradict the exit code.
|
|
125
|
+
if test_skip_reason == "test_worktree_error":
|
|
126
|
+
return (
|
|
127
|
+
"ABORT",
|
|
128
|
+
"test re-run worktree could not be provisioned; verdict "
|
|
129
|
+
"indeterminate until the worktree provisioning failure "
|
|
130
|
+
"is fixed (exit 60)",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# NO-SHIP — a mechanical gate ran and failed.
|
|
134
|
+
if test_result is not None and test_result.outcome == "failed":
|
|
135
|
+
return (
|
|
136
|
+
"NO-SHIP",
|
|
137
|
+
f"mechanical, from test re-run exit {test_result.exit_code} (synthesizer was clean)",
|
|
138
|
+
)
|
|
139
|
+
if failed_checks:
|
|
140
|
+
return (
|
|
141
|
+
"NO-SHIP",
|
|
142
|
+
f"mechanical, from blocking check '{failed_checks[0].name}' exit "
|
|
143
|
+
f"{failed_checks[0].exit_code} (synthesizer was clean)",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# DECISION NEEDED — every gate is clean, but two or more distinct reviewers
|
|
147
|
+
# each raised a blocker and the synthesizer deactivated all of them
|
|
148
|
+
# (PR-h-01 increment D → exit 10). Printing SHIP here would contradict both
|
|
149
|
+
# the exit code and decision-needed.md, which says in as many words that the
|
|
150
|
+
# loop refused to report a SHIP it cannot justify. findings.md is the
|
|
151
|
+
# most-read artifact, so this is the surface where that contradiction does
|
|
152
|
+
# the most damage.
|
|
153
|
+
#
|
|
154
|
+
# This bug class has now regressed three times (T1.6 failed test leg, then
|
|
155
|
+
# failed blocking check, then this) for one reason: a new exit code was
|
|
156
|
+
# added without a matching branch here. If you add a fourth, add it here.
|
|
157
|
+
if _multi_reviewer_blockers_all_deactivated(dispatch_result):
|
|
158
|
+
return (
|
|
159
|
+
"DECISION NEEDED",
|
|
160
|
+
"2+ reviewers each raised a blocker and all were deactivated; "
|
|
161
|
+
"see decision-needed.md (exit 10)",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# SHIP — synth clean and every mechanical gate passed or was skipped.
|
|
165
|
+
if test_result is None:
|
|
166
|
+
# Test leg skipped — configured off (test_command_unset) OR a
|
|
167
|
+
# hypothetical reviewer/synth-failed path (which doesn't reach here in
|
|
168
|
+
# production but is listed in the matrix for completeness).
|
|
169
|
+
return ("SHIP", "mechanical, from consolidated_findings; test leg skipped")
|
|
170
|
+
# test_result.outcome == "passed".
|
|
171
|
+
return (
|
|
172
|
+
"SHIP",
|
|
173
|
+
"mechanical, from consolidated_findings + test re-run passed",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _multi_reviewer_blockers_all_deactivated(dispatch_result: DispatchResult | None) -> bool:
|
|
178
|
+
"""Mirror of :func:`syncade.orchestrator.verdict._multi_reviewer_blockers_all_deactivated`
|
|
179
|
+
for the render surface.
|
|
180
|
+
|
|
181
|
+
Deliberately NOT imported from the orchestrator: ``persistence`` must not
|
|
182
|
+
depend on ``orchestrator`` (which imports persistence), and the predicate is
|
|
183
|
+
one set comprehension. The behavioural tie is enforced by test, not by a
|
|
184
|
+
shared import — see ``tests/persistence/test_findings_md_verdict.py``.
|
|
185
|
+
|
|
186
|
+
Callers reach this only after every gate is clean and
|
|
187
|
+
``has_active_blocker`` is False, so "all deactivated" needs no separate
|
|
188
|
+
check, exactly as on the orchestrator side.
|
|
189
|
+
"""
|
|
190
|
+
if dispatch_result is None:
|
|
191
|
+
return False
|
|
192
|
+
return (
|
|
193
|
+
len(
|
|
194
|
+
{
|
|
195
|
+
r.reviewer_name
|
|
196
|
+
for r in dispatch_result.successes
|
|
197
|
+
if r.output is not None and any(f.severity == "blocker" for f in r.output.findings)
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
>= 2
|
|
201
|
+
)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Markdown-rendering helpers shared across persistence write paths.
|
|
2
|
+
|
|
3
|
+
These leaves of the dependency graph carry only narrow domain imports —
|
|
4
|
+
``syncade.dispatcher`` (``ReviewerRunResult`` for :func:`_reviewer_file_links`,
|
|
5
|
+
``DispatchResult`` for :func:`_consensus_lines`) and
|
|
6
|
+
``syncade.synthesis.ConsolidatedFinding`` (for :func:`_consensus_lines`).
|
|
7
|
+
Higher-level writers (``findings_md``, ``run_summary``) compose these into
|
|
8
|
+
per-document layout.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from syncade.dispatcher import DispatchResult, ReviewerRunResult
|
|
14
|
+
from syncade.synthesis import ConsolidatedFinding, distinct_reviewer_count
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _md_inline_code(text: str) -> str:
|
|
18
|
+
"""Render ``text`` as a markdown inline-code span.
|
|
19
|
+
|
|
20
|
+
Handles content that itself contains backticks.
|
|
21
|
+
|
|
22
|
+
The CommonMark inline-code-span rule: wrap the content with
|
|
23
|
+
a run of backticks one longer than any run of backticks in
|
|
24
|
+
the content. If the content begins or ends with a backtick
|
|
25
|
+
(or any whitespace at the edges of the content matters), pad
|
|
26
|
+
with a single space on each side.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
- ``hello`` → ``\\`hello\\```
|
|
30
|
+
- ``pytest --basetemp=\\`mktemp -d\\``` → ``\\`\\`pytest
|
|
31
|
+
--basetemp=\\`mktemp -d\\`\\`\\``` (double-backtick fence
|
|
32
|
+
surrounds the single-backtick content; outer spaces would
|
|
33
|
+
be needed only if content started/ended with a backtick).
|
|
34
|
+
- ``\\`leading-backtick`` → ``\\`\\` \\`leading-backtick \\`\\``
|
|
35
|
+
|
|
36
|
+
Why this helper exists: the operator's free-form
|
|
37
|
+
``test_command`` can contain backticks for command
|
|
38
|
+
substitution (``$()`` is the modern form but backticks still
|
|
39
|
+
appear in legacy or terse configs). Rendering it with a
|
|
40
|
+
single-backtick markdown span would close the code span
|
|
41
|
+
inside the command, producing broken markdown that renders
|
|
42
|
+
half the operator's command as plain text.
|
|
43
|
+
|
|
44
|
+
: this helper is for SINGLE-LINE content only. Multi-
|
|
45
|
+
line content (operator's TOML triple-quoted commands) must
|
|
46
|
+
use :func:`_md_command_field` instead, which switches to a
|
|
47
|
+
fenced code block when newlines are present. Calling
|
|
48
|
+
``_md_inline_code`` with newline content produces broken
|
|
49
|
+
markdown (the inline span doesn't allow newlines).
|
|
50
|
+
"""
|
|
51
|
+
if not text:
|
|
52
|
+
# Empty span. Use a minimal valid form rather than emit
|
|
53
|
+
# ``"`"`` (which is an unclosed backtick).
|
|
54
|
+
return "``"
|
|
55
|
+
# Find the longest run of consecutive backticks in the text.
|
|
56
|
+
max_run = 0
|
|
57
|
+
current = 0
|
|
58
|
+
for ch in text:
|
|
59
|
+
if ch == "`":
|
|
60
|
+
current += 1
|
|
61
|
+
if current > max_run:
|
|
62
|
+
max_run = current
|
|
63
|
+
else:
|
|
64
|
+
current = 0
|
|
65
|
+
# Wrap with a fence one longer than the longest internal run.
|
|
66
|
+
fence = "`" * (max_run + 1)
|
|
67
|
+
# If the content starts or ends with a backtick, pad with a
|
|
68
|
+
# single space on each side so the fence doesn't touch the
|
|
69
|
+
# backtick (CommonMark would otherwise interpret the
|
|
70
|
+
# leading/trailing backtick as part of the fence).
|
|
71
|
+
pad = " " if (text.startswith("`") or text.endswith("`")) else ""
|
|
72
|
+
return f"{fence}{pad}{text}{pad}{fence}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _md_command_lines(
|
|
76
|
+
command: str,
|
|
77
|
+
*,
|
|
78
|
+
prefix: str = "",
|
|
79
|
+
inline_suffix: str = "",
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Render an operator-configured ``test_command`` as
|
|
82
|
+
one or more markdown lines. Returns a list suitable for
|
|
83
|
+
``lines.extend(...)``.
|
|
84
|
+
|
|
85
|
+
Two rendering forms based on whether ``command`` contains
|
|
86
|
+
newlines:
|
|
87
|
+
|
|
88
|
+
1. **Single-line** → ``[f"{prefix}**Command:** {inline}{inline_suffix}"]``
|
|
89
|
+
where ``inline`` is :func:`_md_inline_code`'s
|
|
90
|
+
backtick-safe span. ``inline_suffix`` is appended to the
|
|
91
|
+
end of the line (used for the findings.md trailing
|
|
92
|
+
two-space hard-break convention).
|
|
93
|
+
2. **Multi-line** → a fenced code block on its own
|
|
94
|
+
paragraph::
|
|
95
|
+
|
|
96
|
+
{prefix}**Command:**
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
{command}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The ``inline_suffix`` is intentionally NOT applied to the
|
|
103
|
+
multi-line form — fenced code blocks don't need a hard-
|
|
104
|
+
break suffix, and adding one would create a malformed line.
|
|
105
|
+
The fence length adapts to the longest internal run of
|
|
106
|
+
backticks so commands containing backtick-fenced
|
|
107
|
+
subshells still render correctly.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
command: The test command string (possibly multi-line).
|
|
111
|
+
prefix: Leading text on the first line (e.g. ``"- "`` for
|
|
112
|
+
list-item context in summary.md; empty for the bare
|
|
113
|
+
findings.md Test Suite block).
|
|
114
|
+
inline_suffix: Trailing text appended to the single-line
|
|
115
|
+
form (e.g. ``" "`` for markdown hard-break).
|
|
116
|
+
Ignored for the multi-line form.
|
|
117
|
+
|
|
118
|
+
Why this helper exists: TOML triple-quoted strings let
|
|
119
|
+
operators write multi-line test_commands (e.g. setup +
|
|
120
|
+
invocation, one-step-per-line for clarity). Rendering
|
|
121
|
+
multi-line content with the single-line ``**Command:**
|
|
122
|
+
\\`...\\``` form produces broken markdown — the inline span
|
|
123
|
+
doesn't accept newlines, so the second line drops out of the
|
|
124
|
+
code span and renders as paragraph text.
|
|
125
|
+
"""
|
|
126
|
+
if "\n" not in command:
|
|
127
|
+
return [f"{prefix}**Command:** {_md_inline_code(command)}{inline_suffix}"]
|
|
128
|
+
# Multi-line: render as a fenced block. Fence length is one
|
|
129
|
+
# longer than the longest run of backticks in the content
|
|
130
|
+
# so any inner backticks pass through unchanged.
|
|
131
|
+
max_run = 0
|
|
132
|
+
current = 0
|
|
133
|
+
for ch in command:
|
|
134
|
+
if ch == "`":
|
|
135
|
+
current += 1
|
|
136
|
+
if current > max_run:
|
|
137
|
+
max_run = current
|
|
138
|
+
else:
|
|
139
|
+
current = 0
|
|
140
|
+
fence = "`" * max(3, max_run + 1)
|
|
141
|
+
return [
|
|
142
|
+
f"{prefix}**Command:**",
|
|
143
|
+
"",
|
|
144
|
+
f"{fence}sh",
|
|
145
|
+
command.rstrip("\n"),
|
|
146
|
+
fence,
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _file_or_repo_wide(file: str | None) -> str:
|
|
151
|
+
"""Markdown for ``ConsolidatedFinding.file`` rendering — ``file``
|
|
152
|
+
as a code-span when present, ``"repo-wide"`` (no backticks) when
|
|
153
|
+
null. Matches the brief's ``findings.md`` layout."""
|
|
154
|
+
if file is None:
|
|
155
|
+
return "repo-wide"
|
|
156
|
+
return f"`{file}`"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _consensus_lines(
|
|
160
|
+
finding: ConsolidatedFinding,
|
|
161
|
+
dispatch_result: DispatchResult | None,
|
|
162
|
+
) -> list[str]:
|
|
163
|
+
"""the advisory per-finding reviewer-consensus line for findings.md.
|
|
164
|
+
|
|
165
|
+
Returns a single ``**Consensus:** N of M reviewers[ (unanimous)]`` line
|
|
166
|
+
(as a one-element list, for ``lines.extend``), or ``[]`` when
|
|
167
|
+
``dispatch_result is None``.
|
|
168
|
+
|
|
169
|
+
- **N** = distinct ``reviewer_name``s in the finding's ``provenance`` —
|
|
170
|
+
how many reviewers actually raised this finding.
|
|
171
|
+
- **M** = reviewers in the run (``len(dispatch_result.results)``).
|
|
172
|
+
- ``M > 1 and N == M`` → append ``(unanimous)``; ``M == 1`` → the singular
|
|
173
|
+
noun "reviewer" (unanimity is only meaningful with more than one).
|
|
174
|
+
|
|
175
|
+
Advisory only: consensus is render-derived and never reaches
|
|
176
|
+
:func:`syncade.orchestrator._compute_exit_code` — the mechanical verdict is
|
|
177
|
+
untouched. Dismissed findings still render their consensus; it describes who
|
|
178
|
+
*raised* the finding, independent of the synthesizer's disposition.
|
|
179
|
+
"""
|
|
180
|
+
if dispatch_result is None:
|
|
181
|
+
return []
|
|
182
|
+
m = len(dispatch_result.results)
|
|
183
|
+
n = distinct_reviewer_count(finding.provenance)
|
|
184
|
+
noun = "reviewer" if m == 1 else "reviewers"
|
|
185
|
+
line = f"**Consensus:** {n} of {m} {noun}"
|
|
186
|
+
if m > 1 and n == m:
|
|
187
|
+
line += " (unanimous)"
|
|
188
|
+
return [f"{line} "]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _summary_needs_block_form(summary: str) -> bool:
|
|
192
|
+
"""Decide whether to render ``summary`` inline or as its own block.
|
|
193
|
+
|
|
194
|
+
The reviewer template allows the ``summary`` field to be "one paragraph or a
|
|
195
|
+
short bulleted list". Inline rendering
|
|
196
|
+
(``**Summary:** the prose``) reads cleanly for one-paragraph
|
|
197
|
+
content, but produces malformed markdown when the summary starts
|
|
198
|
+
with a list marker (``- ran pytest`` becomes
|
|
199
|
+
``**Summary:** - ran pytest`` — the ``-`` reads as inline text,
|
|
200
|
+
not a list bullet) or contains newlines (the second line and
|
|
201
|
+
onward render outside the bold marker's scope and can look
|
|
202
|
+
detached).
|
|
203
|
+
|
|
204
|
+
Block form is used when either holds:
|
|
205
|
+
|
|
206
|
+
- the summary contains a newline (multi-line content), OR
|
|
207
|
+
- the summary's first non-whitespace characters are a markdown
|
|
208
|
+
list marker (``- ``, ``* ``, or ``+ ``) — the reviewer
|
|
209
|
+
intentionally wrote a bulleted list.
|
|
210
|
+
|
|
211
|
+
Otherwise inline form is used.
|
|
212
|
+
"""
|
|
213
|
+
if "\n" in summary:
|
|
214
|
+
return True
|
|
215
|
+
stripped_left = summary.lstrip()
|
|
216
|
+
return stripped_left.startswith(("- ", "* ", "+ "))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _format_summary_block(summary: str) -> list[str]:
|
|
220
|
+
"""Render the ``**Summary:**`` block for one reviewer.
|
|
221
|
+
|
|
222
|
+
See :func:`_summary_needs_block_form` for the inline-vs-block
|
|
223
|
+
decision rule. Returns a list of lines suitable for
|
|
224
|
+
``lines.extend(...)``. Inline form is one line; block form is
|
|
225
|
+
the header on its own line, then a blank line, then the summary
|
|
226
|
+
content (which may itself span multiple lines). Trailing
|
|
227
|
+
whitespace is never produced — the inline form ends with the
|
|
228
|
+
summary content directly; the block form's header line is bare.
|
|
229
|
+
"""
|
|
230
|
+
if _summary_needs_block_form(summary):
|
|
231
|
+
return ["**Summary:**", "", summary]
|
|
232
|
+
return [f"**Summary:** {summary}"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _format_string_list_block(header: str, items: list[str]) -> list[str]:
|
|
236
|
+
"""Render a ``**header:**`` block for the narrative sections
|
|
237
|
+
in ``summary.md`` as a list of lines (no trailing whitespace).
|
|
238
|
+
|
|
239
|
+
- **Empty list** renders inline as a single line ending with
|
|
240
|
+
``None.``::
|
|
241
|
+
|
|
242
|
+
**Coverage gaps:** None.
|
|
243
|
+
|
|
244
|
+
Brief calls this out by name: empty lists should read as
|
|
245
|
+
``None`` for cleaner human reading, not as an empty bullet.
|
|
246
|
+
- **Non-empty list** renders the header on its own line, then a
|
|
247
|
+
blank line, then a markdown bulleted list, one item per line.
|
|
248
|
+
The header line has NO trailing whitespace (fix #3 from the
|
|
249
|
+
QA round)::
|
|
250
|
+
|
|
251
|
+
**Coverage gaps:**
|
|
252
|
+
|
|
253
|
+
- did not exercise the staging Postgres
|
|
254
|
+
- skipped playwright on mobile breakpoints
|
|
255
|
+
|
|
256
|
+
Returns a list of lines suitable for the caller's
|
|
257
|
+
``lines.extend(...)`` — the caller is responsible for joining
|
|
258
|
+
with ``\\n`` at the end. Returning a list (rather than a single
|
|
259
|
+
string) is what makes the no-trailing-whitespace guarantee
|
|
260
|
+
achievable: a single-string formulation forces the header onto
|
|
261
|
+
the same return as the first content line, and the seam between
|
|
262
|
+
them needs whitespace one way or the other.
|
|
263
|
+
"""
|
|
264
|
+
if not items:
|
|
265
|
+
return [f"**{header}:** None."]
|
|
266
|
+
return [f"**{header}:**", "", *(f"- {item}" for item in items)]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _reviewer_file_links(run_result: ReviewerRunResult) -> str:
|
|
270
|
+
"""Build the ``[.ext](file)`` link list for one reviewer, including
|
|
271
|
+
only the per-reviewer files :func:`persist_reviewer_result` actually
|
|
272
|
+
writes for that outcome.
|
|
273
|
+
|
|
274
|
+
``.stdout`` / ``.stderr`` are always written. A success additionally
|
|
275
|
+
writes ``.parsed.json``; a failure additionally writes
|
|
276
|
+
``.error.txt``. Deriving the list from the outcome — rather than
|
|
277
|
+
probing the filesystem — keeps this independent of call ordering and
|
|
278
|
+
free of dangling links.
|
|
279
|
+
"""
|
|
280
|
+
name = run_result.reviewer_name
|
|
281
|
+
suffixes = [".stdout", ".stderr"]
|
|
282
|
+
if run_result.output is not None:
|
|
283
|
+
suffixes.insert(0, ".parsed.json")
|
|
284
|
+
if run_result.error is not None:
|
|
285
|
+
suffixes.append(".error.txt")
|
|
286
|
+
return " | ".join(f"[{suffix}]({name}{suffix})" for suffix in suffixes)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Reviewer-name validation shared across persistence write paths.
|
|
2
|
+
|
|
3
|
+
Reviewer names are validated against a basename rule in
|
|
4
|
+
``WorktreeManager._validate_reviewer_name``; that rule guarantees
|
|
5
|
+
``<round_dir> / <reviewer_name>`` stays under ``round_dir``. Persistence
|
|
6
|
+
applies the same rule defensively in case a caller bypasses the
|
|
7
|
+
worktree manager.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_FORBIDDEN_NAME_CHARS = ("/", "\\")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_reviewer_filename_basename(name: str) -> None:
|
|
18
|
+
"""Refuse a name that would let the persistence path escape
|
|
19
|
+
``round_dir``.
|
|
20
|
+
|
|
21
|
+
The worktree manager already enforces this for the per-reviewer
|
|
22
|
+
directory under ``/tmp/syncade/``; we re-validate here so a caller
|
|
23
|
+
that constructs a :class:`ReviewerRunResult` directly (no worktree
|
|
24
|
+
involved) can't trick persistence into writing outside the run
|
|
25
|
+
directory.
|
|
26
|
+
"""
|
|
27
|
+
if (
|
|
28
|
+
not name
|
|
29
|
+
or name in (".", "..")
|
|
30
|
+
or any(c in name for c in _FORBIDDEN_NAME_CHARS)
|
|
31
|
+
or Path(name).is_absolute()
|
|
32
|
+
):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"reviewer name {name!r} cannot be used as a persistence "
|
|
35
|
+
f"basename (must be a plain basename, no separators, parent "
|
|
36
|
+
f"refs, or absolute paths)"
|
|
37
|
+
)
|