boomtick-cli 0.2.1__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 (103) hide show
  1. boomtick_cli-0.2.1.dist-info/METADATA +20 -0
  2. boomtick_cli-0.2.1.dist-info/RECORD +103 -0
  3. boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
  4. boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
  5. boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
  6. dev_tools/__init__.py +1 -0
  7. dev_tools/cli-schema.json +2028 -0
  8. dev_tools/cli.py +1542 -0
  9. dev_tools/config.py +235 -0
  10. dev_tools/constants.py +14 -0
  11. dev_tools/daemon.py +146 -0
  12. dev_tools/dist/config.js +108 -0
  13. dev_tools/dist/index.js +10 -0
  14. dev_tools/dist/lib/error_utils.js +8 -0
  15. dev_tools/dist/lib/git.js +31 -0
  16. dev_tools/dist/lib/result.js +21 -0
  17. dev_tools/dist/lib/shell.js +91 -0
  18. dev_tools/dist/lib/shell.test.js +10 -0
  19. dev_tools/dist/lib/td-cli.js +27 -0
  20. dev_tools/dist/lib/test-utils.js +20 -0
  21. dev_tools/dist/mcp/definitions.js +242 -0
  22. dev_tools/dist/mcp/server.js +317 -0
  23. dev_tools/dist/mcp/tools.js +18 -0
  24. dev_tools/dist/tools/contract.js +2069 -0
  25. dev_tools/dist/tools/ddgs.search.js +50 -0
  26. dev_tools/dist/tools/ddgs.search.test.js +67 -0
  27. dev_tools/dist/tools/ddgs_search.py +23 -0
  28. dev_tools/dist/tools/github.checkout_branch.js +20 -0
  29. dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
  30. dev_tools/dist/tools/github.create_issue.js +28 -0
  31. dev_tools/dist/tools/github.create_issue.test.js +54 -0
  32. dev_tools/dist/tools/github.create_pull_request.js +44 -0
  33. dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
  34. dev_tools/dist/tools/github.get_pr.js +18 -0
  35. dev_tools/dist/tools/github.get_pr_diff.js +24 -0
  36. dev_tools/dist/tools/github.issue_comment.js +24 -0
  37. dev_tools/dist/tools/github.issue_update.js +29 -0
  38. dev_tools/dist/tools/github.issue_view.js +28 -0
  39. dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
  40. dev_tools/dist/tools/github.search_open_prs.js +31 -0
  41. dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
  42. dev_tools/dist/tools/jules/cancel-session.js +20 -0
  43. dev_tools/dist/tools/jules/create-session.js +20 -0
  44. dev_tools/dist/tools/jules/create-session.test.js +99 -0
  45. dev_tools/dist/tools/jules/get-messages.js +19 -0
  46. dev_tools/dist/tools/jules/get-messages.test.js +45 -0
  47. dev_tools/dist/tools/jules/get-pr.js +14 -0
  48. dev_tools/dist/tools/jules/get-pr.test.js +37 -0
  49. dev_tools/dist/tools/jules/get-session.js +10 -0
  50. dev_tools/dist/tools/jules/list-sessions.js +43 -0
  51. dev_tools/dist/tools/jules/send-message.js +22 -0
  52. dev_tools/dist/tools/jules/send-message.test.js +56 -0
  53. dev_tools/dist/tools/jules/shared.js +26 -0
  54. dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
  55. dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
  56. dev_tools/dist/tools/repo.commit_patch.js +33 -0
  57. dev_tools/dist/tools/repo.create_branch.js +21 -0
  58. dev_tools/dist/tools/repo.create_branch.test.js +42 -0
  59. dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
  60. dev_tools/dist/tools/repo.get_changed_files.js +21 -0
  61. dev_tools/dist/tools/repo.get_command_schema.js +27 -0
  62. dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
  63. dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
  64. dev_tools/dist/tools/repo.get_route_map.js +37 -0
  65. dev_tools/dist/tools/repo.logs.js +16 -0
  66. dev_tools/dist/tools/repo.read_agent_context.js +20 -0
  67. dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
  68. dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
  69. dev_tools/dist/tools/repo.run_playwright.js +29 -0
  70. dev_tools/dist/tools/repo.run_tests.js +43 -0
  71. dev_tools/dist/tools/types.js +1 -0
  72. dev_tools/get_ai_context.py +63 -0
  73. dev_tools/handlers/__init__.py +1 -0
  74. dev_tools/handlers/command_handler.py +69 -0
  75. dev_tools/mcp_server.py +36 -0
  76. dev_tools/models.py +354 -0
  77. dev_tools/orchestrator.py +2841 -0
  78. dev_tools/pr_overlap.py +139 -0
  79. dev_tools/resources/__init__.py +1 -0
  80. dev_tools/resources/build-repo-context.py +182 -0
  81. dev_tools/resources/prompt_constants.json +5 -0
  82. dev_tools/resources/review_template.md +41 -0
  83. dev_tools/resources/ux-audit.config.json +15 -0
  84. dev_tools/resources/visual_guidelines.json +3 -0
  85. dev_tools/review_read_pass.py +232 -0
  86. dev_tools/schema_gen.py +55 -0
  87. dev_tools/schema_utils.py +125 -0
  88. dev_tools/scope_check.py +85 -0
  89. dev_tools/services/__init__.py +1 -0
  90. dev_tools/services/ai_service.py +816 -0
  91. dev_tools/services/dependency_graph.py +123 -0
  92. dev_tools/services/github.py +783 -0
  93. dev_tools/services/jules.py +181 -0
  94. dev_tools/services/repair_service.py +199 -0
  95. dev_tools/services/vector_store.py +82 -0
  96. dev_tools/services/vision_service.py +91 -0
  97. dev_tools/td_cli.py +28 -0
  98. dev_tools/utils/__init__.py +1035 -0
  99. dev_tools/utils/git.py +68 -0
  100. dev_tools/ux_report.py +213 -0
  101. dev_tools/verify_infra.py +91 -0
  102. dev_tools/verify_versions.py +191 -0
  103. dev_tools/version_utils.py +175 -0
dev_tools/utils/git.py ADDED
@@ -0,0 +1,68 @@
1
+ """Git utilities for secure branch operations."""
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from typing import Optional
6
+ from dev_tools.utils import CLIError, log_warn, mask_sensitive_data
7
+
8
+ class GitUtility: # pylint: disable=too-few-public-methods
9
+ """Utility class for Git operations."""
10
+
11
+ def __init__(self, token: Optional[str] = None, repo: Optional[str] = None):
12
+ self.token = token
13
+ self.repo = repo
14
+
15
+ def branch_exists_locally(self, branch: str) -> bool:
16
+ """Checks if a branch exists in the local repository."""
17
+ proc = subprocess.run(
18
+ ["git", "show-ref", "--verify", f"refs/heads/{branch}"],
19
+ capture_output=True,
20
+ text=True,
21
+ check=False
22
+ )
23
+ return proc.returncode == 0
24
+
25
+ def push_branch(self, branch: str) -> bool:
26
+ """Securely pushes a local branch to the remote origin."""
27
+ # Security: Validate branch name to prevent injection
28
+ if not re.match(r"^[a-zA-Z0-9._/-]+$", branch):
29
+ raise CLIError(f"Invalid branch name: {branch}", code=400)
30
+
31
+ try:
32
+ # Check if branch exists locally
33
+ if not self.branch_exists_locally(branch):
34
+ log_warn(f"Local branch '{branch}' not found. Cannot push.")
35
+ return False
36
+
37
+ log_warn(f"Pushing local branch '{branch}' to origin...")
38
+
39
+ # Security: Use environment variable for token to avoid leaking in process list
40
+ env = os.environ.copy()
41
+ if self.token and self.repo:
42
+ env["GIT_TOKEN_FOR_PUSH"] = self.token
43
+ # Use a credential helper to pass the token securely
44
+ cred_helper = "!f() { echo \"username=x-access-token\"; echo \"password=$GIT_TOKEN_FOR_PUSH\"; }; f"
45
+ push_url = f"https://github.com/{self.repo}.git"
46
+ push_args = ["git", "-c", f"credential.helper={cred_helper}", "push", "-u", push_url, branch]
47
+ else:
48
+ push_args = ["git", "push", "-u", "origin", branch]
49
+
50
+ # Capture output and handle error manually to avoid leaking sensitive info in exceptions
51
+ push_res = subprocess.run(
52
+ push_args,
53
+ capture_output=True,
54
+ text=True,
55
+ env=env,
56
+ check=False
57
+ )
58
+
59
+ if push_res.returncode != 0:
60
+ sanitized_stderr = mask_sensitive_data(push_res.stderr)
61
+ log_warn(f"Failed to push branch '{branch}': {sanitized_stderr}")
62
+ return False
63
+
64
+ return True
65
+
66
+ except (subprocess.SubprocessError, OSError) as e:
67
+ log_warn(f"Unexpected error during git push for branch '{branch}': {mask_sensitive_data(str(e))}")
68
+ return False
dev_tools/ux_report.py ADDED
@@ -0,0 +1,213 @@
1
+ # pylint: disable=expression-not-assigned,line-too-long,missing-docstring,too-many-branches,too-many-locals,too-many-statements
2
+ import glob
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+
7
+ from dev_tools.utils import log_info
8
+
9
+
10
+ def generate_report():
11
+ artifacts_dir = os.path.join(os.getcwd(), "artifacts", "ux-audit")
12
+ results_dir = os.path.join(artifacts_dir, "results")
13
+ lighthouse_dir = os.path.join(artifacts_dir, "lighthouse")
14
+ issues_dir = os.path.join(artifacts_dir, "issues")
15
+
16
+ os.makedirs(issues_dir, exist_ok=True)
17
+
18
+ report_lines = [
19
+ "# UX Audit Report",
20
+ f"Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n",
21
+ "## Summary",
22
+ ]
23
+
24
+ findings = []
25
+
26
+ result_files = glob.glob(os.path.join(results_dir, "*.json"))
27
+ report_lines.append(f"- Routes Audited: {len(result_files)}")
28
+
29
+ # Aggregate results
30
+ all_results = []
31
+ for rf in result_files:
32
+ with open(rf, "r", encoding="utf-8") as f:
33
+ all_results.append(json.load(f))
34
+
35
+ # Lighthouse summary
36
+ lh_files = glob.glob(os.path.join(lighthouse_dir, "*.report.json"))
37
+ if lh_files:
38
+ report_lines.append("\n## Lighthouse Scores")
39
+ report_lines.append("| Route | Performance | Accessibility | Best Practices | SEO |")
40
+ report_lines.append("|-------|-------------|---------------|----------------|-----|")
41
+ for lhf in lh_files:
42
+ with open(lhf, "r", encoding="utf-8") as f:
43
+ data = json.load(f)
44
+ cats = data.get("categories", {})
45
+ name = os.path.basename(lhf).replace(".report.json", "")
46
+ perf = int(cats.get("performance", {}).get("score", 0) * 100)
47
+ a11y = int(cats.get("accessibility", {}).get("score", 0) * 100)
48
+ bp = int(cats.get("best-practices", {}).get("score", 0) * 100)
49
+ seo = int(cats.get("seo", {}).get("score", 0) * 100)
50
+ report_lines.append(f"| {name} | {perf} | {a11y} | {bp} | {seo} |")
51
+
52
+ # High Severity Findings
53
+ report_lines.append("\n## Key Findings")
54
+
55
+ for res in all_results:
56
+ route = res["route"]
57
+ vp = res["viewport"]
58
+ screenshot = res.get("screenshot", "N/A")
59
+ "mobile" in vp.lower()
60
+
61
+ # 1. Accessibility (Axe)
62
+ if res.get("accessibility") and res["accessibility"].get("violations"):
63
+ violations = res["accessibility"]["violations"]
64
+ for v in violations:
65
+ finding = {
66
+ "title": f"Accessibility Violation: {v['id']} on `{route}` ({vp})",
67
+ "route": route,
68
+ "viewport": vp,
69
+ "category": "Accessibility",
70
+ "severity": "High" if v["impact"] in ["critical", "serious"] else "Medium",
71
+ "evidence": f"Axe violation: {v['description']}. Found on {len(v['nodes'])} elements.",
72
+ "recommendation": f"Fix {v['id']} issues. Help: {v['helpUrl']}",
73
+ "screenshot": screenshot,
74
+ "user_impact": "Users with disabilities may be unable to navigate or interact with the page effectively.",
75
+ "acceptance_criteria": [
76
+ "Lighthouse/Axe accessibility audit passes for this category",
77
+ "Element is keyboard accessible",
78
+ "Screen reader announcements are clear",
79
+ ],
80
+ }
81
+ findings.append(finding)
82
+
83
+ # 2. Layout Overflow
84
+ if res.get("overflow"):
85
+ finding = {
86
+ "title": f"Horizontal Overflow on `{route}` ({vp})",
87
+ "route": route,
88
+ "viewport": vp,
89
+ "category": "Layout",
90
+ "severity": "High",
91
+ "evidence": f"Detected {len(res['overflow'])} elements overflowing viewport.",
92
+ "recommendation": "Ensure all elements use responsive widths and handle long content with word-wrap or overflow-x: auto.",
93
+ "screenshot": screenshot,
94
+ "user_impact": "Causes janky scrolling and potential content cut-off.",
95
+ "acceptance_criteria": [
96
+ "No horizontal scrolling at tested width",
97
+ "All elements are contained within the viewport",
98
+ ],
99
+ }
100
+ findings.append(finding)
101
+
102
+ # 3. Small Tap Targets
103
+ if res.get("tapTargets"):
104
+ finding = {
105
+ "title": f"Small Tap Targets on `{route}` ({vp})",
106
+ "route": route,
107
+ "viewport": vp,
108
+ "category": "Mobile UX",
109
+ "severity": "Medium",
110
+ "evidence": f"Found {len(res['tapTargets'])} interactive elements smaller than 44x44px.",
111
+ "recommendation": "Increase padding or dimensions of interactive elements to at least 44x44px for better mobile usability.",
112
+ "screenshot": screenshot,
113
+ "user_impact": "Makes interactive elements difficult to tap on a phone, leading to user frustration.",
114
+ "acceptance_criteria": [
115
+ "All interactive elements meet the 44x44px minimum target size",
116
+ "Adequate spacing between adjacent links/buttons",
117
+ ],
118
+ }
119
+ findings.append(finding)
120
+
121
+ # 4. Image issues
122
+ oversized_images = [
123
+ img
124
+ for img in res.get("images", [])
125
+ if img["naturalWidth"] > img["renderedWidth"] * 2 and img["naturalWidth"] > 1000
126
+ ]
127
+ if oversized_images:
128
+ finding = {
129
+ "title": f"Oversized Images on `{route}` ({vp})",
130
+ "route": route,
131
+ "viewport": vp,
132
+ "category": "Performance",
133
+ "severity": "Medium",
134
+ "evidence": f"Found {len(oversized_images)} images where natural size is significantly larger than rendered size.",
135
+ "recommendation": "Use responsive image sets (srcset) or serve optimized crops for smaller viewports.",
136
+ "screenshot": screenshot,
137
+ "user_impact": "Increases page load time and consumes excessive bandwidth.",
138
+ "acceptance_criteria": [
139
+ "Images are appropriately sized for the viewport",
140
+ "Lighthouse performance score is maintained or improved",
141
+ ],
142
+ }
143
+ findings.append(finding)
144
+
145
+ # 5. Above the Fold heuristics
146
+ atf = res.get("aboveTheFold")
147
+ if atf:
148
+ if atf.get("heroViewportPercentage", 0) > 80 and not atf.get("hasPrimaryCTA"):
149
+ finding = {
150
+ "title": f"Poor Above-the-Fold Visibility on `{route}` ({vp})",
151
+ "route": route,
152
+ "viewport": vp,
153
+ "category": "UX",
154
+ "severity": "Medium",
155
+ "evidence": f"Hero occupies {int(atf['heroViewportPercentage'])}% of viewport and no CTA is visible.",
156
+ "recommendation": "Reduce hero height or move primary CTA higher to ensure it is visible above the fold.",
157
+ "screenshot": screenshot,
158
+ "user_impact": "Primary page purpose and next steps are unclear upon initial load.",
159
+ "acceptance_criteria": [
160
+ "Primary page purpose is clear above the fold",
161
+ "Primary CTA is visible without scrolling",
162
+ ],
163
+ }
164
+ findings.append(finding)
165
+
166
+ # List Findings in Report
167
+ if not findings:
168
+ report_lines.append("No major issues detected. ✅")
169
+ else:
170
+ # Sort findings by severity
171
+ severity_map = {"High": 0, "Medium": 1, "Low": 2}
172
+ findings.sort(key=lambda x: severity_map.get(x["severity"], 3))
173
+
174
+ for f in findings:
175
+ report_lines.append(f"### {f['title']}")
176
+ report_lines.append(f"- **Category:** {f['category']}")
177
+ report_lines.append(f"- **Severity:** {f['severity']}")
178
+ report_lines.append(f"- **Evidence:** {f['evidence']}")
179
+ report_lines.append(f"- **Screenshot:** `{f['screenshot']}`")
180
+ report_lines.append(f"- **Recommendation:** {f['recommendation']}\n")
181
+
182
+ # Generate Issue Draft
183
+ safe_title = (
184
+ f["title"].replace("/", "_").replace("`", "").replace("(", "").replace(")", "").replace(":", "")
185
+ )
186
+ issue_slug = f"{f['category']}-{safe_title}".lower().replace(" ", "-")
187
+ issue_path = os.path.join(issues_dir, f"{issue_slug}.md")
188
+ with open(issue_path, "w", encoding="utf-8") as issue_file:
189
+ issue_file.write(f"## Problem\n{f['title']}\n\n")
190
+ issue_file.write(f"## Route / viewport\n- Route: {f['route']}\n- Viewport: {f['viewport']}\n\n")
191
+ issue_file.write(f"## Evidence\n- {f['evidence']}\n- Screenshot: `{f['screenshot']}`\n\n")
192
+ issue_file.write(f"## User impact\n{f.get('user_impact', 'N/A')}\n\n")
193
+ issue_file.write(f"## Recommended fix\n{f['recommendation']}\n\n")
194
+ issue_file.write("## Acceptance criteria\n")
195
+ for ac in f.get("acceptance_criteria", []):
196
+ issue_file.write(f"- [ ] {ac}\n")
197
+
198
+ # Add cross-viewport regression check
199
+ other = "mobile" if "desktop" in f["viewport"].lower() else "desktop"
200
+ issue_file.write(f"- [ ] No new {other} regressions\n")
201
+
202
+ issue_file.write(f"\n## Severity\n{f['severity']}\n")
203
+
204
+ report_path = os.path.join(artifacts_dir, "ux-audit-report.md")
205
+ with open(report_path, "w", encoding="utf-8") as f:
206
+ f.write("\n".join(report_lines))
207
+
208
+ log_info(f"Report generated: {report_path}")
209
+ log_info(f"Issue drafts generated in: {issues_dir}")
210
+
211
+
212
+ if __name__ == "__main__":
213
+ generate_report()
@@ -0,0 +1,91 @@
1
+ # pylint: disable=missing-docstring,too-many-branches
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import sys
6
+
7
+
8
+ def check_shell_script(filepath):
9
+ """Performs static analysis on a shell script."""
10
+ findings = []
11
+
12
+ # 1. Syntax check (only for .sh files)
13
+ if filepath.endswith(".sh"):
14
+ try:
15
+ subprocess.run(["bash", "-n", filepath], check=True, capture_output=True, text=True)
16
+ except subprocess.CalledProcessError as e:
17
+ findings.append(f"Syntax error: {e.stderr.strip()}")
18
+ return findings
19
+
20
+ try:
21
+ with open(filepath, "r", encoding="utf-8") as f:
22
+ lines = f.readlines()
23
+ except Exception as e:
24
+ findings.append(f"Could not read file: {e}")
25
+ return findings
26
+
27
+ # 2. Missing error handling in scripts (check first 10 lines for set -e, set -u, set -o pipefail)
28
+ uncommented_first_lines = [re.sub(r"#.*", "", line) for line in lines[:10]]
29
+ settings_content = " ".join(uncommented_first_lines)
30
+
31
+ missing_settings = []
32
+ if not re.search(r"set\s+-[^ \n]*e", settings_content):
33
+ missing_settings.append("set -e (errexit)")
34
+ if not re.search(r"set\s+-[^ \n]*u", settings_content):
35
+ missing_settings.append("set -u (nounset)")
36
+ if "pipefail" not in settings_content:
37
+ missing_settings.append("set -o pipefail")
38
+
39
+ if missing_settings and len(lines) > 5:
40
+ findings.append(f"Script lacks recommended settings in first 10 lines: {', '.join(missing_settings)}")
41
+
42
+ # 3. Pattern checks
43
+ for i, line in enumerate(lines):
44
+ stripped = line.strip()
45
+ if not stripped or stripped.startswith("#"):
46
+ continue
47
+
48
+ # Hardcoded absolute paths (excluding common system ones)
49
+ if re.search(
50
+ r'(^|\s|["\'])/(?!(bin|usr/bin|dev|proc|tmp|etc|lib|var/lib|sys|usr/sbin|sbin|var/run))[a-zA-Z0-9]',
51
+ line,
52
+ ):
53
+ if not re.search(r"[0-9]\s*/\s*[0-9]", line): # Avoid flagging division
54
+ findings.append(f"Line {i+1}: Potential hardcoded absolute path: {stripped}")
55
+
56
+ # Unquoted variables in risky commands - SIMPLIFIED heuristic
57
+ # We flag cases where a variable expansion ($VAR or ${VAR}) is used in a risky command
58
+ # without any quotes on that line. This is a simple signal.
59
+ if any(cmd in stripped for cmd in ["rm ", "cp ", "mv ", "ls "]):
60
+ if "$" in stripped and not any(q in stripped for q in ['"', "'"]):
61
+ findings.append(f"Line {i+1}: Potential unquoted variable in risky command: {stripped}")
62
+
63
+ if len(lines) > 20:
64
+ findings.append("Note: For complex shell scripts, please run 'shellcheck' for deeper analysis.")
65
+
66
+ return findings
67
+
68
+
69
+ def main():
70
+ if len(sys.argv) < 2:
71
+ print("Usage: python3 verify_infra.py <filepath>")
72
+ sys.exit(1)
73
+
74
+ filepath = sys.argv[1]
75
+ if not os.path.exists(filepath):
76
+ print(f"File not found: {filepath}")
77
+ sys.exit(1)
78
+
79
+ findings = check_shell_script(filepath)
80
+ if findings:
81
+ print(f"Infrastructure violations found in {filepath}:")
82
+ for f in findings:
83
+ print(f"- {f}")
84
+ sys.exit(1)
85
+ else:
86
+ print(f"No infrastructure violations found in {filepath}.")
87
+ sys.exit(0)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
@@ -0,0 +1,191 @@
1
+ # pylint: disable=invalid-name,line-too-long,missing-docstring,no-else-break,no-value-for-parameter,too-many-branches,too-many-locals,too-many-statements
2
+ import json
3
+ import os
4
+ import re
5
+ import sys
6
+ from typing import Dict, List
7
+
8
+ import click
9
+ from dev_tools.utils import (
10
+ compare_versions,
11
+ fetch_latest_gh_action,
12
+ fetch_latest_node,
13
+ fetch_latest_npm,
14
+ get_stack_versions,
15
+ )
16
+
17
+
18
+ def parse_diff(diff_text: str) -> List[Dict]:
19
+ """Parses a git diff to find version changes."""
20
+ changes = []
21
+ current_file = None
22
+
23
+ # Regex patterns
24
+ ACTION_PATTERN = re.compile(r"uses:\s+([\w\-/]+)@([\w\.]+)")
25
+ PKG_JSON_VERSION_PATTERN = re.compile(r'"(node|pnpm|[\w\-\./@]+)":\s*"([\d\.\^x~<>=\| v]+)"')
26
+ PM_PATTERN = re.compile(r'"packageManager":\s*"pnpm@([\d\.]+)"')
27
+
28
+ # Files we care about
29
+ SENSITIVE_FILES = [".nvmrc", ".node-version", "package.json"]
30
+ SENSITIVE_DIRS = [".github/workflows/"]
31
+
32
+ hunks = re.split(r"^(?=--- )", diff_text, flags=re.MULTILINE)
33
+ for hunk in hunks:
34
+ if not hunk.strip():
35
+ continue
36
+
37
+ lines = hunk.splitlines()
38
+ current_file = None
39
+ for line in lines:
40
+ if line.startswith("--- a/"):
41
+ current_file = line[6:]
42
+ break
43
+ elif line.startswith("+++ b/"):
44
+ current_file = line[6:]
45
+ break
46
+
47
+ if not current_file:
48
+ continue
49
+
50
+ is_sensitive = current_file in SENSITIVE_FILES or any(current_file.startswith(sd) for sd in SENSITIVE_DIRS)
51
+ if not is_sensitive:
52
+ continue
53
+
54
+ removals = {} # name -> version
55
+ additions = {} # name -> version
56
+
57
+ for line in lines:
58
+ if line.startswith("--- ") or line.startswith("+++ ") or line.startswith("@@ "):
59
+ continue
60
+
61
+ if line.startswith("-"):
62
+ content = line[1:].strip()
63
+ # Check Actions
64
+ m = ACTION_PATTERN.search(content)
65
+ if m:
66
+ removals[m.group(1)] = m.group(2)
67
+ # Check Dependencies
68
+ m = PKG_JSON_VERSION_PATTERN.search(content)
69
+ if m:
70
+ removals[m.group(1)] = m.group(2)
71
+ # Check pnpm
72
+ m = PM_PATTERN.search(content)
73
+ if m:
74
+ removals["pnpm"] = m.group(1)
75
+ # Check node files
76
+ if current_file in [".nvmrc", ".node-version"]:
77
+ removals["node"] = content.replace("v", "")
78
+
79
+ elif line.startswith("+"):
80
+ content = line[1:].strip()
81
+ m = ACTION_PATTERN.search(content)
82
+ if m:
83
+ additions[m.group(1)] = m.group(2)
84
+ m = PKG_JSON_VERSION_PATTERN.search(content)
85
+ if m:
86
+ additions[m.group(1)] = m.group(2)
87
+ m = PM_PATTERN.search(content)
88
+ if m:
89
+ additions["pnpm"] = m.group(1)
90
+ if current_file in [".nvmrc", ".node-version"]:
91
+ additions["node"] = content.replace("v", "")
92
+
93
+ # Correlate changes
94
+ for name, new_v in additions.items():
95
+ old_v = removals.get(name, "unknown")
96
+ type_val = "action" if "/" in name and "pnpm" not in name else "dependency"
97
+ if name == "node" or current_file in [".nvmrc", ".node-version"]:
98
+ type_val = "runtime"
99
+
100
+ changes.append({"file": current_file, "type": type_val, "name": name, "old": old_v, "new": new_v})
101
+
102
+ return changes
103
+
104
+
105
+ def verify_changes(changes: List[Dict]) -> List[Dict]:
106
+ """Verifies changes against HEAD and registries."""
107
+ findings = []
108
+ stack = get_stack_versions()
109
+
110
+ for c in changes:
111
+ # 1. Compare against HEAD (Downgrade detection)
112
+ head_v = stack.get(c["name"])
113
+ if not head_v and c["name"] == "node":
114
+ head_v = stack.get("node")
115
+ if not head_v and c["name"] == "pnpm":
116
+ head_v = stack.get("pnpm")
117
+
118
+ if head_v:
119
+ if compare_versions(c["new"], head_v) < 0:
120
+ findings.append(
121
+ {
122
+ "severity": "error",
123
+ "file": c["file"],
124
+ "message": f"Version downgrade detected for {c['name']}: {head_v} -> {c['new']}",
125
+ "type": "downgrade",
126
+ }
127
+ )
128
+
129
+ # 2. Compare against Latest (Outdated detection - optional warning)
130
+ latest = None
131
+ if c["name"] == "node":
132
+ latest = fetch_latest_node()
133
+ elif c["type"] == "action":
134
+ latest = fetch_latest_gh_action(c["name"])
135
+ elif c["name"] in ["pnpm"] or c["type"] == "dependency":
136
+ latest = fetch_latest_npm(c["name"])
137
+
138
+ if latest:
139
+ if compare_versions(c["new"], latest) < 0:
140
+ findings.append(
141
+ {
142
+ "severity": "warn",
143
+ "file": c["file"],
144
+ "message": f"Proposed version for {c['name']} ({c['new']}) is outdated. Latest is {latest}.",
145
+ "type": "outdated",
146
+ }
147
+ )
148
+
149
+ # 3. Node.js Hard Block
150
+ if c["name"] == "node":
151
+ # Only trigger hard block if the version is ACTUALLY changing from HEAD
152
+ if head_v and compare_versions(c["new"], head_v) != 0:
153
+ if os.environ.get("ALLOW_NODE_VERSION_CHANGE") != "true":
154
+ findings.append(
155
+ {
156
+ "severity": "error",
157
+ "file": c["file"],
158
+ "message": f"Hard block: Node.js version modification detected ({head_v} -> {c['new']}). Modification is forbidden unless ALLOW_NODE_VERSION_CHANGE=true.",
159
+ "type": "hard_block",
160
+ }
161
+ )
162
+
163
+ return findings
164
+
165
+
166
+ @click.command()
167
+ @click.argument("diff_input")
168
+ def main(diff_input):
169
+ """Verifies version changes in a diff."""
170
+ if os.path.exists(diff_input):
171
+ with open(diff_input, "r", encoding="utf-8") as f:
172
+ diff_text = f.read()
173
+ else:
174
+ diff_text = diff_input
175
+
176
+ changes = parse_diff(diff_text)
177
+ findings = verify_changes(changes)
178
+
179
+ if findings:
180
+ print(json.dumps(findings, indent=2))
181
+ # Exit with error code if any 'error' severity exists
182
+ if any(f["severity"] == "error" for f in findings):
183
+ sys.exit(1)
184
+ else:
185
+ print(json.dumps([], indent=2))
186
+
187
+ sys.exit(0)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()