Skip to content

Commit c7cbbe1

Browse files
authored
Merge pull request #9948 from aemous/manual-review-linting-rules
Add support for ECR GetLogin rule and manual-review rules
2 parents ceef794 + 33e490a commit c7cbbe1

File tree

13 files changed

+726
-83
lines changed

13 files changed

+726
-83
lines changed

awsclilinter/awsclilinter/cli.py

Lines changed: 131 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -12,80 +12,98 @@
1212
from awsclilinter.rules.binary_params_base64 import Base64BinaryFormatRule
1313
from awsclilinter.rules.default_pager import DefaultPagerRule
1414
from awsclilinter.rules.deploy_empty_changeset import DeployEmptyChangesetRule
15+
from awsclilinter.rules.ecr_get_login import EcrGetLoginRule
1516
from awsclilinter.rules.hidden_aliases import create_all_hidden_alias_rules
1617
from awsclilinter.rules.s3_copies import S3CopyRule
1718

1819
# ANSI color codes
1920
RED = "\033[31m"
2021
GREEN = "\033[32m"
2122
CYAN = "\033[36m"
23+
YELLOW = "\033[33m"
2224
RESET = "\033[0m"
2325

2426
# The number of lines to show before an after a fix suggestion, for context within the script
2527
CONTEXT_SIZE = 3
2628

2729

28-
def prompt_user_choice_interactive_mode() -> str:
30+
def prompt_user_choice_interactive_mode(auto_fixable: bool = True) -> str:
2931
"""Get user input for interactive mode."""
3032
while True:
31-
choice = (
32-
input(
33-
"\nApply this fix? [y] yes, [n] no, [u] update all, [s] save and exit, [q] quit: "
33+
if auto_fixable:
34+
choice = (
35+
input(
36+
"\nApply this fix? [y] yes, [n] no, "
37+
"[u] update all, [s] save and exit, [q] quit: "
38+
)
39+
.lower()
40+
.strip()
3441
)
35-
.lower()
36-
.strip()
37-
)
38-
if choice in ["y", "n", "u", "s", "q"]:
39-
return choice
40-
print("Invalid choice. Please enter y, n, u, s, or q.")
42+
if choice in ["y", "n", "u", "s", "q"]:
43+
return choice
44+
print("Invalid choice. Please enter y, n, u, s, or q.")
45+
else:
46+
choice = input("\n[n] next, [s] save, [q] quit: ").lower().strip()
47+
if choice in ["n", "s", "q"]:
48+
return choice
49+
print("Invalid choice. Please enter n, s, or q.")
4150

4251

4352
def display_finding(finding: LintFinding, index: int, script_content: str):
4453
"""Display a finding to the user with context."""
45-
src_lines = script_content.splitlines(keepends=True)
46-
47-
# Apply the edit to get the fixed content
48-
fixed_content = (
49-
script_content[: finding.edit.start_pos]
50-
+ finding.edit.inserted_text
51-
+ script_content[finding.edit.end_pos :]
52-
)
53-
dest_lines = fixed_content.splitlines(keepends=True)
54-
55-
start_line = finding.line_start
56-
end_line = finding.line_end
57-
context_start = max(0, start_line - CONTEXT_SIZE)
58-
context_end = min(len(src_lines), end_line + CONTEXT_SIZE + 1)
59-
60-
src_context = src_lines[context_start:context_end]
61-
dest_context = dest_lines[context_start:context_end]
62-
63-
if len(src_context) != len(dest_context):
64-
raise RuntimeError(
65-
f"Original and new context lengths must be equal. "
66-
f"{len(src_context)} != {len(dest_context)}."
54+
if finding.auto_fixable:
55+
# Apply the edit to get the fixed content
56+
fixed_content = (
57+
script_content[: finding.edit.start_pos]
58+
+ finding.edit.inserted_text
59+
+ script_content[finding.edit.end_pos :]
6760
)
6861

69-
print(f"\n[{index}] {finding.rule_name}")
70-
print(f"{finding.description}")
62+
print(f"\n[{index}] {finding.rule_name}")
63+
print(f"{finding.description}")
7164

72-
diff = difflib.unified_diff(src_context, dest_context, lineterm="")
73-
for line_num, line in enumerate(diff):
74-
if line_num < 2:
75-
# First 2 lines are the --- and +++ lines, we don't print those.
76-
continue
77-
elif line_num == 2:
78-
# The 3rd line is the context control line.
79-
print(f"\n{CYAN}{line}{RESET}")
80-
elif line.startswith("-"):
81-
# Removed line
82-
print(f"{RED}{line}{RESET}\n", end="")
83-
elif line.startswith("+"):
84-
# Added line
85-
print(f"{GREEN}{line}{RESET}", end="")
86-
else:
87-
# Context (unchanged) lines always start with whitespace.
88-
print(line, end="")
65+
diff = difflib.unified_diff(
66+
script_content.splitlines(), fixed_content.splitlines(), n=CONTEXT_SIZE, lineterm=""
67+
)
68+
for line_num, line in enumerate(diff):
69+
if line_num < 2:
70+
# First 2 lines are the --- and +++ lines, we don't print those.
71+
continue
72+
elif line_num == 2:
73+
# The 3rd line is the context control line.
74+
print(f"\n{CYAN}{line}{RESET}")
75+
elif line.startswith("-"):
76+
# Removed line
77+
print(f"{RED}{line}{RESET}")
78+
elif line.startswith("+"):
79+
# Added line
80+
print(f"{GREEN}{line}{RESET}")
81+
else:
82+
# Context (unchanged) lines always start with whitespace.
83+
print(line)
84+
else:
85+
# Non-fixable issue - show only the problematic lines
86+
src_lines = script_content.splitlines()
87+
start_line = finding.line_start
88+
end_line = finding.line_end
89+
context_start = max(0, start_line - CONTEXT_SIZE)
90+
context_end = min(len(src_lines), end_line + CONTEXT_SIZE + 1)
91+
92+
print(f"\n[{index}] {finding.rule_name} {YELLOW}[MANUAL REVIEW REQUIRED]{RESET}")
93+
print(f"{finding.description}")
94+
95+
print(f"\n{CYAN}Lines {context_start + 1}-{context_end + 1}{RESET}")
96+
for i in range(context_start, context_end):
97+
line = src_lines[i]
98+
if start_line <= i <= end_line:
99+
print(f"{YELLOW}{line}{RESET}")
100+
else:
101+
print(f"{line}")
102+
103+
print(
104+
f"\n{YELLOW}⚠️ This issue requires manual intervention. "
105+
f"Suggested action: {finding.suggested_manual_fix}{RESET}"
106+
)
89107

90108

91109
def apply_all_fixes(
@@ -95,7 +113,7 @@ def apply_all_fixes(
95113
"""Apply all fixes using rule-by-rule processing.
96114
97115
Since multiple rules can target the same command, we must process one rule
98-
at a time and re-parse the updated script between rules to get fresh Edit objects.
116+
at a time and reparse the updated script between rules to get fresh Edit objects.
99117
100118
Args:
101119
findings_with_rules: List of findings and their rules.
@@ -106,12 +124,13 @@ def apply_all_fixes(
106124
"""
107125
current_ast = ast
108126

109-
# Group findings by rule
127+
# Group fixable findings by rule
110128
findings_by_rule: Dict[str, List[LintFinding]] = {}
111129
for finding, rule in findings_with_rules:
112-
if rule.name not in findings_by_rule:
113-
findings_by_rule[rule.name] = []
114-
findings_by_rule[rule.name].append(finding)
130+
if finding.auto_fixable:
131+
if rule.name not in findings_by_rule:
132+
findings_by_rule[rule.name] = []
133+
findings_by_rule[rule.name].append(finding)
115134

116135
# Process one rule at a time, re-parsing between rules
117136
for rule in findings_by_rule:
@@ -143,15 +162,33 @@ def interactive_mode_for_rule(
143162

144163
for i, finding in enumerate(findings):
145164
display_finding(finding, finding_offset + i + 1, ast.root().text())
146-
last_choice = prompt_user_choice_interactive_mode()
165+
166+
if not finding.auto_fixable:
167+
# Non-fixable finding - only allow next, save, or quit
168+
last_choice = prompt_user_choice_interactive_mode(auto_fixable=False)
169+
if last_choice == "q":
170+
print("Quit without saving.")
171+
sys.exit(0)
172+
elif last_choice == "s":
173+
# Save and exit - apply accepted findings before returning
174+
if accepted_findings:
175+
ast = parse(linter.apply_fixes(ast, accepted_findings))
176+
return ast, len(accepted_findings) > 0, last_choice
177+
# 'n' means continue to next finding
178+
continue
179+
180+
last_choice = prompt_user_choice_interactive_mode(auto_fixable=True)
147181

148182
if last_choice == "y":
149183
accepted_findings.append(finding)
150184
elif last_choice == "n":
151185
pass # Skip this finding
152186
elif last_choice == "u":
153-
# Accept this and all remaining findings for this rule.
154-
accepted_findings.extend(findings[i:])
187+
# Accept this and all remaining fixable findings for this rule.
188+
accepted_findings.append(finding)
189+
for remaining_finding in findings[i + 1 :]:
190+
if remaining_finding.auto_fixable:
191+
accepted_findings.append(remaining_finding)
155192
if accepted_findings:
156193
ast = parse(linter.apply_fixes(ast, accepted_findings))
157194
return ast, True, last_choice
@@ -211,6 +248,8 @@ def main():
211248
S3CopyRule(),
212249
DeployEmptyChangesetRule(),
213250
*create_all_hidden_alias_rules(),
251+
# Rules that do not automatically generate fixes go last
252+
EcrGetLoginRule(),
214253
]
215254

216255
if args.interactive:
@@ -269,10 +308,23 @@ def main():
269308
elif args.fix or args.output:
270309
current_ast = parse(script_content)
271310
findings_with_rules = linter.lint(current_ast, rules)
272-
updated_script = apply_all_fixes(findings_with_rules, current_ast)
273-
output_path = Path(args.output) if args.output else script_path
274-
output_path.write_text(updated_script)
275-
print(f"Modified script written to: {output_path}")
311+
312+
fixable = [(f, r) for f, r in findings_with_rules if f.auto_fixable]
313+
non_fixable = [(f, r) for f, r in findings_with_rules if not f.auto_fixable]
314+
315+
if fixable:
316+
updated_script = apply_all_fixes(fixable, current_ast)
317+
output_path = Path(args.output) if args.output else script_path
318+
output_path.write_text(updated_script)
319+
print(f"Modified script written to: {output_path}")
320+
print(f"Applied {len(fixable)} fix(es) automatically.")
321+
else:
322+
print("No automatic fixes available.")
323+
324+
if non_fixable:
325+
print(f"\n{YELLOW}⚠️ {len(non_fixable)} issue(s) require manual review:{RESET}\n")
326+
for i, (finding, _) in enumerate(non_fixable, 1):
327+
display_finding(finding, i, script_content)
276328
else:
277329
current_ast = parse(script_content)
278330
findings_with_rules = linter.lint(current_ast, rules)
@@ -281,10 +333,24 @@ def main():
281333
print("No issues found.")
282334
return
283335

284-
print(f"\nFound {len(findings_with_rules)} issue(s):\n")
336+
fixable = [(f, r) for f, r in findings_with_rules if f.auto_fixable]
337+
non_fixable = [(f, r) for f, r in findings_with_rules if not f.auto_fixable]
338+
339+
print(f"\nFound {len(findings_with_rules)} issue(s):")
340+
if fixable and non_fixable:
341+
print(f" - {len(fixable)} can be automatically fixed")
342+
print(f" - {len(non_fixable)} require manual review")
343+
print()
344+
285345
for i, (finding, _) in enumerate(findings_with_rules, 1):
286346
display_finding(finding, i, script_content)
287-
print("\n\nRun with --fix to apply changes or --interactive to review each change.")
347+
348+
if fixable:
349+
print("\n\nRun with --fix to apply automatic fixes")
350+
if non_fixable:
351+
print("Non-fixable issues will be shown for manual review")
352+
else:
353+
print("\n\nAll issues require manual review")
288354

289355

290356
if __name__ == "__main__":

awsclilinter/awsclilinter/linter.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ def lint(ast: SgRoot, rules: List[LintRule]) -> List[Tuple[LintFinding, LintRule
1717
findings = rule.check(ast)
1818
for finding in findings:
1919
findings_with_rules.append((finding, rule))
20-
return sorted(findings_with_rules, key=lambda fr: (fr[0].edit.start_pos, fr[0].edit.end_pos))
20+
return sorted(
21+
findings_with_rules,
22+
key=lambda fr: (
23+
fr[0].edit.start_pos if fr[0].edit else fr[0].line_start,
24+
fr[0].edit.end_pos if fr[0].edit else fr[0].line_end,
25+
),
26+
)
2127

2228

2329
def lint_for_rule(ast: SgRoot, rule: LintRule) -> List[LintFinding]:
@@ -31,7 +37,13 @@ def lint_for_rule(ast: SgRoot, rule: LintRule) -> List[LintFinding]:
3137
List of findings for this rule, sorted by position (ascending)
3238
"""
3339
findings = rule.check(ast)
34-
return sorted(findings, key=lambda f: (f.edit.start_pos, f.edit.end_pos))
40+
return sorted(
41+
findings,
42+
key=lambda f: (
43+
f.edit.start_pos if f.edit else f.line_start,
44+
f.edit.end_pos if f.edit else f.line_end,
45+
),
46+
)
3547

3648

3749
def apply_fixes(ast: SgRoot, findings: List[LintFinding]) -> str:
@@ -49,5 +61,8 @@ def apply_fixes(ast: SgRoot, findings: List[LintFinding]) -> str:
4961
return root.text()
5062

5163
# Collect all edits - they should be non-overlapping within a single rule
52-
edits = [f.edit for f in findings]
64+
# Filter out non-fixable findings
65+
edits = [f.edit for f in findings if f.auto_fixable]
66+
if not edits:
67+
return root.text()
5368
return root.commit_edits(edits)

awsclilinter/awsclilinter/rules/base.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from abc import ABC, abstractmethod
22
from dataclasses import dataclass
3-
from typing import List
3+
from typing import List, Optional
44

55
from ast_grep_py.ast_grep_py import Edit, SgRoot
66

@@ -11,10 +11,23 @@ class LintFinding:
1111

1212
line_start: int
1313
line_end: int
14-
edit: Edit
14+
edit: Optional[Edit]
1515
original_text: str
1616
rule_name: str
1717
description: str
18+
suggested_manual_fix: Optional[str] = None
19+
20+
def __post_init__(self):
21+
if self.edit is None and self.suggested_manual_fix is None:
22+
raise ValueError(
23+
f"suggested_manual_fix must be provided "
24+
f"when edit is None for rule {self.rule_name}."
25+
)
26+
27+
@property
28+
def auto_fixable(self) -> bool:
29+
"""Return True if this finding can be automatically fixed."""
30+
return self.edit is not None
1831

1932

2033
class LintRule(ABC):

awsclilinter/awsclilinter/rules/binary_params_base64.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,26 @@ def check(self, root: SgRoot) -> List[LintFinding]:
2828
base64_broken_nodes = node.find_all(
2929
all=[ # type: ignore[arg-type]
3030
{"kind": "command"},
31-
{"pattern": "aws $SERVICE $OPERATION"},
31+
{
32+
"has": {
33+
"kind": "command_name",
34+
"has": {
35+
"kind": "word",
36+
"pattern": "aws",
37+
},
38+
}
39+
},
3240
{"not": {"has": {"kind": "word", "pattern": "--cli-binary-format"}}},
41+
# Command is not ecr-get-login, since it was removed in AWS CLI v2, and we don't
42+
# want to add v2-specific arguments to commands that don't exist in AWS CLI v2.
43+
{
44+
"not": {
45+
"all": [
46+
{"has": {"kind": "word", "pattern": "ecr"}},
47+
{"has": {"kind": "word", "pattern": "get-login"}},
48+
]
49+
}
50+
},
3351
]
3452
)
3553

0 commit comments

Comments
 (0)