countersign-cli 0.2.0__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.
@@ -0,0 +1,182 @@
1
+ # audited on 20260905
2
+ """Claims proposed from an agent's own completion report.
3
+
4
+ An agent ends its work with prose: "All tests pass. Created src/pricing.ts.
5
+ The endpoint at http://localhost:3000/api returns the price." Every one of
6
+ those is a claim, and most of them are checkable with one command. This
7
+ module turns the checkable sentences into proposed claims, deterministically,
8
+ with the agent's own sentence kept as the statement so the receipt later
9
+ says exactly which promise held and which did not.
10
+
11
+ Only English patterns, and only sentences whose disproof command can be
12
+ derived from the repository itself (its test runner, its build script, a
13
+ file path, a URL). A sentence that is checkable in principle but has no
14
+ derivable command is reported as unresolved with the reason, never guessed.
15
+ No model reads the report.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from .claims import Claim
26
+ from .starter import TESTS_PASS, StarterClaim, _package_manager, detect_starter_claims
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Proposal:
31
+ claim: Claim
32
+ sentence: str
33
+ source: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Unresolved:
38
+ sentence: str
39
+ reason: str
40
+
41
+
42
+ @dataclass
43
+ class Proposals:
44
+ claims: list[Proposal] = field(default_factory=list)
45
+ unresolved: list[Unresolved] = field(default_factory=list)
46
+
47
+
48
+ _BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+")
49
+ _URL = re.compile(r"https?://[^\s'\"`<>()]+")
50
+ _PATH = re.compile(r"(?<![\w/])(?:[\w@.-]+/)*[\w@-][\w@.-]*\.[A-Za-z][A-Za-z0-9]{0,9}(?![\w/])")
51
+
52
+ _TESTS = re.compile(r"\b(?:tests?|test suite|specs?)\b.{0,40}?\b(?:pass|passes|passing|passed|green|succeed|succeeds)\b|\b(?:pass|passes|passing|passed)\b.{0,20}?\btests?\b", re.IGNORECASE)
53
+ _BUILD = re.compile(r"\bbuild(?:s|ing)?\b.{0,30}?\b(?:succeed|succeeds|succeeded|successful|successfully|passes|clean|cleanly|without errors|no errors)\b|\bbuilt successfully\b", re.IGNORECASE)
54
+ _LINT = re.compile(r"\blint(?:er|ing)?\b.{0,30}?\b(?:pass|passes|passing|passed|clean|no (?:errors|warnings))\b", re.IGNORECASE)
55
+ _TYPES = re.compile(r"\b(?:type ?checks?|type ?checking|typechecks?|tsc|types)\b.{0,30}?\b(?:pass|passes|passing|passed|clean|no (?:type )?errors)\b", re.IGNORECASE)
56
+ _CREATED = re.compile(r"\b(?:created|added|wrote|generated|introduced|updated|modified|edited|implemented|saved)\b", re.IGNORECASE)
57
+ _REMOVED = re.compile(r"\b(?:removed|deleted|dropped)\b", re.IGNORECASE)
58
+
59
+
60
+ def _sentences(text: str) -> list[str]:
61
+ out: list[str] = []
62
+ for line in text.splitlines():
63
+ line = _BULLET.sub("", line).strip()
64
+ if not line:
65
+ continue
66
+ for piece in re.split(r"(?<=[.!?])\s+(?=[A-Z])", line):
67
+ piece = piece.strip()
68
+ if piece:
69
+ out.append(piece)
70
+ return out
71
+
72
+
73
+ def _slug(text: str) -> str:
74
+ return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", text.lower())).strip("-")
75
+
76
+
77
+ def _quote(value: str) -> str:
78
+ return "'" + value.replace("'", "'\\''") + "'"
79
+
80
+
81
+ def _statement(sentence: str) -> str:
82
+ flat = " ".join(sentence.split())
83
+ return "Agent report: " + (flat if len(flat) <= 160 else flat[:157] + "...")
84
+
85
+
86
+ def _build_command(root: Path) -> str | None:
87
+ package_json = root / "package.json"
88
+ if package_json.is_file():
89
+ try:
90
+ data = json.loads(package_json.read_text(encoding="utf-8-sig"))
91
+ except (OSError, ValueError):
92
+ data = {}
93
+ scripts = data.get("scripts") if isinstance(data, dict) else None
94
+ if isinstance(scripts, dict) and isinstance(scripts.get("build"), str) and scripts["build"].strip():
95
+ return f"{_package_manager(root)} run build"
96
+ if (root / "Cargo.toml").is_file():
97
+ return "cargo build"
98
+ if (root / "go.mod").is_file():
99
+ return "go build ./..."
100
+ return None
101
+
102
+
103
+ def claims_from_report(text: str, root: Path) -> Proposals:
104
+ """Checkable sentences of ``text`` as proposed claims for the repository at ``root``."""
105
+ root = Path(root)
106
+ starters: dict[str, StarterClaim] = {c.claim_id: c for c in detect_starter_claims(root)}
107
+ proposals = Proposals()
108
+ seen: set[str] = set()
109
+
110
+ def propose(claim_id: str, sentence: str, command: str, source: str, expect: str = "exit 0") -> None:
111
+ if claim_id in seen:
112
+ return
113
+ seen.add(claim_id)
114
+ proposals.claims.append(Proposal(Claim(claim_id, _statement(sentence), command, expect), sentence, source))
115
+
116
+ def from_starter(claim_id: str, sentence: str, missing_reason: str) -> None:
117
+ starter = starters.get(claim_id)
118
+ if starter is None:
119
+ if claim_id not in seen:
120
+ proposals.unresolved.append(Unresolved(sentence, missing_reason))
121
+ return
122
+ propose(claim_id, sentence, starter.command, starter.source)
123
+
124
+ for sentence in _sentences(text):
125
+ urls = _URL.findall(sentence)
126
+ for url in urls:
127
+ url = url.rstrip(".,;:")
128
+ bare = re.sub(r"^https?://", "", url)
129
+ propose(f"url-{_slug(bare)}", sentence, f"curl -sf -o /dev/null {_quote(url)}", "URL in the report")
130
+ without_urls = _URL.sub(" ", sentence)
131
+
132
+ if _TESTS.search(without_urls):
133
+ from_starter(TESTS_PASS, sentence, "no test runner recognised in this repository (no package.json test script, pytest, go.mod, Cargo.toml or Gemfile with spec/)")
134
+ if _BUILD.search(without_urls):
135
+ command = _build_command(root)
136
+ if command:
137
+ propose("build-succeeds", sentence, command, "build command of the repository")
138
+ elif "build-succeeds" not in seen:
139
+ proposals.unresolved.append(Unresolved(sentence, "no build command recognised (no package.json build script, Cargo.toml or go.mod)"))
140
+ if _LINT.search(without_urls):
141
+ from_starter("lint-clean", sentence, "no linter configuration recognised (no package.json lint script, ruff configuration)")
142
+ if _TYPES.search(without_urls):
143
+ from_starter("types-check", sentence, "no type checker recognised (no typecheck script, tsconfig with typescript, or mypy configuration)")
144
+
145
+ removed = _REMOVED.search(without_urls)
146
+ created = _CREATED.search(without_urls)
147
+ if removed or created:
148
+ verb = removed if removed and (not created or removed.start() < created.start()) else created
149
+ rest = without_urls[verb.end():]
150
+ path_match = _PATH.search(rest)
151
+ if path_match:
152
+ path = path_match.group(0).rstrip(".")
153
+ if verb is removed:
154
+ propose(f"file-gone-{_slug(path)}", sentence, f"test ! -e {_quote(path)}", "file named in the report")
155
+ else:
156
+ propose(f"file-{_slug(path)}", sentence, f"test -f {_quote(path)}", "file named in the report")
157
+
158
+ return proposals
159
+
160
+
161
+ def without_ids(proposals: Proposals, ids: set[str]) -> Proposals:
162
+ """The proposals whose claim id is not in ``ids`` (those already declared)."""
163
+ return Proposals(claims=[p for p in proposals.claims if p.claim.claim_id not in ids], unresolved=list(proposals.unresolved))
164
+
165
+
166
+ def render_proposals_toml(proposals: Proposals) -> str:
167
+ lines: list[str] = []
168
+ for proposal in proposals.claims:
169
+ lines += [
170
+ f"# from the agent's report, {proposal.source}",
171
+ "[[claim]]",
172
+ f'id = "{proposal.claim.claim_id}"',
173
+ f"statement = {_toml(proposal.claim.statement)}",
174
+ f"command = {_toml(proposal.claim.command)}",
175
+ f'expect = "{proposal.claim.expect}"',
176
+ "",
177
+ ]
178
+ return "\n".join(lines)
179
+
180
+
181
+ def _toml(value: str) -> str:
182
+ return json.dumps(value)
@@ -0,0 +1,167 @@
1
+ # audited on 20260903
2
+ """Prove that a recorded run still reproduces.
3
+
4
+ The promise this module keeps: any verification run can be re-run later,
5
+ from the same inputs, and produce the same findings. It takes a receipt,
6
+ confirms the config and claims files are byte for byte the ones the run
7
+ read, re-runs the marker scan and every claim, and compares what comes out
8
+ against what the receipt recorded.
9
+
10
+ Commands that touch the outside world may legitimately diverge (a test that
11
+ pings a live service can pass today and fail in a year); each divergence is
12
+ reported, not excused. The marker scan has no such excuse: it is pure
13
+ arithmetic over files, and any difference means the files changed.
14
+
15
+ A receipt that cannot be read is a verdict (not reproduced), never a crash:
16
+ the file may have been hand-edited, and that is exactly the case this
17
+ command exists to catch.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from pathlib import Path
24
+
25
+ from .claims import ClaimsError, load_claims, run_claim
26
+ from .config import Config, ConfigError, file_sha256
27
+ from .receipt import find_receipt, load_receipt
28
+ from .register import Register
29
+ from .stubscan import scan_tree
30
+
31
+
32
+ def _finding_key(finding: dict) -> str:
33
+ return json.dumps(
34
+ {k: finding.get(k) for k in ("path", "line", "rule_id", "evidence")},
35
+ sort_keys=True,
36
+ )
37
+
38
+
39
+ def _load_receipt_or_explain(receipt_path: Path) -> tuple[dict | None, str | None]:
40
+ try:
41
+ receipt = load_receipt(receipt_path)
42
+ except (OSError, ValueError) as exc: # json.JSONDecodeError is a ValueError
43
+ return None, f"receipt {receipt_path.name} cannot be read: {exc}"
44
+ if not isinstance(receipt, dict):
45
+ return None, f"receipt {receipt_path.name} cannot be read: not a receipt object"
46
+ for key in ("verdict", "recorded_at", "config", "findings", "scan"):
47
+ if key not in receipt:
48
+ return None, f"receipt {receipt_path.name} cannot be read: missing '{key}'"
49
+ if not isinstance(receipt["config"], dict) or "sha256" not in receipt["config"]:
50
+ return None, f"receipt {receipt_path.name} cannot be read: config fingerprint missing"
51
+ if not isinstance(receipt["findings"], list):
52
+ return None, f"receipt {receipt_path.name} cannot be read: findings are not a list"
53
+ return receipt, None
54
+
55
+
56
+ def reproduce_run(config: Config, run_id: str) -> tuple[bool, list[str]]:
57
+ """Re-derive a recorded run. Returns (reproduced, human readable notes)."""
58
+ notes: list[str] = []
59
+
60
+ receipt_path = find_receipt(config.receipts_root(), run_id)
61
+ if receipt_path is None:
62
+ return False, [f"no receipt for run {run_id} under {config.receipts_root()}"]
63
+
64
+ receipt, problem = _load_receipt_or_explain(receipt_path)
65
+ if receipt is None:
66
+ return False, [problem or "receipt cannot be read"]
67
+ notes.append(f"receipt: {receipt_path.name}, verdict {receipt['verdict']}, recorded {receipt['recorded_at']}")
68
+
69
+ register = Register(config.register_path())
70
+ intact, chain_note = register.verify_chain()
71
+ if not intact:
72
+ return False, notes + [f"register: {chain_note}"]
73
+ notes.append(f"register: {chain_note}")
74
+
75
+ recorded_config_sha = str(receipt["config"]["sha256"])
76
+ actual_config_sha = file_sha256(config.config_path)
77
+ if recorded_config_sha != actual_config_sha:
78
+ notes.append(
79
+ f"config: CHANGED since the run ({recorded_config_sha[:12]}... then, {actual_config_sha[:12]}... now); "
80
+ "findings may differ for that reason"
81
+ )
82
+ else:
83
+ notes.append(f"config: sha256 matches ({actual_config_sha[:12]}...)")
84
+
85
+ recorded_claims_sha = (receipt.get("claims_file") or {}).get("sha256")
86
+ if recorded_claims_sha:
87
+ claims_path = config.claims_path()
88
+ if claims_path is None:
89
+ notes.append("claims: the file the run read is no longer present")
90
+ else:
91
+ actual_claims_sha = file_sha256(claims_path)
92
+ if actual_claims_sha != recorded_claims_sha:
93
+ notes.append(
94
+ f"claims: CHANGED since the run ({recorded_claims_sha[:12]}... then, {actual_claims_sha[:12]}... now)"
95
+ )
96
+ else:
97
+ notes.append(f"claims: sha256 matches ({actual_claims_sha[:12]}...)")
98
+
99
+ try:
100
+ rerun_findings, _exemptions, _inert, files_scanned = scan_tree(config)
101
+ except ConfigError as exc:
102
+ return False, notes + [f"marker scan: cannot re-run, {exc}", "overall: NOT REPRODUCED"]
103
+ recorded_findings = receipt["findings"]
104
+ rerun_keys = sorted(_finding_key(f.__dict__) for f in rerun_findings)
105
+ recorded_keys = sorted(_finding_key(f) for f in recorded_findings if isinstance(f, dict))
106
+
107
+ recorded_files = receipt["scan"].get("files_scanned") if isinstance(receipt["scan"], dict) else None
108
+ if isinstance(recorded_files, int) and recorded_files != files_scanned:
109
+ notes.append(f"tree: {recorded_files} file(s) then, {files_scanned} now; files were added or removed since the run")
110
+
111
+ scan_reproduced = rerun_keys == recorded_keys
112
+ if scan_reproduced:
113
+ notes.append(f"marker scan: {len(rerun_keys)} findings re-derived, identical ({files_scanned} files)")
114
+ else:
115
+ only_recorded = [k for k in recorded_keys if k not in rerun_keys][:3]
116
+ only_now = [k for k in rerun_keys if k not in recorded_keys][:3]
117
+ notes.append(
118
+ f"marker scan: DOES NOT reproduce, {len(recorded_keys)} recorded vs {len(rerun_keys)} re-derived"
119
+ )
120
+ for item in only_recorded:
121
+ notes.append(f" recorded but not re-derived: {json.loads(item).get('path')}:{json.loads(item).get('line')}")
122
+ for item in only_now:
123
+ notes.append(f" re-derived but not recorded: {json.loads(item).get('path')}:{json.loads(item).get('line')}")
124
+
125
+ claims_reproduced = True
126
+ recorded_claims = receipt.get("claims")
127
+ if recorded_claims is None:
128
+ notes.append("claims: the run skipped the claims check; nothing to re-run")
129
+ elif not isinstance(recorded_claims, list):
130
+ claims_reproduced = False
131
+ notes.append("claims: the receipt's claims section cannot be read")
132
+ elif config.claims_path() is None:
133
+ claims_reproduced = False
134
+ notes.append("claims: the claims file has since been removed; cannot re-run")
135
+ else:
136
+ try:
137
+ claims = load_claims(config.claims_path()) or []
138
+ except ClaimsError as exc:
139
+ claims = []
140
+ claims_reproduced = False
141
+ notes.append(f"claims: the claims file can no longer be honoured as written: {exc}")
142
+ for recorded in recorded_claims:
143
+ if not isinstance(recorded, dict) or "claim_id" not in recorded or "status" not in recorded:
144
+ claims_reproduced = False
145
+ notes.append("claim: a recorded claim entry cannot be read")
146
+ continue
147
+ match = next((c for c in claims if c.claim_id == recorded["claim_id"]), None)
148
+ if match is None:
149
+ claims_reproduced = False
150
+ notes.append(f"claim {recorded['claim_id']}: no longer declared in the claims file")
151
+ continue
152
+ rerun_result = run_claim(match, config.root, config.timeout_s, config.max_output_bytes)
153
+ if rerun_result.status == recorded["status"]:
154
+ notes.append(f"claim {recorded['claim_id']}: {str(recorded['status']).upper()}, reproduced ({rerun_result.duration_ms} ms)")
155
+ else:
156
+ claims_reproduced = False
157
+ notes.append(
158
+ f"claim {recorded['claim_id']}: was {str(recorded['status']).upper()}, re-ran {rerun_result.status.upper()}"
159
+ )
160
+
161
+ # The verdict is a pure function of the findings and the claim statuses,
162
+ # so if both of those reproduced, the verdict reproduced with them. The
163
+ # gate itself is never re-run here: appending evidence while checking
164
+ # evidence would put reproduce output into the register.
165
+ reproduced = scan_reproduced and claims_reproduced
166
+ notes.append(f"overall: {'REPRODUCED' if reproduced else 'NOT REPRODUCED'}")
167
+ return reproduced, notes
countersign/starter.py ADDED
@@ -0,0 +1,242 @@
1
+ # audited on 20260903
2
+ """Starter claims for ``countersign init``: what this repository can already
3
+ prove about itself, read from the build files that are actually there.
4
+
5
+ Nothing here guesses. A claim is proposed only when the file that makes its
6
+ command meaningful exists (a ``test`` script in package.json, a pytest
7
+ configuration, a go.mod). The proposed commands are the stack's own
8
+ conventional ones; the team edits them like any other claim.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ TESTS_PASS = "tests-pass"
20
+
21
+ # Where customers' workflows point. Moves only with a release; the tag it
22
+ # names must exist on that repository before this constant changes.
23
+ ACTION_REF = "krishnaflipprr/countersign@v0.2"
24
+ WORKFLOW_RELATIVE_PATH = Path(".github") / "workflows" / "countersign.yml"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class GitHubRepository:
29
+ toplevel: Path
30
+ default_branch: str
31
+
32
+
33
+ def _git(root: Path, *args: str) -> str | None:
34
+ try:
35
+ completed = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True, timeout=15)
36
+ except (OSError, subprocess.TimeoutExpired):
37
+ return None
38
+ if completed.returncode != 0:
39
+ return None
40
+ return completed.stdout.strip()
41
+
42
+
43
+ def detect_github_repository(root: Path) -> GitHubRepository | None:
44
+ """The repository around ``root`` when its origin is on github.com.
45
+
46
+ Nothing is guessed: no git, no origin, or an origin elsewhere means None.
47
+ The default branch comes from origin's HEAD when the clone knows it,
48
+ else from the current branch, else ``main``.
49
+ """
50
+ toplevel = _git(root, "rev-parse", "--show-toplevel")
51
+ if not toplevel:
52
+ return None
53
+ origin = _git(root, "remote", "get-url", "origin")
54
+ if not origin or "github.com" not in origin:
55
+ return None
56
+ head = _git(root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
57
+ if head and head.startswith("origin/"):
58
+ branch = head[len("origin/"):]
59
+ else:
60
+ branch = _git(root, "branch", "--show-current") or "main"
61
+ return GitHubRepository(Path(toplevel).resolve(), branch or "main")
62
+
63
+
64
+ def render_workflow(config_path_in_repo: str, default_branch: str) -> str:
65
+ return f"""# Countersign: verifies every push to {default_branch} and every pull request.
66
+ # Written by `countersign init`. Safe to edit; the action's inputs are
67
+ # documented at https://github.com/{ACTION_REF.split('@')[0]}.
68
+ name: countersign
69
+ on:
70
+ push:
71
+ branches: [{json.dumps(default_branch)}]
72
+ pull_request:
73
+
74
+ permissions:
75
+ contents: read
76
+
77
+ jobs:
78
+ countersign:
79
+ name: countersign verify
80
+ runs-on: ubuntu-latest
81
+ steps:
82
+ - uses: actions/checkout@v7
83
+ - uses: {ACTION_REF}
84
+ with:
85
+ config: {json.dumps(config_path_in_repo)}
86
+ """
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class StarterClaim:
91
+ claim_id: str
92
+ statement: str
93
+ command: str
94
+ source: str
95
+
96
+
97
+ def _package_manager(root: Path) -> str:
98
+ if (root / "pnpm-lock.yaml").exists():
99
+ return "pnpm"
100
+ if (root / "yarn.lock").exists():
101
+ return "yarn"
102
+ if (root / "bun.lockb").exists() or (root / "bun.lock").exists():
103
+ return "bun"
104
+ return "npm"
105
+
106
+
107
+ def _node_claims(root: Path) -> list[StarterClaim]:
108
+ package_json = root / "package.json"
109
+ if not package_json.is_file():
110
+ return []
111
+ try:
112
+ data = json.loads(package_json.read_text(encoding="utf-8-sig"))
113
+ except (OSError, ValueError):
114
+ return []
115
+ scripts = data.get("scripts") if isinstance(data, dict) else None
116
+ if not isinstance(scripts, dict):
117
+ return []
118
+ manager = _package_manager(root)
119
+ run = f"{manager} run"
120
+ # `bun test` is bun's own runner, not the package's test script; every
121
+ # other manager treats `<manager> test` as the script.
122
+ test_command = "bun run test" if manager == "bun" else f"{manager} test"
123
+ claims: list[StarterClaim] = []
124
+ if isinstance(scripts.get("test"), str) and scripts["test"].strip():
125
+ claims.append(StarterClaim(TESTS_PASS, "The full test suite passes", test_command, "package.json scripts.test"))
126
+ if isinstance(scripts.get("lint"), str) and scripts["lint"].strip():
127
+ claims.append(StarterClaim("lint-clean", "The linter reports nothing", f"{run} lint", "package.json scripts.lint"))
128
+ for name in ("typecheck", "type-check", "tsc"):
129
+ if isinstance(scripts.get(name), str) and scripts[name].strip():
130
+ claims.append(StarterClaim("types-check", "The type checker reports nothing", f"{run} {name}", f"package.json scripts.{name}"))
131
+ break
132
+ else:
133
+ deps = {}
134
+ for key in ("devDependencies", "dependencies"):
135
+ if isinstance(data.get(key), dict):
136
+ deps.update(data[key])
137
+ if "typescript" in deps and (root / "tsconfig.json").is_file():
138
+ claims.append(StarterClaim("types-check", "The type checker reports nothing", "npx tsc --noEmit", "tsconfig.json with typescript installed"))
139
+ return claims
140
+
141
+
142
+ def _python_claims(root: Path) -> list[StarterClaim]:
143
+ pyproject = root / "pyproject.toml"
144
+ pyproject_text = ""
145
+ if pyproject.is_file():
146
+ try:
147
+ pyproject_text = pyproject.read_text(encoding="utf-8-sig")
148
+ except OSError:
149
+ pyproject_text = ""
150
+ has_python = bool(pyproject_text) or (root / "setup.py").is_file() or (root / "setup.cfg").is_file() or (root / "requirements.txt").is_file()
151
+ claims: list[StarterClaim] = []
152
+ pytest_configured = (
153
+ "[tool.pytest" in pyproject_text
154
+ or re.search(r"\bpytest\b", pyproject_text) is not None
155
+ or (root / "pytest.ini").is_file()
156
+ or (root / "conftest.py").is_file()
157
+ )
158
+ if pytest_configured:
159
+ claims.append(StarterClaim(TESTS_PASS, "The full test suite passes", "python3 -m pytest -q", "pytest configuration"))
160
+ elif has_python and ((root / "tests").is_dir() or (root / "test").is_dir()):
161
+ start = "tests" if (root / "tests").is_dir() else "test"
162
+ claims.append(StarterClaim(TESTS_PASS, "The full test suite passes", f"python3 -m unittest discover -s {start} -t .", f"{start}/ directory"))
163
+ if "[tool.ruff" in pyproject_text or (root / "ruff.toml").is_file() or (root / ".ruff.toml").is_file():
164
+ claims.append(StarterClaim("lint-clean", "The linter reports nothing", "ruff check .", "ruff configuration"))
165
+ if "[tool.mypy" in pyproject_text or (root / "mypy.ini").is_file():
166
+ claims.append(StarterClaim("types-check", "The type checker reports nothing", "mypy .", "mypy configuration"))
167
+ return claims
168
+
169
+
170
+ def _go_claims(root: Path) -> list[StarterClaim]:
171
+ if not (root / "go.mod").is_file():
172
+ return []
173
+ return [
174
+ StarterClaim(TESTS_PASS, "The full test suite passes", "go test ./...", "go.mod"),
175
+ StarterClaim("vet-clean", "go vet reports nothing", "go vet ./...", "go.mod"),
176
+ ]
177
+
178
+
179
+ def _rust_claims(root: Path) -> list[StarterClaim]:
180
+ if not (root / "Cargo.toml").is_file():
181
+ return []
182
+ return [StarterClaim(TESTS_PASS, "The full test suite passes", "cargo test", "Cargo.toml")]
183
+
184
+
185
+ def _ruby_claims(root: Path) -> list[StarterClaim]:
186
+ if (root / "Gemfile").is_file() and (root / "spec").is_dir():
187
+ return [StarterClaim(TESTS_PASS, "The full test suite passes", "bundle exec rspec", "Gemfile with spec/")]
188
+ return []
189
+
190
+
191
+ def detect_starter_claims(root: Path) -> list[StarterClaim]:
192
+ """Starter claims for ``root``, at most one per claim id, first stack wins."""
193
+ root = Path(root)
194
+ seen: set[str] = set()
195
+ claims: list[StarterClaim] = []
196
+ for detector in (_node_claims, _python_claims, _go_claims, _rust_claims, _ruby_claims):
197
+ for claim in detector(root):
198
+ if claim.claim_id in seen:
199
+ continue
200
+ seen.add(claim.claim_id)
201
+ claims.append(claim)
202
+ return claims
203
+
204
+
205
+ def _toml_string(value: str) -> str:
206
+ return json.dumps(value)
207
+
208
+
209
+ def render_claims_toml(claims: list[StarterClaim]) -> str:
210
+ lines = [
211
+ "# What is true about this repository, each claim paired with the command",
212
+ "# that fails if the claim is false. Countersign runs every command from",
213
+ "# the repository root through your shell and judges it exactly as declared.",
214
+ "#",
215
+ '# expect = "exit 0" the command must succeed (default)',
216
+ '# expect = "nonzero exit" the command must fail (negative tests)',
217
+ '# expect = "output contains" the needle must appear in the combined output',
218
+ "",
219
+ ]
220
+ if not claims:
221
+ lines += [
222
+ "# No build files were recognised, so no claim was written for you.",
223
+ "# Declare your first claim by editing the example below.",
224
+ "#",
225
+ "# [[claim]]",
226
+ '# id = "tests-pass"',
227
+ '# statement = "The full test suite passes"',
228
+ '# command = "make test"',
229
+ '# expect = "exit 0"',
230
+ "",
231
+ ]
232
+ for claim in claims:
233
+ lines += [
234
+ f"# proposed from {claim.source}",
235
+ "[[claim]]",
236
+ f"id = {_toml_string(claim.claim_id)}",
237
+ f"statement = {_toml_string(claim.statement)}",
238
+ f"command = {_toml_string(claim.command)}",
239
+ 'expect = "exit 0"',
240
+ "",
241
+ ]
242
+ return "\n".join(lines)