punchlist 0.6.0__tar.gz

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,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .coverage
9
+
10
+ # Scratch output
11
+ temp/
12
+
13
+ # Virtual environment
14
+ .venv/
15
+
16
+ # IDE / Tools
17
+ .idea/
18
+ .claude/*
19
+ !.claude/skills/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steven Moret
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: punchlist
3
+ Version: 0.6.0
4
+ Summary: A file-based issue tracker: one markdown file per issue, in the project's own git repo
5
+ Project-URL: Homepage, https://github.com/smoret/punchlist
6
+ Project-URL: Source, https://github.com/smoret/punchlist
7
+ Project-URL: Issues, https://github.com/smoret/punchlist/tree/main/issues
8
+ Author-email: Steven Moret <smoret@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,cli,git,issue tracker,markdown
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Bug Tracking
23
+ Requires-Python: >=3.11
24
+ Provides-Extra: dev
25
+ Requires-Dist: pylint; extra == 'dev'
26
+ Requires-Dist: pyright; extra == 'dev'
27
+ Requires-Dist: pytest; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # punchlist
31
+
32
+ A small, file-based issue tracker for projects worked on by one developer and a crew of agents. Each issue is a markdown file with a short header, stored in the project's own git repo, so closing an issue lands in the same commit as the work that closed it. The `punch` CLI creates, lists, filters, and checks them.
33
+
34
+ Status: early, and tracking its own work: planned work is in [issues/](https://github.com/smoret/punchlist/tree/main/issues) (`punch list`).
35
+
36
+ ## Install
37
+
38
+ Needs Python 3.11 or later. `punch history` and `punch changed` read the repo's log, so they also need `git` on the PATH.
39
+
40
+ ```bash
41
+ pipx install punchlist # or: pip install punchlist
42
+ ```
43
+
44
+ ## Development
45
+
46
+ ```bash
47
+ python -m venv .venv
48
+ .venv/Scripts/activate # Windows (bash); .venv\Scripts\activate in cmd
49
+ pip install -e ".[dev]"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ punch --version # the installed version
56
+ punch init --prefix pl # write punchlist.json in the current directory
57
+ punch init --prefix pl --line-endings lf # write every file LF (default: the platform's)
58
+ punch new "Title" --type task --category tooling # prints the new id
59
+ punch new "Title" --type task --priority high --rank 2.5 # rank orders within a priority
60
+ punch new "Title" --type bug --body-file - # body from stdin
61
+ punch list # every status but closed: id status type priority category title
62
+ punch list --all --label cli --json
63
+ punch list --ready # open issues whose depends-on issues are all closed
64
+ punch set 7k3 depends-on+=2x9 # 7k3 also waits on 2x9; --blocked lists what's waiting
65
+ punch set 7k3 related+=9qp # add a non-blocking link, written into both issues
66
+ punch set 7k3 related-=9qp # remove one link, from both issues
67
+ punch search 'retry|backoff' # title or body, case-insensitive; takes list's filters
68
+ punch search retry --context 1 # plus each hit's matching lines (file line numbers), 1 line either side
69
+ punch show pl-7k3qx # or any unique start: pl-7k, 7k3
70
+ punch show 7k3 --json # header fields plus body
71
+ punch close pl-7k3qx
72
+ punch close 7k3 --comment "Fixed by the retry change." --author claude
73
+ punch comment 7k3 --author claude --body "Tried X; blocked on Y." # author: flag, $PUNCH_AUTHOR, git
74
+ punch show 7k3 --comments # the issue, then its comments oldest first
75
+ punch history 7k3 # from git: each commit that changed it or mentions it
76
+ punch history 7k3 --since 2026-03-01 --json # or --since <rev>: only what's newer
77
+ punch history 2x9 --tree --diff # an epic and everything under it; body changes as diffs
78
+ punch changed 2026-03-01 --type bug # created/closed/reopened/rescoped since; takes list's filters
79
+ punch set 7k3 priority=high labels=cli,docs # a list's = replaces the WHOLE list; field= removes
80
+ punch set 7k3 labels+=api rank=2 # += adds to a list, -= removes; one op per field per call
81
+ punch serve # kanban board at http://127.0.0.1:8314/, a lane per status
82
+ punch serve --author alex --port 0 --no-browser # any free port; comments signed alex
83
+ punch check # validate every issue file
84
+ punch check --references # also flag ids mentioned in bodies that don't exist
85
+ punch import-md ROADMAP.md --dry-run # what a markdown checklist would become
86
+ punch import-md ROADMAP.md --add-categories
87
+ ```
88
+
89
+ ## Issue format
90
+
91
+ ```markdown
92
+ ---
93
+ id: pl-7k3qx
94
+ title: An example issue
95
+ type: task
96
+ status: open
97
+ created: 2026-10-18
98
+ ---
99
+
100
+ Markdown body.
101
+ ```
102
+
103
+ The header is `key: value` lines, values taken verbatim -- no quoting, no YAML. See [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) for the full rules.
104
+
105
+ ## Documentation
106
+
107
+ Links are absolute so they also work on the PyPI page.
108
+
109
+ | File | Purpose |
110
+ |---|---|
111
+ | [CLAUDE.md](https://github.com/smoret/punchlist/blob/main/CLAUDE.md) | Conventions for agents working in this repo |
112
+ | [issues/](https://github.com/smoret/punchlist/tree/main/issues) | Planned work, as punchlist issues |
113
+ | [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) | What was decided and why |
114
+ | [ARCHITECTURE.md](https://github.com/smoret/punchlist/blob/main/ARCHITECTURE.md) | The module map |
115
+
116
+ ## License
117
+
118
+ MIT; see [LICENSE](https://github.com/smoret/punchlist/blob/main/LICENSE).
@@ -0,0 +1,89 @@
1
+ # punchlist
2
+
3
+ A small, file-based issue tracker for projects worked on by one developer and a crew of agents. Each issue is a markdown file with a short header, stored in the project's own git repo, so closing an issue lands in the same commit as the work that closed it. The `punch` CLI creates, lists, filters, and checks them.
4
+
5
+ Status: early, and tracking its own work: planned work is in [issues/](https://github.com/smoret/punchlist/tree/main/issues) (`punch list`).
6
+
7
+ ## Install
8
+
9
+ Needs Python 3.11 or later. `punch history` and `punch changed` read the repo's log, so they also need `git` on the PATH.
10
+
11
+ ```bash
12
+ pipx install punchlist # or: pip install punchlist
13
+ ```
14
+
15
+ ## Development
16
+
17
+ ```bash
18
+ python -m venv .venv
19
+ .venv/Scripts/activate # Windows (bash); .venv\Scripts\activate in cmd
20
+ pip install -e ".[dev]"
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ punch --version # the installed version
27
+ punch init --prefix pl # write punchlist.json in the current directory
28
+ punch init --prefix pl --line-endings lf # write every file LF (default: the platform's)
29
+ punch new "Title" --type task --category tooling # prints the new id
30
+ punch new "Title" --type task --priority high --rank 2.5 # rank orders within a priority
31
+ punch new "Title" --type bug --body-file - # body from stdin
32
+ punch list # every status but closed: id status type priority category title
33
+ punch list --all --label cli --json
34
+ punch list --ready # open issues whose depends-on issues are all closed
35
+ punch set 7k3 depends-on+=2x9 # 7k3 also waits on 2x9; --blocked lists what's waiting
36
+ punch set 7k3 related+=9qp # add a non-blocking link, written into both issues
37
+ punch set 7k3 related-=9qp # remove one link, from both issues
38
+ punch search 'retry|backoff' # title or body, case-insensitive; takes list's filters
39
+ punch search retry --context 1 # plus each hit's matching lines (file line numbers), 1 line either side
40
+ punch show pl-7k3qx # or any unique start: pl-7k, 7k3
41
+ punch show 7k3 --json # header fields plus body
42
+ punch close pl-7k3qx
43
+ punch close 7k3 --comment "Fixed by the retry change." --author claude
44
+ punch comment 7k3 --author claude --body "Tried X; blocked on Y." # author: flag, $PUNCH_AUTHOR, git
45
+ punch show 7k3 --comments # the issue, then its comments oldest first
46
+ punch history 7k3 # from git: each commit that changed it or mentions it
47
+ punch history 7k3 --since 2026-03-01 --json # or --since <rev>: only what's newer
48
+ punch history 2x9 --tree --diff # an epic and everything under it; body changes as diffs
49
+ punch changed 2026-03-01 --type bug # created/closed/reopened/rescoped since; takes list's filters
50
+ punch set 7k3 priority=high labels=cli,docs # a list's = replaces the WHOLE list; field= removes
51
+ punch set 7k3 labels+=api rank=2 # += adds to a list, -= removes; one op per field per call
52
+ punch serve # kanban board at http://127.0.0.1:8314/, a lane per status
53
+ punch serve --author alex --port 0 --no-browser # any free port; comments signed alex
54
+ punch check # validate every issue file
55
+ punch check --references # also flag ids mentioned in bodies that don't exist
56
+ punch import-md ROADMAP.md --dry-run # what a markdown checklist would become
57
+ punch import-md ROADMAP.md --add-categories
58
+ ```
59
+
60
+ ## Issue format
61
+
62
+ ```markdown
63
+ ---
64
+ id: pl-7k3qx
65
+ title: An example issue
66
+ type: task
67
+ status: open
68
+ created: 2026-10-18
69
+ ---
70
+
71
+ Markdown body.
72
+ ```
73
+
74
+ The header is `key: value` lines, values taken verbatim -- no quoting, no YAML. See [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) for the full rules.
75
+
76
+ ## Documentation
77
+
78
+ Links are absolute so they also work on the PyPI page.
79
+
80
+ | File | Purpose |
81
+ |---|---|
82
+ | [CLAUDE.md](https://github.com/smoret/punchlist/blob/main/CLAUDE.md) | Conventions for agents working in this repo |
83
+ | [issues/](https://github.com/smoret/punchlist/tree/main/issues) | Planned work, as punchlist issues |
84
+ | [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) | What was decided and why |
85
+ | [ARCHITECTURE.md](https://github.com/smoret/punchlist/blob/main/ARCHITECTURE.md) | The module map |
86
+
87
+ ## License
88
+
89
+ MIT; see [LICENSE](https://github.com/smoret/punchlist/blob/main/LICENSE).
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ # 1.27 is the first hatchling to write the PEP 639 license fields below.
3
+ requires = ["hatchling>=1.27"]
4
+ build-backend = "hatchling.build"
5
+
6
+ [project]
7
+ name = "punchlist"
8
+ version = "0.6.0"
9
+ description = "A file-based issue tracker: one markdown file per issue, in the project's own git repo"
10
+ authors = [
11
+ { name = "Steven Moret", email = "smoret@gmail.com" },
12
+ ]
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ readme = "README.md"
16
+ requires-python = ">=3.11"
17
+ dependencies = []
18
+ keywords = ["issue tracker", "git", "markdown", "cli", "agents"]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Environment :: Console",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3 :: Only",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Software Development :: Bug Tracking",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/smoret/punchlist"
35
+ Source = "https://github.com/smoret/punchlist"
36
+ Issues = "https://github.com/smoret/punchlist/tree/main/issues"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest",
41
+ "pylint",
42
+ "pyright",
43
+ ]
44
+
45
+ [project.scripts]
46
+ punch = "punchlist.cli:main"
47
+ punchlist = "punchlist.cli:main"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ # Everything under the package ships, including static/board.html for `punch serve`.
51
+ packages = ["src/punchlist"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ # The sdist is for building and testing the package, not the project's issues or docs.
55
+ include = ["src", "tests", "README.md", "LICENSE"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ # Tests import shared helpers as `from helpers import ...`.
60
+ pythonpath = ["tests"]
61
+
62
+ [tool.pyright]
63
+ extraPaths = ["tests"]
64
+
65
+ [tool.pylint.basic]
66
+ # http.server dispatches a request to the handler method named do_<METHOD>.
67
+ good-names-rgxs = ["^do_[A-Z]+$"]
68
+
69
+ [tool.pylint."messages control"]
70
+ # Complexity metrics are refactor-time signals, not defects.
71
+ disable = [
72
+ "too-many-arguments",
73
+ "too-many-branches",
74
+ "too-many-instance-attributes",
75
+ "too-many-locals",
76
+ "too-many-positional-arguments",
77
+ "too-many-return-statements",
78
+ "too-many-statements",
79
+ "too-few-public-methods",
80
+ ]
@@ -0,0 +1 @@
1
+ """punchlist: a file-based issue tracker, one markdown file per issue in the project's own repo."""
@@ -0,0 +1,324 @@
1
+ """`punch check`: validate every issue file and report every problem, not just the first.
2
+
3
+ Per file it reports a file outside its bucket and the first parse error (the
4
+ parser stops there), then checks across files: each `id:` matches its
5
+ filename, which with correct buckets also makes ids unique; each `parent`
6
+ names an existing issue file; and no chain of parents loops back on itself.
7
+ With a `depends-on` field: each dependency names an existing issue file, and
8
+ no group of issues depends on each other in a cycle. With a `related` field:
9
+ each related id names another existing issue, which lists this one back.
10
+ Opt-in (config `check_references`, or `punch check --references`): each id
11
+ of this tracker's shape mentioned in a body names an existing issue file.
12
+ Comments: every folder beside an issue file belongs to an existing issue, and
13
+ every file in a `comments/` folder is a valid comment matching its name.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from .comment import read_comment
22
+ from .config import DEPENDS_ON, RELATED, Config
23
+ from .errors import PunchError
24
+ from .ids import ALPHABET
25
+ from .issue import Issue, issue_files, placement_error, read_issue
26
+
27
+
28
+ def parent_loop(config: Config, issue_id: str, parent: str) -> list[str] | None:
29
+ """The loop that giving `issue_id` this `parent` would close, as `[a, b, a]`; else None.
30
+
31
+ Walks up from `parent` through each ancestor's file. A dangling parent or
32
+ a loop elsewhere ends the walk; `punch check` reports those.
33
+ """
34
+ chain: list[str] = [issue_id]
35
+ node: str | None = parent
36
+ while node is not None and node not in chain[1:]:
37
+ chain.append(node)
38
+ if node == issue_id:
39
+ return chain
40
+ path: Path = config.issue_path(node)
41
+ if not path.is_file():
42
+ return None
43
+ node = read_issue(path, config).text("parent")
44
+ return None
45
+
46
+
47
+ def dependency_loop(config: Config, issue_id: str,
48
+ dependencies: list[str]) -> list[str] | None:
49
+ """The loop giving `issue_id` these dependencies would close, as `[a, b, a]`; else None.
50
+
51
+ Searches breadth-first from each dependency through the `depends-on` of
52
+ each issue file it reaches, so the loop reported is a shortest one. A
53
+ missing file ends that branch; `punch check` reports it.
54
+ """
55
+ previous: dict[str, str] = {}
56
+ queue: list[str] = []
57
+ for dependency in dependencies:
58
+ if dependency == issue_id:
59
+ return [issue_id, issue_id]
60
+ if dependency not in previous:
61
+ previous[dependency] = issue_id
62
+ queue.append(dependency)
63
+ while queue:
64
+ node: str = queue.pop(0)
65
+ path: Path = config.issue_path(node)
66
+ if not path.is_file():
67
+ continue
68
+ for successor in read_issue(path, config).items(DEPENDS_ON):
69
+ if successor == issue_id:
70
+ return _loop_path(previous, issue_id, node)
71
+ if successor not in previous:
72
+ previous[successor] = node
73
+ queue.append(successor)
74
+ return None
75
+
76
+
77
+ def _loop_path(previous: dict[str, str], start: str, last: str) -> list[str]:
78
+ """`[start, ..., last, start]` from the breadth-first `previous` links."""
79
+ path: list[str] = [last]
80
+ while path[0] != start:
81
+ path.insert(0, previous[path[0]])
82
+ return [*path, start]
83
+
84
+
85
+ def check_issues(config: Config,
86
+ references: bool | None = None) -> tuple[int, list[PunchError]]:
87
+ """The number of issue files, and every problem found in them, in file and line order.
88
+
89
+ `references` turns the body reference check on or off; None follows the
90
+ config's `check_references`.
91
+ """
92
+ if references is None:
93
+ references = config.check_references
94
+ paths: list[Path] = issue_files(config)
95
+ # Parents are checked against filenames, so a corrupt parent file is one
96
+ # problem rather than one per child.
97
+ stems: set[str] = {path.stem for path in paths}
98
+ problems: list[PunchError] = []
99
+ parsed: dict[str, Issue] = {}
100
+ for path in paths:
101
+ placement: PunchError | None = placement_error(path, config)
102
+ if placement is not None:
103
+ problems.append(placement)
104
+ try:
105
+ issue: Issue = read_issue(path, config)
106
+ except PunchError as exc:
107
+ problems.append(exc)
108
+ continue
109
+ parsed[path.stem] = issue
110
+ if issue.id != path.stem:
111
+ problems.append(PunchError(
112
+ f"id {issue.id!r} does not match the filename", path, issue.lines["id"]
113
+ ))
114
+ parent: str | None = issue.text("parent")
115
+ if parent is not None and parent not in stems:
116
+ problems.append(PunchError(
117
+ f"parent {parent!r} does not exist", path, issue.lines["parent"]
118
+ ))
119
+ if config.has_links:
120
+ for dependency in issue.items(DEPENDS_ON):
121
+ if dependency not in stems:
122
+ problems.append(PunchError(
123
+ f"{DEPENDS_ON} {dependency!r} does not exist", path,
124
+ issue.lines[DEPENDS_ON],
125
+ ))
126
+ if config.has_related:
127
+ for other in issue.items(RELATED):
128
+ if other == issue.id:
129
+ problems.append(PunchError(
130
+ f"{RELATED} lists the issue itself", path, issue.lines[RELATED]
131
+ ))
132
+ elif other not in stems:
133
+ problems.append(PunchError(
134
+ f"{RELATED} {other!r} does not exist", path, issue.lines[RELATED]
135
+ ))
136
+ if references:
137
+ problems.extend(_dangling_references(config, issue, stems))
138
+ problems.extend(_parent_loops(parsed))
139
+ if config.has_links:
140
+ problems.extend(_dependency_loops(parsed))
141
+ if config.has_related:
142
+ problems.extend(_one_sided_related(parsed))
143
+ problems.extend(_comment_problems(config))
144
+ problems.sort(key=lambda p: (p.path or Path(), p.line or 0))
145
+ return len(paths), problems
146
+
147
+
148
+ def _comment_problems(config: Config) -> list[PunchError]:
149
+ """Problems with comment folders and files.
150
+
151
+ A folder beside an issue file (`issues/<bucket>/<id>/`) must belong to an
152
+ existing, correctly placed issue; every entry of its `comments/` folder
153
+ must be a comment file whose header parses and matches its name.
154
+ """
155
+ problems: list[PunchError] = []
156
+ if not config.issues_path.is_dir():
157
+ return problems
158
+ for folder in sorted(config.issues_path.glob("*/*/")):
159
+ try:
160
+ expected: Path = config.issue_path(folder.name).with_suffix("")
161
+ except PunchError:
162
+ expected = Path()
163
+ if folder != expected or not (folder.parent / f"{folder.name}.md").is_file():
164
+ problems.append(PunchError(
165
+ f"folder for issue {folder.name!r}, which does not exist here", folder
166
+ ))
167
+ continue
168
+ comments: Path = folder / "comments"
169
+ if not comments.is_dir():
170
+ continue
171
+ for path in sorted(comments.iterdir()):
172
+ if not path.is_file() or path.suffix != ".md":
173
+ problems.append(PunchError(
174
+ "not a comment file (expected <time>-<author>.md)", path
175
+ ))
176
+ continue
177
+ try:
178
+ read_comment(path)
179
+ except PunchError as exc:
180
+ problems.append(exc)
181
+ return problems
182
+
183
+
184
+ def _dangling_references(config: Config, issue: Issue, stems: set[str]) -> list[PunchError]:
185
+ """Each body mention of this tracker's id shape that names no issue file, once per line.
186
+
187
+ Only `<prefix>-` plus `id_length` id-alphabet characters, on word
188
+ boundaries, counts, so another tracker's ids (another prefix) never match.
189
+ """
190
+ pattern: re.Pattern[str] = re.compile(
191
+ rf"\b{re.escape(config.prefix)}-[{ALPHABET}]{{{config.id_length}}}\b"
192
+ )
193
+ problems: list[PunchError] = []
194
+ for offset, line in enumerate(issue.body.split("\n")):
195
+ for mention in dict.fromkeys(pattern.findall(line)):
196
+ if mention not in stems:
197
+ problems.append(PunchError(
198
+ f"reference to {mention}, which does not exist",
199
+ issue.path, issue.body_line + offset,
200
+ ))
201
+ return problems
202
+
203
+
204
+ def _parent_loops(issues: dict[str, Issue]) -> list[PunchError]:
205
+ """One problem per parent loop, at the `parent:` line of the loop's lowest id."""
206
+ parents: dict[str, str] = {}
207
+ for stem, issue in issues.items():
208
+ parent: str | None = issue.text("parent")
209
+ if parent is not None:
210
+ parents[stem] = parent
211
+ problems: list[PunchError] = []
212
+ done: set[str] = set()
213
+ for start in sorted(parents):
214
+ chain: list[str] = []
215
+ node: str | None = start
216
+ while node is not None and node not in done and node not in chain:
217
+ chain.append(node)
218
+ node = parents.get(node)
219
+ if node is not None and node in chain:
220
+ loop: list[str] = chain[chain.index(node):]
221
+ first: int = loop.index(min(loop))
222
+ loop = loop[first:] + loop[:first]
223
+ head: Issue = issues[loop[0]]
224
+ problems.append(PunchError(
225
+ f"parent loop: {' -> '.join([*loop, loop[0]])}", head.path, head.lines["parent"]
226
+ ))
227
+ done.update(chain)
228
+ return problems
229
+
230
+
231
+ def _one_sided_related(issues: dict[str, Issue]) -> list[PunchError]:
232
+ """A related link the other issue doesn't list back, on the side that has it.
233
+
234
+ `set` and `new` write both sides, so this comes from a hand edit or a merge
235
+ that kept one file's side. A partner that didn't parse is skipped; its own
236
+ problem is already reported.
237
+ """
238
+ problems: list[PunchError] = []
239
+ for stem, issue in issues.items():
240
+ for other in issue.items(RELATED):
241
+ if other != stem and other in issues and stem not in issues[other].items(RELATED):
242
+ problems.append(PunchError(
243
+ f"{RELATED} {other!r} does not list {stem!r} back", issue.path,
244
+ issue.lines[RELATED],
245
+ ))
246
+ return problems
247
+
248
+
249
+ def _dependency_loops(issues: dict[str, Issue]) -> list[PunchError]:
250
+ """One problem per group of issues that depend on each other in a cycle.
251
+
252
+ Each strongly connected component with a cycle is reported once, at the
253
+ `depends-on` line of its lowest id, naming a shortest loop through it.
254
+ """
255
+ graph: dict[str, list[str]] = {
256
+ stem: [d for d in issue.items(DEPENDS_ON) if d in issues]
257
+ for stem, issue in issues.items()
258
+ }
259
+ problems: list[PunchError] = []
260
+ for component in _components(graph):
261
+ start: str = min(component)
262
+ if len(component) == 1 and start not in graph[start]:
263
+ continue
264
+ members: set[str] = set(component)
265
+ previous: dict[str, str] = {}
266
+ queue: list[str] = [start]
267
+ last: str | None = None
268
+ while queue and last is None:
269
+ node: str = queue.pop(0)
270
+ for successor in sorted(graph[node]):
271
+ if successor == start:
272
+ last = node
273
+ break
274
+ if successor in members and successor not in previous:
275
+ previous[successor] = node
276
+ queue.append(successor)
277
+ assert last is not None # a component with a cycle has a loop through any member
278
+ head: Issue = issues[start]
279
+ loop: list[str] = _loop_path(previous, start, last)
280
+ problems.append(PunchError(
281
+ f"dependency loop: {' -> '.join(loop)}", head.path, head.lines[DEPENDS_ON]
282
+ ))
283
+ return problems
284
+
285
+
286
+ def _components(graph: dict[str, list[str]]) -> list[list[str]]:
287
+ """Strongly connected components of `graph` (Tarjan's algorithm, iterative)."""
288
+ index: dict[str, int] = {}
289
+ low: dict[str, int] = {}
290
+ stack: list[str] = []
291
+ on_stack: set[str] = set()
292
+ components: list[list[str]] = []
293
+ for root in sorted(graph):
294
+ if root in index:
295
+ continue
296
+ work: list[tuple[str, int]] = [(root, 0)]
297
+ while work:
298
+ node, position = work[-1]
299
+ if position == 0:
300
+ index[node] = low[node] = len(index)
301
+ stack.append(node)
302
+ on_stack.add(node)
303
+ successors: list[str] = graph[node]
304
+ if position < len(successors):
305
+ work[-1] = (node, position + 1)
306
+ successor: str = successors[position]
307
+ if successor not in index:
308
+ work.append((successor, 0))
309
+ elif successor in on_stack:
310
+ low[node] = min(low[node], index[successor])
311
+ continue
312
+ work.pop()
313
+ if work:
314
+ low[work[-1][0]] = min(low[work[-1][0]], low[node])
315
+ if low[node] == index[node]:
316
+ component: list[str] = []
317
+ while True:
318
+ member: str = stack.pop()
319
+ on_stack.discard(member)
320
+ component.append(member)
321
+ if member == node:
322
+ break
323
+ components.append(component)
324
+ return components