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.
- boomtick_cli-0.2.1.dist-info/METADATA +20 -0
- boomtick_cli-0.2.1.dist-info/RECORD +103 -0
- boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
- boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
- boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
- dev_tools/__init__.py +1 -0
- dev_tools/cli-schema.json +2028 -0
- dev_tools/cli.py +1542 -0
- dev_tools/config.py +235 -0
- dev_tools/constants.py +14 -0
- dev_tools/daemon.py +146 -0
- dev_tools/dist/config.js +108 -0
- dev_tools/dist/index.js +10 -0
- dev_tools/dist/lib/error_utils.js +8 -0
- dev_tools/dist/lib/git.js +31 -0
- dev_tools/dist/lib/result.js +21 -0
- dev_tools/dist/lib/shell.js +91 -0
- dev_tools/dist/lib/shell.test.js +10 -0
- dev_tools/dist/lib/td-cli.js +27 -0
- dev_tools/dist/lib/test-utils.js +20 -0
- dev_tools/dist/mcp/definitions.js +242 -0
- dev_tools/dist/mcp/server.js +317 -0
- dev_tools/dist/mcp/tools.js +18 -0
- dev_tools/dist/tools/contract.js +2069 -0
- dev_tools/dist/tools/ddgs.search.js +50 -0
- dev_tools/dist/tools/ddgs.search.test.js +67 -0
- dev_tools/dist/tools/ddgs_search.py +23 -0
- dev_tools/dist/tools/github.checkout_branch.js +20 -0
- dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
- dev_tools/dist/tools/github.create_issue.js +28 -0
- dev_tools/dist/tools/github.create_issue.test.js +54 -0
- dev_tools/dist/tools/github.create_pull_request.js +44 -0
- dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
- dev_tools/dist/tools/github.get_pr.js +18 -0
- dev_tools/dist/tools/github.get_pr_diff.js +24 -0
- dev_tools/dist/tools/github.issue_comment.js +24 -0
- dev_tools/dist/tools/github.issue_update.js +29 -0
- dev_tools/dist/tools/github.issue_view.js +28 -0
- dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
- dev_tools/dist/tools/github.search_open_prs.js +31 -0
- dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
- dev_tools/dist/tools/jules/cancel-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.test.js +99 -0
- dev_tools/dist/tools/jules/get-messages.js +19 -0
- dev_tools/dist/tools/jules/get-messages.test.js +45 -0
- dev_tools/dist/tools/jules/get-pr.js +14 -0
- dev_tools/dist/tools/jules/get-pr.test.js +37 -0
- dev_tools/dist/tools/jules/get-session.js +10 -0
- dev_tools/dist/tools/jules/list-sessions.js +43 -0
- dev_tools/dist/tools/jules/send-message.js +22 -0
- dev_tools/dist/tools/jules/send-message.test.js +56 -0
- dev_tools/dist/tools/jules/shared.js +26 -0
- dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
- dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
- dev_tools/dist/tools/repo.commit_patch.js +33 -0
- dev_tools/dist/tools/repo.create_branch.js +21 -0
- dev_tools/dist/tools/repo.create_branch.test.js +42 -0
- dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
- dev_tools/dist/tools/repo.get_changed_files.js +21 -0
- dev_tools/dist/tools/repo.get_command_schema.js +27 -0
- dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
- dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
- dev_tools/dist/tools/repo.get_route_map.js +37 -0
- dev_tools/dist/tools/repo.logs.js +16 -0
- dev_tools/dist/tools/repo.read_agent_context.js +20 -0
- dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
- dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
- dev_tools/dist/tools/repo.run_playwright.js +29 -0
- dev_tools/dist/tools/repo.run_tests.js +43 -0
- dev_tools/dist/tools/types.js +1 -0
- dev_tools/get_ai_context.py +63 -0
- dev_tools/handlers/__init__.py +1 -0
- dev_tools/handlers/command_handler.py +69 -0
- dev_tools/mcp_server.py +36 -0
- dev_tools/models.py +354 -0
- dev_tools/orchestrator.py +2841 -0
- dev_tools/pr_overlap.py +139 -0
- dev_tools/resources/__init__.py +1 -0
- dev_tools/resources/build-repo-context.py +182 -0
- dev_tools/resources/prompt_constants.json +5 -0
- dev_tools/resources/review_template.md +41 -0
- dev_tools/resources/ux-audit.config.json +15 -0
- dev_tools/resources/visual_guidelines.json +3 -0
- dev_tools/review_read_pass.py +232 -0
- dev_tools/schema_gen.py +55 -0
- dev_tools/schema_utils.py +125 -0
- dev_tools/scope_check.py +85 -0
- dev_tools/services/__init__.py +1 -0
- dev_tools/services/ai_service.py +816 -0
- dev_tools/services/dependency_graph.py +123 -0
- dev_tools/services/github.py +783 -0
- dev_tools/services/jules.py +181 -0
- dev_tools/services/repair_service.py +199 -0
- dev_tools/services/vector_store.py +82 -0
- dev_tools/services/vision_service.py +91 -0
- dev_tools/td_cli.py +28 -0
- dev_tools/utils/__init__.py +1035 -0
- dev_tools/utils/git.py +68 -0
- dev_tools/ux_report.py +213 -0
- dev_tools/verify_infra.py +91 -0
- dev_tools/verify_versions.py +191 -0
- 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()
|