1212from awsclilinter .rules .binary_params_base64 import Base64BinaryFormatRule
1313from awsclilinter .rules .default_pager import DefaultPagerRule
1414from awsclilinter .rules .deploy_empty_changeset import DeployEmptyChangesetRule
15+ from awsclilinter .rules .ecr_get_login import EcrGetLoginRule
1516from awsclilinter .rules .hidden_aliases import create_all_hidden_alias_rules
1617from awsclilinter .rules .s3_copies import S3CopyRule
1718
1819# ANSI color codes
1920RED = "\033 [31m"
2021GREEN = "\033 [32m"
2122CYAN = "\033 [36m"
23+ YELLOW = "\033 [33m"
2224RESET = "\033 [0m"
2325
2426# The number of lines to show before an after a fix suggestion, for context within the script
2527CONTEXT_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- "\n Apply this fix? [y] yes, [n] no, [u] update all, [s] save and exit, [q] quit: "
33+ if auto_fixable :
34+ choice = (
35+ input (
36+ "\n Apply 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
4352def 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
91109def 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"\n Found { 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"\n Found { 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 \n Run with --fix to apply changes or --interactive to review each change." )
347+
348+ if fixable :
349+ print ("\n \n Run 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 \n All issues require manual review" )
288354
289355
290356if __name__ == "__main__" :
0 commit comments