engineering-process 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.
Files changed (69) hide show
  1. engineering_process/__init__.py +3 -0
  2. engineering_process/_download_worker.py +73 -0
  3. engineering_process/_supervisor_posix.py +162 -0
  4. engineering_process/_supervisor_windows.py +148 -0
  5. engineering_process/_windows_job.py +419 -0
  6. engineering_process/bootstrap.py +217 -0
  7. engineering_process/bundles.py +81 -0
  8. engineering_process/cli.py +1012 -0
  9. engineering_process/contracts.py +1274 -0
  10. engineering_process/distribution.py +90 -0
  11. engineering_process/environment.py +753 -0
  12. engineering_process/helper_launch.py +37 -0
  13. engineering_process/lifecycle.py +1024 -0
  14. engineering_process/managed.py +89 -0
  15. engineering_process/markdown.py +78 -0
  16. engineering_process/publication.py +463 -0
  17. engineering_process/requirements-build.txt +1 -0
  18. engineering_process/requirements-dev.txt +10 -0
  19. engineering_process/requirements-runtime.txt +3 -0
  20. engineering_process/runner.py +191 -0
  21. engineering_process/runtime.py +55 -0
  22. engineering_process/skills.py +118 -0
  23. engineering_process/supervision.py +72 -0
  24. engineering_process/syncing.py +398 -0
  25. engineering_process/tooling.py +891 -0
  26. engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
  27. engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
  28. engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
  29. engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
  30. engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
  31. engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
  32. engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
  33. engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
  34. engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
  35. engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
  36. engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
  37. engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
  38. engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
  39. engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
  40. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
  41. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
  42. engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
  43. engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
  44. engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
  45. engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
  46. engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
  47. engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
  48. engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
  49. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
  50. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
  51. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
  52. engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
  53. engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
  54. engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
  55. engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
  56. engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
  57. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
  58. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
  59. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
  60. engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
  61. engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
  62. engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
  63. engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
  64. engineering_process-0.1.0.dist-info/METADATA +387 -0
  65. engineering_process-0.1.0.dist-info/RECORD +69 -0
  66. engineering_process-0.1.0.dist-info/WHEEL +5 -0
  67. engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
  68. engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. engineering_process-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .contracts import ContractError
6
+ from .markdown import (
7
+ contains_raw_html,
8
+ mask_nonvisible_markdown_blocks,
9
+ strip_html_comments,
10
+ )
11
+
12
+
13
+ AGENTS_START = "<!-- engineering-process:start -->"
14
+ AGENTS_END = "<!-- engineering-process:end -->"
15
+ _AGENTS_START_TOKEN = "ENGINEERING_PROCESS_MANAGED_AGENTS_START"
16
+ _AGENTS_END_TOKEN = "ENGINEERING_PROCESS_MANAGED_AGENTS_END"
17
+
18
+
19
+ def _normalized_markdown(text: str) -> str:
20
+ return text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
21
+
22
+
23
+ def _agents_span(text: str) -> tuple[int, int]:
24
+ if text.count(AGENTS_START) != 1 or text.count(AGENTS_END) != 1:
25
+ raise ContractError("AGENTS.md: invalid engineering-process managed block")
26
+ starts = list(re.finditer(rf"(?m)^{re.escape(AGENTS_START)}$", text))
27
+ ends = list(re.finditer(rf"(?m)^{re.escape(AGENTS_END)}$", text))
28
+ if len(starts) != 1 or len(ends) != 1:
29
+ raise ContractError("AGENTS.md: managed markers must each occupy their own line")
30
+ if starts[0].end() >= ends[0].start():
31
+ raise ContractError(
32
+ "AGENTS.md: engineering-process managed markers are out of order"
33
+ )
34
+ return starts[0].start(), ends[0].end()
35
+
36
+
37
+ def managed_agents_block(text: str) -> str:
38
+ normalized = _normalized_markdown(text)
39
+ start, end = _agents_span(normalized)
40
+ return normalized[start:end]
41
+
42
+
43
+ def managed_agents_visibility_issues(text: str) -> list[str]:
44
+ text = _normalized_markdown(text)
45
+ try:
46
+ _agents_span(text)
47
+ except ContractError as error:
48
+ return [str(error)]
49
+ if _AGENTS_START_TOKEN in text or _AGENTS_END_TOKEN in text:
50
+ return ["AGENTS.md contains reserved engineering-process marker text"]
51
+ visible = text.replace(AGENTS_START, _AGENTS_START_TOKEN).replace(
52
+ AGENTS_END, _AGENTS_END_TOKEN
53
+ )
54
+ visible, malformed_comments = strip_html_comments(visible)
55
+ if malformed_comments:
56
+ return ["AGENTS.md contains an unterminated or malformed HTML comment"]
57
+ if contains_raw_html(visible):
58
+ return [
59
+ "AGENTS.md must not contain raw HTML; use visible CommonMark outside "
60
+ "the managed markers"
61
+ ]
62
+ structural = mask_nonvisible_markdown_blocks(visible)
63
+ lines = structural.splitlines()
64
+ starts = [
65
+ index for index, line in enumerate(lines) if line == _AGENTS_START_TOKEN
66
+ ]
67
+ ends = [index for index, line in enumerate(lines) if line == _AGENTS_END_TOKEN]
68
+ if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]:
69
+ return [
70
+ "AGENTS.md managed block must be visible and outside comments, code fences, "
71
+ "or non-visible Markdown blocks"
72
+ ]
73
+ return []
74
+
75
+
76
+ def merge_managed_agents(current: str, block: str) -> str:
77
+ canonical = managed_agents_block(block).strip()
78
+ current = _normalized_markdown(current)
79
+ start_count = current.count(AGENTS_START)
80
+ end_count = current.count(AGENTS_END)
81
+ if start_count == 0 and end_count == 0:
82
+ if not current.strip():
83
+ return canonical + "\n"
84
+ return current.rstrip() + "\n\n" + canonical + "\n"
85
+ start, end = _agents_span(current)
86
+ prefix = current[:start].rstrip()
87
+ suffix = current[end:].strip()
88
+ parts = [part for part in (prefix, canonical, suffix) if part]
89
+ return "\n\n".join(parts) + "\n"
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ import re
5
+ import unicodedata
6
+
7
+ from markdown_it import MarkdownIt
8
+ from markdown_it.token import Token
9
+
10
+ from .runtime import assert_runtime_dependencies
11
+
12
+
13
+ assert_runtime_dependencies()
14
+
15
+
16
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
17
+ RAW_HTML_LIKE_LINE_RE = re.compile(r"(?m)^ {0,3}<(?:/?[A-Za-z]|[!?])")
18
+ _COMMONMARK = MarkdownIt("commonmark", {"html": True})
19
+ _NONVISIBLE_BLOCK_TYPES = {"code_block", "fence", "html_block"}
20
+
21
+
22
+ def strip_html_comments(text: str) -> tuple[str, bool]:
23
+ visible = COMMENT_RE.sub("", text)
24
+ malformed = "<!--" in visible or "-->" in visible
25
+ return visible, malformed
26
+
27
+
28
+ def mask_nonvisible_markdown_blocks(text: str) -> str:
29
+ """Mask source lines CommonMark renders as code or raw HTML blocks."""
30
+
31
+ lines = text.splitlines()
32
+ hidden: set[int] = set()
33
+ for token in _COMMONMARK.parse(text):
34
+ if token.type not in _NONVISIBLE_BLOCK_TYPES or token.map is None:
35
+ continue
36
+ start, end = token.map
37
+ hidden.update(range(start, end))
38
+ return "\n".join("" if index in hidden else line for index, line in enumerate(lines))
39
+
40
+
41
+ def _walk_tokens(tokens: list[Token]) -> Iterator[Token]:
42
+ for token in tokens:
43
+ yield token
44
+ if token.children:
45
+ yield from _walk_tokens(token.children)
46
+
47
+
48
+ def contains_raw_html(text: str) -> bool:
49
+ return RAW_HTML_LIKE_LINE_RE.search(text) is not None or any(
50
+ token.type in {"html_block", "html_inline"}
51
+ for token in _walk_tokens(_COMMONMARK.parse(text))
52
+ )
53
+
54
+
55
+ def _inline_text(tokens: list[Token]) -> str:
56
+ pieces: list[str] = []
57
+ for token in tokens:
58
+ if token.children:
59
+ pieces.append(_inline_text(token.children))
60
+ elif token.type in {"text", "code_inline"}:
61
+ pieces.append(token.content)
62
+ elif token.type in {"softbreak", "hardbreak"}:
63
+ pieces.append(" ")
64
+ elif token.type == "image":
65
+ pieces.append(token.content)
66
+ return "".join(pieces)
67
+
68
+
69
+ def normalized_rendered_inline_text(text: str) -> str:
70
+ """Return formatting-insensitive CommonMark inline text for policy checks."""
71
+
72
+ rendered = _inline_text(_COMMONMARK.parseInline(text))
73
+ normalized = unicodedata.normalize("NFKC", rendered)
74
+ normalized = "".join(
75
+ " " if unicodedata.category(character) == "Cf" else character
76
+ for character in normalized
77
+ )
78
+ return " ".join(normalized.split()).casefold()
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from .contracts import ContractError
8
+ from .markdown import (
9
+ COMMENT_RE,
10
+ contains_raw_html,
11
+ mask_nonvisible_markdown_blocks,
12
+ normalized_rendered_inline_text,
13
+ strip_html_comments,
14
+ )
15
+
16
+
17
+ CONVENTIONAL_SUBJECT_MAX_LENGTH = 72
18
+ CONVENTIONAL_SUBJECT_RE = re.compile(
19
+ r"^[a-z]+(?:\([a-z0-9-]+\))?!?: \S(?:.*\S)?$"
20
+ )
21
+ MANUAL_BRANCH_RE = re.compile(
22
+ r"^(?:feat|fix|docs|refactor|test|chore|build|ci|perf)/"
23
+ r"[a-z0-9]+(?:-[a-z0-9]+)*$"
24
+ )
25
+ AUTOMATION_BRANCH_RE = re.compile(
26
+ r"^automation/[a-z0-9]+(?:-[a-z0-9]+)*/"
27
+ r"[a-z0-9](?:[a-z0-9._/-]*[a-z0-9])?$"
28
+ )
29
+ GIT_RANGE_RE = re.compile(
30
+ r"^[0-9A-Za-z][0-9A-Za-z._/~^-]*(?:\.\.\.?[0-9A-Za-z][0-9A-Za-z._/~^-]*)?$"
31
+ )
32
+ HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
33
+ CHECKBOX_RE = re.compile(
34
+ r"^\s*-\s+\[(?P<state>[ xX])\]\s+\*\*(?P<label>[^*]+)\*\*(?P<detail>.*)$",
35
+ re.MULTILINE,
36
+ )
37
+ PROJECT_REQUIREMENT_RE = re.compile(
38
+ r"^- \[(?P<state>[ xX])\] \*\*Project-specific: "
39
+ r"(?P<label>[A-Za-z0-9][A-Za-z0-9 /_.-]{0,99})\*\* "
40
+ r"— (?P<detail>\S.*)$"
41
+ )
42
+ CHECKLIST_STATUS_RE = re.compile(
43
+ r"\[status:\s*(?P<status>[a-z-]+)\]\s*$", re.IGNORECASE
44
+ )
45
+ CHECKLIST_REASON_RE = re.compile(
46
+ r"\[reason:\s*(?P<reason>[^\]]*\S[^\]]*)\]", re.IGNORECASE
47
+ )
48
+ CHECKLIST_STATUSES = {"satisfied", "not-applicable", "pending"}
49
+ PR_DESCRIPTION_START = "<!-- engineering-process:pr-description:start -->"
50
+ PR_DESCRIPTION_END = "<!-- engineering-process:pr-description:end -->"
51
+ _START_TOKEN = "ENGINEERING_PROCESS_MANAGED_PR_DESCRIPTION_START"
52
+ _END_TOKEN = "ENGINEERING_PROCESS_MANAGED_PR_DESCRIPTION_END"
53
+ RAW_HTML_BLOCK_RE = re.compile(r"(?m)^ {0,3}</?[A-Za-z][^>]*>")
54
+ REQUIRED_SECTIONS = (
55
+ "Summary",
56
+ "Contract and scope",
57
+ "Impact and risk",
58
+ "Verification",
59
+ "Independent review",
60
+ "Requirements and rules followed",
61
+ )
62
+ REQUIRED_REQUIREMENTS = (
63
+ "Scope and contract",
64
+ "Verification evidence",
65
+ "Independent review",
66
+ )
67
+ STANDARD_REQUIREMENT_DETAILS = {
68
+ "Scope and contract": (
69
+ "— accepted scope is implemented without unapproved expansion."
70
+ ),
71
+ "Verification evidence": (
72
+ "— required current profiles pass on the published checkpoint."
73
+ ),
74
+ "Independent review": (
75
+ "— a separate reviewer approved the published checkpoint with no open "
76
+ "required finding."
77
+ ),
78
+ }
79
+
80
+
81
+ def _normalized_markdown(text: str) -> str:
82
+ return text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
83
+
84
+
85
+ def _policy_key(text: str) -> str:
86
+ return "".join(
87
+ character
88
+ for character in normalized_rendered_inline_text(text)
89
+ if character.isalnum()
90
+ )
91
+
92
+
93
+ def validate_conventional_subject(subject: str, *, label: str) -> list[str]:
94
+ stripped = subject.strip()
95
+ if not stripped:
96
+ return [f"{label} is empty; use Conventional Commit style"]
97
+ issues: list[str] = []
98
+ if subject != stripped or CONVENTIONAL_SUBJECT_RE.fullmatch(stripped) is None:
99
+ issues.append(
100
+ f"{label} must use Conventional Commit style: "
101
+ "`type(scope): subject` or `type: subject`"
102
+ )
103
+ if len(stripped) > CONVENTIONAL_SUBJECT_MAX_LENGTH:
104
+ issues.append(
105
+ f"{label} must be at most {CONVENTIONAL_SUBJECT_MAX_LENGTH} characters "
106
+ f"({len(stripped)} found)"
107
+ )
108
+ if stripped.endswith("."):
109
+ issues.append(f"{label} must not end with a period")
110
+ return issues
111
+
112
+
113
+ def validate_commit_subject(subject: str) -> list[str]:
114
+ return validate_conventional_subject(subject, label="Commit subject")
115
+
116
+
117
+ def validate_pr_title(title: str) -> list[str]:
118
+ return validate_conventional_subject(title, label="PR title")
119
+
120
+
121
+ def is_automation_branch(branch: str) -> bool:
122
+ return AUTOMATION_BRANCH_RE.fullmatch(branch.strip()) is not None
123
+
124
+
125
+ def validate_branch(branch: str) -> list[str]:
126
+ stripped = branch.strip()
127
+ if not stripped:
128
+ return ["PR branch is empty"]
129
+ if branch != stripped:
130
+ return ["PR branch must not contain surrounding whitespace"]
131
+ if MANUAL_BRANCH_RE.fullmatch(stripped) or AUTOMATION_BRANCH_RE.fullmatch(stripped):
132
+ return []
133
+ return [
134
+ "PR branch must be `{type}/{kebab-description}` or "
135
+ "`automation/{owner}/{description}`"
136
+ ]
137
+
138
+
139
+ def _sections(body: str) -> tuple[dict[str, str], list[str]]:
140
+ matches = list(HEADING_RE.finditer(body))
141
+ result: dict[str, str] = {}
142
+ duplicates: list[str] = []
143
+ for index, match in enumerate(matches):
144
+ name = match.group(1).strip()
145
+ start = match.end()
146
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
147
+ if name in result:
148
+ duplicates.append(name)
149
+ result[name] = body[start:end]
150
+ return result, duplicates
151
+
152
+
153
+ def _visible(text: str) -> str:
154
+ return COMMENT_RE.sub("", text).strip()
155
+
156
+
157
+ def _managed_span(text: str) -> tuple[int, int]:
158
+ if text.count(PR_DESCRIPTION_START) != 1 or text.count(PR_DESCRIPTION_END) != 1:
159
+ raise ContractError(
160
+ "PR body must contain exactly one engineering-process managed block"
161
+ )
162
+ start_matches = list(
163
+ re.finditer(
164
+ rf"(?m)^{re.escape(PR_DESCRIPTION_START)}$",
165
+ text,
166
+ )
167
+ )
168
+ end_matches = list(
169
+ re.finditer(
170
+ rf"(?m)^{re.escape(PR_DESCRIPTION_END)}$",
171
+ text,
172
+ )
173
+ )
174
+ if len(start_matches) != 1 or len(end_matches) != 1:
175
+ raise ContractError("PR managed markers must each occupy their own line")
176
+ start = start_matches[0]
177
+ end = end_matches[0]
178
+ if start.end() >= end.start():
179
+ raise ContractError("PR managed block markers are out of order")
180
+ return start.start(), end.end()
181
+
182
+
183
+ def managed_pull_request_block(text: str) -> str:
184
+ normalized = _normalized_markdown(text)
185
+ start, end = _managed_span(normalized)
186
+ return normalized[start:end]
187
+
188
+
189
+ def merge_managed_pull_request_template(current: str, block: str) -> str:
190
+ canonical = managed_pull_request_block(block).strip()
191
+ current = _normalized_markdown(current)
192
+ if PR_DESCRIPTION_START not in current and PR_DESCRIPTION_END not in current:
193
+ if current.strip():
194
+ raise ContractError(
195
+ ".github/PULL_REQUEST_TEMPLATE.md: existing unmanaged template must "
196
+ "be migrated around the engineering-process managed block"
197
+ )
198
+ return canonical + "\n"
199
+ start, end = _managed_span(current)
200
+ prefix = current[:start].rstrip()
201
+ suffix = current[end:].strip()
202
+ parts = [part for part in (canonical, prefix, suffix) if part]
203
+ return "\n\n".join(parts) + "\n"
204
+
205
+
206
+ def _visible_managed_content(body: str) -> tuple[str | None, list[str]]:
207
+ try:
208
+ start, _ = _managed_span(body)
209
+ except ContractError as error:
210
+ return None, [str(error)]
211
+ if body[:start].strip():
212
+ return None, [
213
+ "PR managed block must be the first non-whitespace content; append project "
214
+ "extensions after it"
215
+ ]
216
+ if _START_TOKEN in body or _END_TOKEN in body:
217
+ return None, ["PR body contains reserved engineering-process marker text"]
218
+ visible = body.replace(PR_DESCRIPTION_START, _START_TOKEN).replace(
219
+ PR_DESCRIPTION_END, _END_TOKEN
220
+ )
221
+ visible, malformed_comments = strip_html_comments(visible)
222
+ if malformed_comments:
223
+ return None, ["PR body contains an unterminated or malformed HTML comment"]
224
+ if contains_raw_html(visible):
225
+ return None, ["PR body must not contain raw HTML"]
226
+ structural = mask_nonvisible_markdown_blocks(visible)
227
+ lines = structural.splitlines()
228
+ starts = [index for index, line in enumerate(lines) if line.strip() == _START_TOKEN]
229
+ ends = [index for index, line in enumerate(lines) if line.strip() == _END_TOKEN]
230
+ if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]:
231
+ return None, [
232
+ "PR managed block must be visible and must not be inside a code fence or comment"
233
+ ]
234
+ managed = "\n".join(lines[starts[0] + 1 : ends[0]])
235
+ if RAW_HTML_BLOCK_RE.search(managed):
236
+ return None, ["PR managed block must not contain raw HTML block elements"]
237
+ return managed, []
238
+
239
+
240
+ def managed_pull_request_visibility_issues(body: str) -> list[str]:
241
+ _, issues = _visible_managed_content(_normalized_markdown(body))
242
+ return issues
243
+
244
+
245
+ PROJECT_EXTENSION_HEADING = "## Project-specific requirements"
246
+
247
+
248
+ def validate_project_extensions(body: str, *, allow_pending: bool) -> list[str]:
249
+ body = _normalized_markdown(body)
250
+ _, end = _managed_span(body)
251
+ extension = body[end:]
252
+ if not extension.strip():
253
+ return []
254
+ if contains_raw_html(extension):
255
+ return ["PR extension must not contain raw HTML"]
256
+ lines = extension.strip().splitlines()
257
+ if not lines or lines[0] != PROJECT_EXTENSION_HEADING:
258
+ return [
259
+ "PR extension must use only the supported project-specific checklist grammar"
260
+ ]
261
+ issues: list[str] = []
262
+ labels: set[str] = set()
263
+ requirements = 0
264
+ for line in lines[1:]:
265
+ if not line:
266
+ continue
267
+ match = PROJECT_REQUIREMENT_RE.fullmatch(line)
268
+ if match is None:
269
+ issues.append(
270
+ "Unsupported PR extension content; use `- [ ] **Project-specific: "
271
+ "Label** — detail. [status: pending]`"
272
+ )
273
+ continue
274
+ requirements += 1
275
+ label = match.group("label").strip()
276
+ folded = label.casefold()
277
+ if folded in labels:
278
+ issues.append(f"Duplicate project-specific requirement: {label}")
279
+ labels.add(folded)
280
+ normalized_line = _policy_key(line)
281
+ if any(
282
+ _policy_key(required) in normalized_line
283
+ for required in REQUIRED_REQUIREMENTS
284
+ ):
285
+ issues.append(
286
+ "Project-specific requirement must not restate reserved core policy: "
287
+ + label
288
+ )
289
+ line_status = CHECKLIST_STATUS_RE.search(line)
290
+ if line_status is None:
291
+ issues.append(f"Project-specific requirement is missing a status: {line}")
292
+ continue
293
+ status = line_status.group("status").lower()
294
+ checked = match.group("state").lower() == "x"
295
+ if status not in CHECKLIST_STATUSES:
296
+ issues.append(
297
+ f"Project-specific requirement has invalid status `{status}`: {line}"
298
+ )
299
+ elif status == "pending":
300
+ if checked:
301
+ issues.append(
302
+ f"Pending project-specific requirement must be unchecked: {line}"
303
+ )
304
+ if not allow_pending:
305
+ issues.append(
306
+ "Pending project-specific requirement is not ready for publication: "
307
+ + line
308
+ )
309
+ elif not checked:
310
+ issues.append(
311
+ f"Resolved project-specific requirement must be checked: {line}"
312
+ )
313
+ if (
314
+ status == "not-applicable"
315
+ and CHECKLIST_REASON_RE.search(line) is None
316
+ ):
317
+ issues.append(
318
+ "Not-applicable project-specific requirement must include "
319
+ f"`[reason: ...]`: {line}"
320
+ )
321
+ if requirements == 0:
322
+ issues.append("PR extension must contain a project-specific checklist item")
323
+ return issues
324
+
325
+
326
+ def validate_pr_body(body: str, *, allow_pending: bool) -> list[str]:
327
+ body = _normalized_markdown(body)
328
+ if not _visible(body):
329
+ return ["PR body is empty; use the managed pull-request template"]
330
+ managed, marker_issues = _visible_managed_content(body)
331
+ if managed is None:
332
+ return marker_issues
333
+ headings = [match.group(1).strip() for match in HEADING_RE.finditer(managed)]
334
+ issues = list(marker_issues)
335
+ if tuple(headings) != REQUIRED_SECTIONS:
336
+ issues.append(
337
+ "Managed PR sections must exactly match the canonical order: "
338
+ + ", ".join(f"## {name}" for name in REQUIRED_SECTIONS)
339
+ )
340
+ sections, duplicates = _sections(managed)
341
+ issues.extend(
342
+ f"Duplicate section: ## {name}" for name in sorted(set(duplicates))
343
+ )
344
+ for required in REQUIRED_SECTIONS:
345
+ if required not in sections:
346
+ issues.append(f"Missing section: ## {required}")
347
+ elif required != "Requirements and rules followed" and not _visible(
348
+ sections[required]
349
+ ):
350
+ issues.append(f"Section must be filled in: ## {required}")
351
+
352
+ requirements = sections.get("Requirements and rules followed", "")
353
+ checkboxes = list(CHECKBOX_RE.finditer(requirements))
354
+ labels = [match.group("label").strip() for match in checkboxes]
355
+ if tuple(labels) != REQUIRED_REQUIREMENTS:
356
+ issues.append(
357
+ "Managed standard requirements must exactly match the canonical order: "
358
+ + ", ".join(REQUIRED_REQUIREMENTS)
359
+ )
360
+ for required in REQUIRED_REQUIREMENTS:
361
+ count = labels.count(required)
362
+ if count == 0:
363
+ issues.append(f"Missing standard requirement: {required}")
364
+ elif count > 1:
365
+ issues.append(f"Duplicate standard requirement: {required}")
366
+
367
+ for match in checkboxes:
368
+ line = match.group(0).strip()
369
+ label = match.group("label").strip()
370
+ checked = match.group("state").lower() == "x"
371
+ status_match = CHECKLIST_STATUS_RE.search(line)
372
+ if status_match is None:
373
+ issues.append(f"Requirement is missing a structured status: {line}")
374
+ continue
375
+ status = status_match.group("status").lower()
376
+ if status not in CHECKLIST_STATUSES:
377
+ issues.append(f"Requirement has invalid status `{status}`: {line}")
378
+ continue
379
+ detail = match.group("detail").strip()
380
+ detail_without_status = CHECKLIST_STATUS_RE.sub("", detail).strip()
381
+ reason_match = CHECKLIST_REASON_RE.search(detail_without_status)
382
+ if reason_match is not None:
383
+ if detail_without_status[reason_match.end() :].strip():
384
+ issues.append(f"Requirement reason must precede its status: {line}")
385
+ detail_without_status = (
386
+ detail_without_status[: reason_match.start()].strip()
387
+ )
388
+ expected_detail = STANDARD_REQUIREMENT_DETAILS.get(label)
389
+ if expected_detail is not None and detail_without_status != expected_detail:
390
+ issues.append(
391
+ f"Standard requirement text must remain canonical for {label}"
392
+ )
393
+ if status == "pending":
394
+ if checked:
395
+ issues.append(f"Pending requirement must be unchecked: {line}")
396
+ if not allow_pending:
397
+ issues.append(f"Pending requirement is not ready for publication: {line}")
398
+ elif not checked:
399
+ issues.append(f"Resolved requirement must be checked: {line}")
400
+ if status == "not-applicable" and reason_match is None:
401
+ issues.append(
402
+ f"Not-applicable requirement must include `[reason: ...]`: {line}"
403
+ )
404
+ issues.extend(validate_project_extensions(body, allow_pending=allow_pending))
405
+ return issues
406
+
407
+
408
+ def validate_pull_request(
409
+ *, title: str, body: str, branch: str, state: str
410
+ ) -> list[str]:
411
+ if state not in {"draft", "ready"}:
412
+ raise ContractError("pull-request state must be draft or ready")
413
+ allow_pending = state == "draft"
414
+ return [
415
+ *validate_branch(branch),
416
+ *validate_pr_title(title),
417
+ *validate_pr_body(body, allow_pending=allow_pending),
418
+ ]
419
+
420
+
421
+ def commit_subjects(project_root: Path, range_spec: str) -> list[tuple[str, str]]:
422
+ if GIT_RANGE_RE.fullmatch(range_spec) is None:
423
+ raise ContractError(f"invalid Git revision or range: {range_spec}")
424
+ try:
425
+ result = subprocess.run(
426
+ ["git", "log", "--format=%H%x00%s", range_spec, "--"],
427
+ cwd=project_root,
428
+ check=False,
429
+ stdin=subprocess.DEVNULL,
430
+ stdout=subprocess.PIPE,
431
+ stderr=subprocess.PIPE,
432
+ timeout=30,
433
+ )
434
+ except (OSError, subprocess.TimeoutExpired) as error:
435
+ raise ContractError(f"cannot inspect commit range {range_spec}: {error}") from error
436
+ if result.returncode != 0:
437
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
438
+ raise ContractError(
439
+ f"cannot inspect commit range {range_spec}"
440
+ + (f": {detail}" if detail else "")
441
+ )
442
+ records: list[tuple[str, str]] = []
443
+ for line in result.stdout.decode("utf-8", errors="replace").splitlines():
444
+ commit, separator, subject = line.partition("\0")
445
+ if not separator:
446
+ raise ContractError("git returned an invalid commit record")
447
+ records.append((commit, subject))
448
+ if not records:
449
+ raise ContractError(f"commit range {range_spec} is empty")
450
+ return records
451
+
452
+
453
+ def validate_commit_range(
454
+ project_root: Path, *, branch: str, range_spec: str
455
+ ) -> tuple[list[str], list[tuple[str, str]]]:
456
+ records = commit_subjects(project_root, range_spec)
457
+ issues = validate_branch(branch)
458
+ for commit, subject in records:
459
+ issues.extend(
460
+ f"Commit {commit[:12]}: {issue}"
461
+ for issue in validate_commit_subject(subject)
462
+ )
463
+ return issues, records
@@ -0,0 +1 @@
1
+ setuptools==84.0.0
@@ -0,0 +1,10 @@
1
+ attrs==26.1.0
2
+ build==1.5.0
3
+ colorama==0.4.6; os_name == "nt"
4
+ jsonschema==4.26.0
5
+ jsonschema-specifications==2025.9.1
6
+ packaging==26.3
7
+ pyproject_hooks==1.2.0
8
+ referencing==0.37.0
9
+ rpds-py==2026.6.3
10
+ typing-extensions==4.15.0; python_version < "3.13"
@@ -0,0 +1,3 @@
1
+ markdown-it-py==4.2.0
2
+ mdurl==0.1.2
3
+ regex==2026.7.19