ratch 0.1.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.
ratch/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """ratch — a ratchet for repository invariants.
2
+
3
+ Decision: code is the single source of truth (SSOT); docs are pointers,
4
+ generated, or signed. Rejected: a docs/ tree. Because: a copied fact rots.
5
+ """
6
+
7
+ from ratch.result import (
8
+ EXIT,
9
+ PRECEDENCE,
10
+ Finding,
11
+ MeasuredValue,
12
+ MState,
13
+ Result,
14
+ State,
15
+ run_exit_code,
16
+ )
17
+
18
+ __all__ = [
19
+ "EXIT",
20
+ "PRECEDENCE",
21
+ "Finding",
22
+ "MeasuredValue",
23
+ "MState",
24
+ "Result",
25
+ "State",
26
+ "run_exit_code",
27
+ ]
ratch/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable 'python -m ratch'."""
2
+ import sys
3
+
4
+ from ratch.cli import main
5
+
6
+ sys.exit(main())
ratch/check.py ADDED
@@ -0,0 +1,46 @@
1
+ """The Check contract: the shape every rule instance must present.
2
+
3
+ A check is configured by instantiation — constructor arguments on the
4
+ instance, never a config file. The runtime-checkable Protocol lets the
5
+ runner accept any object that exposes this shape, keeping rules and the
6
+ engine decoupled.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
14
+
15
+ from ratch.result import Result
16
+
17
+ if TYPE_CHECKING:
18
+ # TYPE_CHECKING-only: resolves the Workspace forward reference for
19
+ # static type checkers; not imported at runtime (no ratch code calls
20
+ # get_type_hints on these annotations, and this avoids a runtime
21
+ # import).
22
+ from ratch.workspace import Workspace
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Plant:
27
+ label: str
28
+ planted_ws: object
29
+ expected: tuple[str, str, str]
30
+
31
+
32
+ @runtime_checkable
33
+ class Check(Protocol):
34
+ id: str
35
+ tier: str
36
+ kind: str
37
+ scope: str
38
+ proven_in: tuple[str, ...]
39
+ confidence: str
40
+ tolerates_unparseable: bool
41
+
42
+ def check(self, ws) -> Result: ...
43
+
44
+ def plants(self, ws) -> Iterable[Plant]: ...
45
+
46
+ def fixture(self, kit) -> Workspace: ...
@@ -0,0 +1,7 @@
1
+ """Shipped check plugins. Discovery is via entry points, not this module."""
2
+
3
+
4
+ def is_test_py(path):
5
+ return path.endswith(".py") and (
6
+ path.startswith("tests/") or "/tests/" in path
7
+ )
@@ -0,0 +1,125 @@
1
+ """No-AI-signatures check."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from subprocess import CompletedProcess
6
+
7
+ from ratch.check import Plant
8
+ from ratch.result import Finding, Result, State
9
+ from ratch.testing import FakeWorkspace
10
+
11
+
12
+ def _log_text(commits):
13
+ """Render commits exactly as ws.git_log(fmt=...) would: each commit is
14
+ ``sha\\0an\\0ae\\0cn\\0ce\\0body\\0`` and git joins records with ``\\n``."""
15
+ recs = ["\x00".join(c) + "\x00" for c in commits]
16
+ return "\n".join(recs) + "\n"
17
+
18
+
19
+ class NoAiSignatures:
20
+ """Forbid AI-attribution shapes anywhere in the full commit history.
21
+
22
+ Rule:
23
+ No commit message or committer/author identity, across the entire
24
+ reachable history, may match any configured attribution shape.
25
+
26
+ Why:
27
+ An AI attribution trailer or bot identity is a provenance leak that
28
+ survives squash and rebase; only a scan of the whole history proves
29
+ its absence, and a shallow clone that hides history must not read as
30
+ clean.
31
+
32
+ Proven in:
33
+ agent-runner/tests/invariants/test_no_ai_signatures.py
34
+
35
+ Not this:
36
+ Not a judgment on who or what wrote the code. Only fixed attribution
37
+ SHAPES are rejected; authorship, naming, and content are untouched.
38
+ """
39
+
40
+ id = "no-ai-signatures"
41
+ tier = "A"
42
+ kind = "gate"
43
+ scope = "global"
44
+ proven_in = ("agent-runner/tests/invariants/test_no_ai_signatures.py",)
45
+ confidence = "breadth"
46
+ tolerates_unparseable = False
47
+
48
+ def __init__(self, patterns=(
49
+ r"Co-Authored-By:",
50
+ r"Generated with",
51
+ r"Assisted by",
52
+ "\N{ROBOT FACE}",
53
+ r"[\w.+-]+@users\.noreply\.[\w.]+",
54
+ ), min_surface=1):
55
+ if min_surface < 1:
56
+ raise ValueError("min_surface must be >= 1")
57
+ self.patterns = tuple(patterns)
58
+ self.min_surface = min_surface
59
+
60
+ def _state(self, findings, examined_n):
61
+ if findings:
62
+ return State.FAIL
63
+ if examined_n < self.min_surface:
64
+ return State.VACUOUS
65
+ return State.PASS
66
+
67
+ def _is_shallow(self, ws):
68
+ proc = ws.run(["git", "rev-parse", "--is-shallow-repository"])
69
+ return proc.stdout.strip() == "true"
70
+
71
+ def check(self, ws):
72
+ findings = []
73
+ examined_n = 0
74
+ if not self._is_shallow(ws):
75
+ raw = ws.git_log(
76
+ rng=None,
77
+ fmt="%H%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x00",
78
+ )
79
+ fields = raw.split("\x00")
80
+ for i in range(0, len(fields) - 5, 6):
81
+ sha = fields[i].strip()
82
+ an, ae, cn, ce, body = fields[i + 1:i + 6]
83
+ examined_n += 1
84
+ haystack = "\n".join([an, ae, cn, ce, body])
85
+ for pattern in self.patterns:
86
+ # IGNORECASE so GitHub's canonical `Co-authored-by:` casing
87
+ # (and `generated with` / `assisted by`) is caught too
88
+ # (resolves Fable finding: default missed lowercase trailers).
89
+ if re.search(pattern, haystack, re.IGNORECASE):
90
+ anchor = f"{sha[:9]}:{pattern}"
91
+ findings.append(
92
+ Finding(self.id, "<commit>", anchor,
93
+ message=f"provenance leak {anchor}")
94
+ )
95
+ return Result(
96
+ self.id, self._state(findings, examined_n),
97
+ examined_n=examined_n, skipped_n=ws.skipped_n,
98
+ unparseable_n=0, findings=findings,
99
+ )
100
+
101
+ def plants(self, ws):
102
+ sha = "0f1e2d3c4b5a6978"
103
+ bot = "".join(["c", "l", "a", "u", "d", "e"]) # vendor token, never typed whole
104
+ body = f"feat: thing\n\nCo-Authored-By: {bot} <{bot}-bot@example.test>\n"
105
+ planted = FakeWorkspace(
106
+ files={"ok.py": "x = 1\n"},
107
+ run_table={("git", "rev-parse", "--is-shallow-repository"):
108
+ CompletedProcess([], 0, stdout="false\n")},
109
+ git_log_text=_log_text([(sha, "Dev", "dev@example.test",
110
+ "Dev", "dev@example.test", body)]),
111
+ )
112
+ yield Plant(
113
+ label="commit:co-authored-by",
114
+ planted_ws=planted, # type: ignore[arg-type]
115
+ expected=(self.id, "<commit>", f"{sha[:9]}:Co-Authored-By:"),
116
+ )
117
+
118
+ def fixture(self, kit):
119
+ return FakeWorkspace(
120
+ files={"clean.py": "x = 1\n"},
121
+ run_table={("git", "rev-parse", "--is-shallow-repository"):
122
+ CompletedProcess([], 0, stdout="false\n")},
123
+ git_log_text=_log_text([("feedface12345678", "Dev", "dev@example.test",
124
+ "Dev", "dev@example.test", "feat: initial\n")]),
125
+ )
@@ -0,0 +1,108 @@
1
+ """BDD test-naming check."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+
6
+ from ratch.check import Plant
7
+ from ratch.checks import is_test_py
8
+ from ratch.result import Finding, Result, State
9
+ from ratch.testing import FakeWorkspace
10
+
11
+
12
+ def _is_bdd_name(name):
13
+ if name.startswith("test_"):
14
+ return False
15
+ if "_should_" not in name or "_when_" not in name:
16
+ return False
17
+ return "or" not in name.split("_")
18
+
19
+
20
+ class BddTestConventions:
21
+ """Require BDD test names in tracked test modules.
22
+
23
+ Rule:
24
+ In tracked tests/*.py, every top-level def whose name does not
25
+ start with underscore must contain _should_ and _when_, must not
26
+ start with test_, and must not use or as a snake_case segment.
27
+
28
+ Why:
29
+ A test name that reads as a sentence is the readable spec; a
30
+ machine can prove the shape so pytest collection and review
31
+ share one convention.
32
+
33
+ Proven in:
34
+ internal/pillar-1-teeth.md
35
+
36
+ Not this:
37
+ Not a requirement on private helpers. Not a ban on tokens that
38
+ merely contain the letters o-r (error, format). Not a change to
39
+ pytest python_functions.
40
+ """
41
+
42
+ id = "bdd-test-conventions"
43
+ tier = "A"
44
+ kind = "gate"
45
+ scope = "global"
46
+ proven_in = ("internal/pillar-1-teeth.md",)
47
+ confidence = "inferred"
48
+ tolerates_unparseable = False
49
+
50
+ def __init__(self, min_surface=1):
51
+ if min_surface < 1:
52
+ raise ValueError("min_surface must be >= 1")
53
+ self.min_surface = min_surface
54
+
55
+ def _state(self, findings, examined_n):
56
+ if findings:
57
+ return State.FAIL
58
+ if examined_n < self.min_surface:
59
+ return State.VACUOUS
60
+ return State.PASS
61
+
62
+ def check(self, ws):
63
+ findings = []
64
+ examined_n = 0
65
+ for path in ws.tracked_files():
66
+ if not is_test_py(path):
67
+ continue
68
+ examined_n += 1
69
+ tree = ws.ast(path)
70
+ if tree is None:
71
+ continue
72
+ for node in tree.body:
73
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
74
+ continue
75
+ name = node.name
76
+ if name.startswith("_"):
77
+ continue
78
+ if not _is_bdd_name(name):
79
+ findings.append(
80
+ Finding(self.id, path, name,
81
+ message=f"bdd name: {name}")
82
+ )
83
+ return Result(
84
+ self.id, self._state(findings, examined_n),
85
+ examined_n=examined_n, skipped_n=ws.skipped_n,
86
+ unparseable_n=ws.unparseable_n, findings=findings,
87
+ )
88
+
89
+ def plants(self, ws):
90
+ yield Plant(
91
+ label="test-prefix",
92
+ planted_ws=FakeWorkspace(
93
+ files={"tests/t.py": "def test_foo():\n pass\n"}
94
+ ),
95
+ expected=(self.id, "tests/t.py", "test_foo"),
96
+ )
97
+ yield Plant(
98
+ label="or-segment",
99
+ planted_ws=FakeWorkspace(
100
+ files={"tests/t.py": "def foo_should_pass_or_fail_when_x():\n pass\n"}
101
+ ),
102
+ expected=(self.id, "tests/t.py", "foo_should_pass_or_fail_when_x"),
103
+ )
104
+
105
+ def fixture(self, kit):
106
+ return FakeWorkspace(
107
+ files={"tests/t.py": "def foo_should_bar_when_baz():\n pass\n"}
108
+ )
@@ -0,0 +1,64 @@
1
+ """catalog-size eye."""
2
+ from ratch.check import Plant
3
+ from ratch.result import MeasuredValue, MState, Result, State
4
+ from ratch.testing import FakeWorkspace
5
+
6
+
7
+ class CatalogSize:
8
+ """Report how many checks are installed, without gating.
9
+
10
+ Rule:
11
+ The installed ratch.checks catalog size is reported as a
12
+ measured value.
13
+
14
+ Why:
15
+ A gate that failed the run because the catalog was empty would
16
+ confuse absence of plugins with a rule break; an eye only
17
+ measures.
18
+
19
+ Proven in:
20
+ internal/specs/2026-09-17-ratchet-v1-spine-design.md
21
+
22
+ Not this:
23
+ Not a gate. VACUOUS on an empty catalog does not fail the run.
24
+ """
25
+
26
+ id = "catalog-size"
27
+ tier = "A"
28
+ kind = "eye"
29
+ scope = "global"
30
+ proven_in = ("internal/specs/2026-09-17-ratchet-v1-spine-design.md",)
31
+ confidence = "inferred"
32
+ tolerates_unparseable = False
33
+
34
+ def __init__(self, min_surface=1):
35
+ if min_surface < 1:
36
+ raise ValueError("min_surface must be >= 1")
37
+ self.min_surface = min_surface
38
+
39
+ def check(self, ws):
40
+ n = len(ws.plugin_classes())
41
+ measured = MeasuredValue(
42
+ value=n, state=MState.MEASURED,
43
+ source="plugin_classes", measured_at=ws.now(),
44
+ )
45
+ if n < self.min_surface:
46
+ state = State.VACUOUS
47
+ else:
48
+ state = State.PASS
49
+ return Result(
50
+ self.id, state, examined_n=n, findings=[], measured=measured,
51
+ )
52
+
53
+ def plants(self, ws):
54
+ yield Plant(
55
+ label="empty-catalog",
56
+ planted_ws=FakeWorkspace(files={"a.py": "x = 1\n"}, plugin_classes={}),
57
+ expected=(self.id, "catalog", "0"),
58
+ )
59
+
60
+ def fixture(self, kit):
61
+ return FakeWorkspace(
62
+ files={"a.py": "x = 1\n"},
63
+ plugin_classes={"no-forbidden-literal": object},
64
+ )
@@ -0,0 +1,120 @@
1
+ """No-circular-import check."""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+
8
+ from ratch.check import Plant
9
+ from ratch.result import Finding, Result, State
10
+ from ratch.workspace import Workspace
11
+
12
+ _CYCLE_SIGNATURES = ("partially initialized module", "circular import")
13
+
14
+
15
+ def _strip_trailing_path(line):
16
+ """Drop CPython's trailing ``(/abs/path/mod.py)`` from an ImportError line.
17
+
18
+ That path varies by machine and tmp dir; removing it makes the anchor
19
+ deterministic so a planted cycle yields a stable finding identity.
20
+ """
21
+ if line.endswith(")"):
22
+ open_at = line.rfind(" (")
23
+ if open_at != -1 and "/" in line[open_at:]:
24
+ return line[:open_at].rstrip()
25
+ return line
26
+
27
+
28
+ def _cycle_line(stderr):
29
+ lines = [ln.strip() for ln in stderr.splitlines() if ln.strip()]
30
+ for line in reversed(lines):
31
+ if any(sig in line for sig in _CYCLE_SIGNATURES):
32
+ return _strip_trailing_path(line)
33
+ return lines[-1] if lines else ""
34
+
35
+
36
+ def _write_package(root, name, modules):
37
+ pkg = pathlib.Path(root) / name
38
+ pkg.mkdir(parents=True, exist_ok=True)
39
+ for mod_name, text in modules.items():
40
+ (pkg / mod_name).write_text(text)
41
+ return pkg
42
+
43
+
44
+ class NoCircularImport:
45
+ """Forbid a package that cannot be imported because of an import cycle.
46
+
47
+ Rule:
48
+ Importing the configured top-level package in a fresh interpreter
49
+ must not raise a CPython circular-import error.
50
+
51
+ Why:
52
+ A cycle imports clean in one entry order and explodes in another, so
53
+ it hides until a production import path hits the bad order; a
54
+ subprocess import proves the package loads on every commit, where an
55
+ in-process import would be masked by an already-populated sys.modules.
56
+
57
+ Proven in:
58
+ argus-gateway/tests/test_invariant_no_circular_import.py
59
+
60
+ Not this:
61
+ Not a static import-graph analysis and not a style rule. Only an
62
+ actual interpreter cycle FAILs; a missing dependency or any other
63
+ import error is reported as ERROR (could-not-measure), never FAIL.
64
+ The subprocess import runs against the checked-out worktree, not
65
+ the index, because Workspace.run always uses repo_root as cwd.
66
+ """
67
+
68
+ id = "no-circular-import"
69
+ tier = "A"
70
+ kind = "gate"
71
+ scope = "global"
72
+ proven_in = ("argus-gateway/tests/test_invariant_no_circular_import.py",)
73
+ confidence = "portable-with-config"
74
+ tolerates_unparseable = False
75
+ min_surface = 1
76
+
77
+ def __init__(self, package, interpreter=None):
78
+ self.package = package
79
+ self.interpreter = interpreter
80
+
81
+ def check(self, ws):
82
+ interp = self.interpreter or sys.executable
83
+ proc = ws.run([interp, "-c", f"import {self.package}"])
84
+ if proc.returncode == 0:
85
+ return Result(self.id, State.PASS, examined_n=1)
86
+ stderr = proc.stderr or ""
87
+ if any(sig in stderr for sig in _CYCLE_SIGNATURES):
88
+ anchor = _cycle_line(stderr)
89
+ return Result(
90
+ self.id, State.FAIL, examined_n=1,
91
+ findings=[Finding(self.id, self.package, anchor,
92
+ message=f"circular import: {anchor}")],
93
+ )
94
+ return Result(self.id, State.ERROR, examined_n=0)
95
+
96
+ def plants(self, ws):
97
+ root = pathlib.Path(tempfile.mkdtemp(prefix="ratch-circ-"))
98
+ _write_package(root, self.package, {
99
+ "__init__.py": f"from {self.package} import a\n",
100
+ "a.py": f"from {self.package}.b import beta\n\nalpha = 1\n",
101
+ "b.py": f"from {self.package}.a import alpha\n\nbeta = 2\n",
102
+ })
103
+ planted = Workspace(root)
104
+ probe = NoCircularImport(self.package, interpreter=self.interpreter)
105
+ probed = probe.check(planted)
106
+ if probed.state is not State.FAIL or not probed.findings:
107
+ raise AssertionError(
108
+ f"{self.id}: probe could not reproduce the planted cycle "
109
+ f"(got {probed.state})")
110
+ finding = probed.findings[0]
111
+ yield Plant(
112
+ label=f"circular:{self.package}",
113
+ planted_ws=planted,
114
+ expected=(self.id, self.package, finding.anchor),
115
+ )
116
+
117
+ def fixture(self, kit):
118
+ root = pathlib.Path(tempfile.mkdtemp(prefix="ratch-circ-clean-"))
119
+ _write_package(root, self.package, {"__init__.py": "value = 42\n"})
120
+ return Workspace(root)
@@ -0,0 +1,89 @@
1
+ """commit-heatmap eye, lifted from argus-gateway commit_heatmap hour buckets."""
2
+ from datetime import datetime
3
+
4
+ from ratch.check import Plant
5
+ from ratch.result import MeasuredValue, MState, Result, State
6
+ from ratch.testing import FakeWorkspace
7
+
8
+ _MAX_COMMITS = 500
9
+
10
+
11
+ def _hour_counts(text):
12
+ hours = [0] * 24
13
+ n = 0
14
+ for line in text.splitlines():
15
+ line = line.strip()
16
+ if not line:
17
+ continue
18
+ try:
19
+ ts = int(line)
20
+ except ValueError:
21
+ continue
22
+ hours[datetime.fromtimestamp(ts).hour] += 1
23
+ n += 1
24
+ if n >= _MAX_COMMITS:
25
+ break
26
+ return hours, n
27
+
28
+
29
+ class CommitHeatmap:
30
+ """Report the busiest hour-of-day in recent git history.
31
+
32
+ Rule:
33
+ Recent commit timestamps are bucketed by local hour of day and
34
+ the peak bucket is reported as a measured value.
35
+
36
+ Why:
37
+ A commit heatmap is a lens on when the repo actually moves;
38
+ failing the run because Tuesday was quiet would turn a dashboard
39
+ into a gate. argus-gateway's commit_heatmap is the structure x
40
+ time tool; this eye keeps only the hour histogram.
41
+
42
+ Proven in:
43
+ argus-gateway/tools/commit_heatmap.py
44
+
45
+ Not this:
46
+ Not the ASCII directory heatmap. Not role filters, --exclude, or
47
+ depth. Not a gate.
48
+ """
49
+
50
+ id = "commit-heatmap"
51
+ tier = "A"
52
+ kind = "eye"
53
+ scope = "global"
54
+ proven_in = ("argus-gateway/tools/commit_heatmap.py",)
55
+ confidence = "depth-once"
56
+ tolerates_unparseable = False
57
+
58
+ def __init__(self, min_surface=1):
59
+ if min_surface < 1:
60
+ raise ValueError("min_surface must be >= 1")
61
+ self.min_surface = min_surface
62
+
63
+ def check(self, ws):
64
+ hours, n = _hour_counts(ws.git_log(fmt="%at"))
65
+ if n < self.min_surface:
66
+ return Result(self.id, State.VACUOUS, examined_n=n, findings=[])
67
+ peak_hour = max(range(24), key=lambda hour: hours[hour])
68
+ measured = MeasuredValue(
69
+ value=f"{peak_hour}h:{hours[peak_hour]}/{n}",
70
+ state=MState.MEASURED,
71
+ source="git_log:%at",
72
+ measured_at=ws.now(),
73
+ )
74
+ return Result(
75
+ self.id, State.PASS, examined_n=n, findings=[], measured=measured,
76
+ )
77
+
78
+ def plants(self, ws):
79
+ yield Plant(
80
+ label="empty-log",
81
+ planted_ws=FakeWorkspace(files={"a.py": "x = 1\n"}, git_log_text=""),
82
+ expected=(self.id, "git_log", "0"),
83
+ )
84
+
85
+ def fixture(self, kit):
86
+ return FakeWorkspace(
87
+ files={"a.py": "x = 1\n"},
88
+ git_log_text="1700000000\n1700003600\n",
89
+ )
@@ -0,0 +1,79 @@
1
+ """No-conflict-markers check."""
2
+ from __future__ import annotations
3
+
4
+ from ratch.check import Plant
5
+ from ratch.result import Finding, Result, State
6
+ from ratch.testing import FakeWorkspace
7
+
8
+ _START = "<" * 7
9
+ _END = ">" * 7
10
+
11
+
12
+ class NoConflictMarkers:
13
+ """Forbid leftover git conflict markers in tracked files.
14
+
15
+ Rule:
16
+ No tracked file may contain a line whose stripped prefix is the
17
+ seven-character git conflict start or end marker.
18
+
19
+ Why:
20
+ A leftover conflict marker is an unfinished merge that no
21
+ reviewer reliably catches by eye; a machine can prove absence
22
+ on every commit.
23
+
24
+ Proven in:
25
+ internal/pillar-1-teeth.md
26
+
27
+ Not this:
28
+ Not a Markdown rule. A setext underline of equals signs is
29
+ allowed; only the git start and end markers are rejected.
30
+ """
31
+
32
+ id = "no-conflict-markers"
33
+ tier = "A"
34
+ kind = "gate"
35
+ scope = "global"
36
+ proven_in = ("internal/pillar-1-teeth.md",)
37
+ confidence = "inferred"
38
+ tolerates_unparseable = False
39
+
40
+ def __init__(self, min_surface=1):
41
+ if min_surface < 1:
42
+ raise ValueError("min_surface must be >= 1")
43
+ self.min_surface = min_surface
44
+
45
+ def _state(self, findings, examined_n):
46
+ if findings:
47
+ return State.FAIL
48
+ if examined_n < self.min_surface:
49
+ return State.VACUOUS
50
+ return State.PASS
51
+
52
+ def check(self, ws):
53
+ findings = []
54
+ examined_n = 0
55
+ for path in ws.tracked_files():
56
+ examined_n += 1
57
+ for line in ws.read(path).splitlines():
58
+ stripped = line.lstrip()
59
+ if stripped.startswith((_START, _END)):
60
+ anchor = " ".join(line.split())
61
+ findings.append(
62
+ Finding(self.id, path, anchor,
63
+ message=f"conflict marker: {anchor}")
64
+ )
65
+ return Result(
66
+ self.id, self._state(findings, examined_n),
67
+ examined_n=examined_n, skipped_n=ws.skipped_n, findings=findings,
68
+ )
69
+
70
+ def plants(self, ws):
71
+ line = _START + " HEAD"
72
+ yield Plant(
73
+ label="start-marker",
74
+ planted_ws=FakeWorkspace(files={"a.py": line + "\nx = 1\n"}),
75
+ expected=(self.id, "a.py", line),
76
+ )
77
+
78
+ def fixture(self, kit):
79
+ return FakeWorkspace(files={"a.py": "x = 1\n"})