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/billing.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The ONE place that decides whether a dollar is money.
|
|
2
|
+
|
|
3
|
+
**This module exists because I got the same thing wrong eight times.**
|
|
4
|
+
|
|
5
|
+
``cost_usd`` is an API-EQUIVALENT VALUATION, not spend. It is fiction whenever the call
|
|
6
|
+
rode a subscription, and that is orthogonal to ``cost_source`` (``claude`` reports
|
|
7
|
+
``total_cost_usd`` even on an OAuth session). Only ``auth_mode`` knows.
|
|
8
|
+
|
|
9
|
+
Syncade renders that judgement on TWO surfaces — ``--metrics`` and each round's
|
|
10
|
+
``summary.md`` — and every single time I fixed one, I left the other telling the original
|
|
11
|
+
lie. Not once: repeatedly, over four review rounds, in the same PR:
|
|
12
|
+
|
|
13
|
+
round 5 --metrics learned billed-vs-valuation. summary.md still said "Total: $0.14".
|
|
14
|
+
round 7 --metrics learned unpriced API = lower bound. summary.md did not.
|
|
15
|
+
round 8 --metrics learned unpriced+unknown != free. summary.md did not.
|
|
16
|
+
|
|
17
|
+
Each fix was correct. Each was applied to one of two twins. The bug was never the logic —
|
|
18
|
+
it was that the logic lived in two places, so "fixed" only ever meant "fixed here".
|
|
19
|
+
|
|
20
|
+
So the rules and the WORDS both live here now, and both surfaces call this. They cannot
|
|
21
|
+
disagree, because there is no longer anywhere for them to disagree from.
|
|
22
|
+
|
|
23
|
+
The rules, once:
|
|
24
|
+
|
|
25
|
+
- **billed** — ``auth_mode == "api"``. Money that actually left the account.
|
|
26
|
+
- **api_equiv** — every priced token, however it was paid for. What the traffic would
|
|
27
|
+
cost at API list price; on a subscription that is what the plan is
|
|
28
|
+
worth, not what was spent.
|
|
29
|
+
- **unclassified** — auth mode unrecorded. NEVER folded into either: we do not know, and
|
|
30
|
+
guessing is what produced the original $71.18-of-nothing.
|
|
31
|
+
- **unpriced tokens** are tracked PER CLASS, because ``SUM()`` coalesces a NULL cost to 0
|
|
32
|
+
and a zero here is a claim, not a fact. Unpriced API tokens make ``billed`` a LOWER
|
|
33
|
+
BOUND. Unpriced unknown tokens must not read as free.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
from typing import NamedTuple, Protocol
|
|
40
|
+
|
|
41
|
+
_KNOWN = ("api", "subscription")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _HasUsage(Protocol):
|
|
45
|
+
auth_mode: str
|
|
46
|
+
cost_usd: float | None
|
|
47
|
+
total_tokens: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Billing(NamedTuple):
|
|
51
|
+
"""The numbers that must never be conflated, plus the two that qualify them."""
|
|
52
|
+
|
|
53
|
+
billed: float = 0.0
|
|
54
|
+
api_equiv: float = 0.0
|
|
55
|
+
unclassified: float = 0.0
|
|
56
|
+
billed_unpriced_tokens: int = 0
|
|
57
|
+
unclassified_unpriced_tokens: int = 0
|
|
58
|
+
# ALL unpriced tokens, every auth class. billed_unpriced (api) and
|
|
59
|
+
# unclassified_unpriced (unknown) are subsets; the remainder is SUBSCRIPTION-mode
|
|
60
|
+
# unpriced, which was tracked by neither and so vanished from the report entirely.
|
|
61
|
+
total_unpriced_tokens: int = 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def from_rows(rows: Iterable[tuple[str, float, int]]) -> Billing:
|
|
65
|
+
"""``(auth_mode, summed_cost, summed_unpriced_tokens)`` → :class:`Billing`.
|
|
66
|
+
|
|
67
|
+
The shape ``--metrics`` gets from SQL (``GROUP BY auth_mode``, never from display
|
|
68
|
+
aggregates — grouping for display once merged two modes into "mixed" and made real API
|
|
69
|
+
spend vanish).
|
|
70
|
+
"""
|
|
71
|
+
rows = list(rows)
|
|
72
|
+
return Billing(
|
|
73
|
+
billed=sum(c for mode, c, _t in rows if mode == "api"),
|
|
74
|
+
api_equiv=sum(c for _mode, c, _t in rows),
|
|
75
|
+
unclassified=sum(c for mode, c, _t in rows if mode not in _KNOWN),
|
|
76
|
+
billed_unpriced_tokens=sum(t for mode, _c, t in rows if mode == "api"),
|
|
77
|
+
unclassified_unpriced_tokens=sum(t for mode, _c, t in rows if mode not in _KNOWN),
|
|
78
|
+
total_unpriced_tokens=sum(t for _mode, _c, t in rows),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def from_usages(usages: Iterable[_HasUsage]) -> Billing:
|
|
83
|
+
"""Live :class:`~syncade.usage.Usage` records → :class:`Billing`.
|
|
84
|
+
|
|
85
|
+
The shape ``summary.md`` gets. Same rules as :func:`from_rows` — because they ARE the
|
|
86
|
+
same rules, and keeping them in one function is the entire point of this module.
|
|
87
|
+
"""
|
|
88
|
+
# Read Usage.cost_incomplete_tokens -- the SAME rule the DB column is written from --
|
|
89
|
+
# rather than re-deriving "priced" here. A non-null cost_usd is NOT sufficient: a retry
|
|
90
|
+
# (_add_usage) can carry a partial cost with cost_source="unknown", and treating that as
|
|
91
|
+
# fully priced dropped the lower-bound hedge on summary.md.
|
|
92
|
+
rows = [(u.auth_mode, u.cost_usd or 0.0, u.cost_incomplete_tokens) for u in usages]
|
|
93
|
+
return from_rows(rows)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render(b: Billing, *, indent: str = " ", bullet: bool = False) -> list[str]:
|
|
97
|
+
"""The billing block, worded once.
|
|
98
|
+
|
|
99
|
+
Both surfaces render from here. When the wording of "what is money" changes, it changes
|
|
100
|
+
in one place — which is what stops ``summary.md`` from quietly saying "Total: $0.14"
|
|
101
|
+
for a run that cost nothing while ``--metrics`` says $0.00 billed.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# `bullet` = markdown context (summary.md). Plain = terminal (--metrics). Only the
|
|
105
|
+
# decoration differs; the NUMBERS and the CLAIMS are identical by construction.
|
|
106
|
+
def label(text: str) -> str:
|
|
107
|
+
return f"{indent}- **{text}:**" if bullet else f"{indent}{text + ':':<11}"
|
|
108
|
+
|
|
109
|
+
lines: list[str] = []
|
|
110
|
+
|
|
111
|
+
billed_note = "(money that left your account)"
|
|
112
|
+
if b.billed_unpriced_tokens:
|
|
113
|
+
# A lower bound is never presented as a total.
|
|
114
|
+
billed_note = (
|
|
115
|
+
f"(AT LEAST — {b.billed_unpriced_tokens} API tokens are unpriced, "
|
|
116
|
+
f"so real spend is HIGHER)"
|
|
117
|
+
)
|
|
118
|
+
lines.append(f"{label('billed')} ${b.billed:.4f} {billed_note}")
|
|
119
|
+
|
|
120
|
+
# Unpriced tokens that are neither api (surfaced on `billed`) nor unknown (surfaced on
|
|
121
|
+
# `unclassed`) -- i.e. SUBSCRIPTION-mode unpriced. billed is genuinely $0 for these
|
|
122
|
+
# (subscription is $0 marginal), but their API-EQUIVALENT valuation is unknown, so
|
|
123
|
+
# API-equiv is a lower bound. This was the dropped case: billed $0, no signal at all.
|
|
124
|
+
sub_unpriced = (
|
|
125
|
+
b.total_unpriced_tokens - b.billed_unpriced_tokens - b.unclassified_unpriced_tokens
|
|
126
|
+
)
|
|
127
|
+
if b.api_equiv > b.billed or sub_unpriced:
|
|
128
|
+
api_note = "(same traffic at API list price; subscription traffic is $0 marginal"
|
|
129
|
+
if sub_unpriced:
|
|
130
|
+
api_note += f"; {sub_unpriced} tokens unpriced, so this is a LOWER BOUND"
|
|
131
|
+
lines.append(f"{label('API-equiv')} ${b.api_equiv:.4f} {api_note})")
|
|
132
|
+
|
|
133
|
+
if b.unclassified or b.unclassified_unpriced_tokens:
|
|
134
|
+
detail = f"${b.unclassified:.4f}"
|
|
135
|
+
if b.unclassified_unpriced_tokens:
|
|
136
|
+
detail += f" + {b.unclassified_unpriced_tokens} unpriced tokens"
|
|
137
|
+
lines.append(
|
|
138
|
+
f"{label('unclassed')} {detail} "
|
|
139
|
+
f"(auth mode not recorded — cannot say whether this was billed)"
|
|
140
|
+
)
|
|
141
|
+
return lines
|
syncade/checks_config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""User-defined mechanical-check config.
|
|
2
|
+
|
|
3
|
+
:class:`CheckConfig` models one ``[[checks]]`` block — a mechanical gate the
|
|
4
|
+
orchestrator runs itself (a shell command + exit code), tagged ``blocking`` or
|
|
5
|
+
``advisory``. It lives in its OWN module because ``config.py`` is already over
|
|
6
|
+
the project's 400–500 LOC discipline; :class:`SyncadeConfig` imports this model
|
|
7
|
+
plus :func:`validate_check_names` and adds only the ``checks`` field and a thin
|
|
8
|
+
delegating validator. Empty ``checks`` list = today's loop, byte-identical.
|
|
9
|
+
|
|
10
|
+
Two-lane wall: a check is a command-with-an-exit-code ONLY. Anything needing
|
|
11
|
+
LLM judgment stays in ``reviewer.md`` — never a mechanical check.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
20
|
+
|
|
21
|
+
from syncade.worktree import TEST_WORKTREE_NAME
|
|
22
|
+
|
|
23
|
+
CheckSeverity = Literal["blocking", "advisory"]
|
|
24
|
+
"""``blocking`` → failure ORs into NO-SHIP exactly like a failing test leg (and
|
|
25
|
+
the producer, which runs on NO-SHIP, can fix it). ``advisory`` → failure is
|
|
26
|
+
surfaced but NEVER gates the verdict. Default is ``advisory`` — fail-safe, so a
|
|
27
|
+
forgotten tag cannot silently block a ship."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CheckConfig(BaseModel):
|
|
31
|
+
"""One mechanical check: a named shell command + a severity."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
name: str = Field(
|
|
36
|
+
min_length=1,
|
|
37
|
+
description="Stable identifier used in run artifacts and as the "
|
|
38
|
+
"check's worktree basename. Must be unique and must not collide with a "
|
|
39
|
+
"reviewer name or the reserved 'tests' worktree basename.",
|
|
40
|
+
)
|
|
41
|
+
command: str = Field(
|
|
42
|
+
min_length=1,
|
|
43
|
+
description="Shell command run verbatim via ``sh -c`` in a fresh "
|
|
44
|
+
"stripped worktree — the same mechanism as ``[loop] test_command``. "
|
|
45
|
+
"Non-zero exit = the check failed.",
|
|
46
|
+
)
|
|
47
|
+
severity: CheckSeverity = Field(
|
|
48
|
+
default="advisory",
|
|
49
|
+
description="``blocking`` folds a failure into the mechanical verdict "
|
|
50
|
+
"(NO-SHIP, like a failing test); ``advisory`` surfaces it without "
|
|
51
|
+
"gating. Defaults to ``advisory`` so a forgotten tag never gates.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@field_validator("name", "command")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _not_whitespace(cls, value: str) -> str:
|
|
57
|
+
"""Reject ``" "`` / ``"\\n\\t"`` — ``min_length`` passes them through
|
|
58
|
+
(length is non-zero), but a whitespace command would silently SIGKILL
|
|
59
|
+
the check leg and a whitespace name would make an unusable worktree
|
|
60
|
+
basename. Mirrors ``LoopConfig._test_command_not_whitespace``."""
|
|
61
|
+
if not value.strip():
|
|
62
|
+
raise ValueError("must not be empty or whitespace-only (got a blank string)")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
@field_validator("name")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _name_plain_basename(cls, value: str) -> str:
|
|
68
|
+
"""The name is used as a worktree basename AND an artifact filename, so
|
|
69
|
+
reject anything that isn't a plain basename — a check name must never be
|
|
70
|
+
able to escape the round directory. Mirrors
|
|
71
|
+
``WorktreeManager._validate_reviewer_name``."""
|
|
72
|
+
if value in (".", "..") or "/" in value or "\\" in value or Path(value).is_absolute():
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"check name {value!r} must be a plain basename "
|
|
75
|
+
f"(no '/', '\\', '.', '..', or absolute paths)"
|
|
76
|
+
)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_check_names(reviewer_names: list[str], check_names: list[str]) -> None:
|
|
81
|
+
"""Raise ``ValueError`` if any check name collides on the per-round worktree
|
|
82
|
+
path. Each configured check provisions a worktree at ``round-N/<name>/``, so
|
|
83
|
+
a name must be unique among checks and must not equal (case-insensitively)
|
|
84
|
+
any reviewer name or the reserved ``tests`` worktree basename.
|
|
85
|
+
|
|
86
|
+
UNCONDITIONAL (unlike ``SyncadeConfig``'s reviewer-vs-``tests`` validator,
|
|
87
|
+
which is gated on ``test_command``): checks always provision worktrees when
|
|
88
|
+
configured, so reserving ``tests`` here also forecloses the latent collision
|
|
89
|
+
the moment ``test_command`` is set. ``casefold()`` matches the existing
|
|
90
|
+
validator — case-insensitive filesystems resolve ``Tests``/``tests`` to one
|
|
91
|
+
path.
|
|
92
|
+
"""
|
|
93
|
+
if not check_names:
|
|
94
|
+
return
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
reviewer_fold = {n.casefold() for n in reviewer_names}
|
|
97
|
+
reserved = TEST_WORKTREE_NAME.casefold()
|
|
98
|
+
for name in check_names:
|
|
99
|
+
fold = name.casefold()
|
|
100
|
+
if fold in seen:
|
|
101
|
+
raise ValueError(f"duplicate check name {name!r} (check names must be unique)")
|
|
102
|
+
seen.add(fold)
|
|
103
|
+
if fold in reviewer_fold:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"check name {name!r} collides with a reviewer name "
|
|
106
|
+
f"(both provision a worktree at round-N/{name}/); rename the check"
|
|
107
|
+
)
|
|
108
|
+
if fold == reserved:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"check name {name!r} collides with the reserved "
|
|
111
|
+
f"{TEST_WORKTREE_NAME!r} test-re-run worktree basename "
|
|
112
|
+
f"(case-insensitive); rename the check"
|
|
113
|
+
)
|