syncade 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )