ara-cli 0.1.9.69__py3-none-any.whl → 0.1.10.8__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.

Potentially problematic release.


This version of ara-cli might be problematic. Click here for more details.

Files changed (150) hide show
  1. ara_cli/__init__.py +18 -2
  2. ara_cli/__main__.py +248 -62
  3. ara_cli/ara_command_action.py +155 -86
  4. ara_cli/ara_config.py +226 -80
  5. ara_cli/ara_subcommands/__init__.py +0 -0
  6. ara_cli/ara_subcommands/autofix.py +26 -0
  7. ara_cli/ara_subcommands/chat.py +27 -0
  8. ara_cli/ara_subcommands/classifier_directory.py +16 -0
  9. ara_cli/ara_subcommands/common.py +100 -0
  10. ara_cli/ara_subcommands/create.py +75 -0
  11. ara_cli/ara_subcommands/delete.py +22 -0
  12. ara_cli/ara_subcommands/extract.py +22 -0
  13. ara_cli/ara_subcommands/fetch_templates.py +14 -0
  14. ara_cli/ara_subcommands/list.py +65 -0
  15. ara_cli/ara_subcommands/list_tags.py +25 -0
  16. ara_cli/ara_subcommands/load.py +48 -0
  17. ara_cli/ara_subcommands/prompt.py +136 -0
  18. ara_cli/ara_subcommands/read.py +47 -0
  19. ara_cli/ara_subcommands/read_status.py +20 -0
  20. ara_cli/ara_subcommands/read_user.py +20 -0
  21. ara_cli/ara_subcommands/reconnect.py +27 -0
  22. ara_cli/ara_subcommands/rename.py +22 -0
  23. ara_cli/ara_subcommands/scan.py +14 -0
  24. ara_cli/ara_subcommands/set_status.py +22 -0
  25. ara_cli/ara_subcommands/set_user.py +22 -0
  26. ara_cli/ara_subcommands/template.py +16 -0
  27. ara_cli/artefact_autofix.py +649 -68
  28. ara_cli/artefact_creator.py +8 -11
  29. ara_cli/artefact_deleter.py +2 -4
  30. ara_cli/artefact_fuzzy_search.py +22 -10
  31. ara_cli/artefact_link_updater.py +4 -4
  32. ara_cli/artefact_lister.py +29 -55
  33. ara_cli/artefact_models/artefact_data_retrieval.py +23 -0
  34. ara_cli/artefact_models/artefact_load.py +11 -3
  35. ara_cli/artefact_models/artefact_model.py +146 -39
  36. ara_cli/artefact_models/artefact_templates.py +70 -44
  37. ara_cli/artefact_models/businessgoal_artefact_model.py +23 -25
  38. ara_cli/artefact_models/epic_artefact_model.py +34 -26
  39. ara_cli/artefact_models/feature_artefact_model.py +203 -64
  40. ara_cli/artefact_models/keyfeature_artefact_model.py +21 -24
  41. ara_cli/artefact_models/serialize_helper.py +1 -1
  42. ara_cli/artefact_models/task_artefact_model.py +83 -15
  43. ara_cli/artefact_models/userstory_artefact_model.py +37 -27
  44. ara_cli/artefact_models/vision_artefact_model.py +23 -42
  45. ara_cli/artefact_reader.py +92 -91
  46. ara_cli/artefact_renamer.py +8 -4
  47. ara_cli/artefact_scan.py +66 -3
  48. ara_cli/chat.py +622 -162
  49. ara_cli/chat_agent/__init__.py +0 -0
  50. ara_cli/chat_agent/agent_communicator.py +62 -0
  51. ara_cli/chat_agent/agent_process_manager.py +211 -0
  52. ara_cli/chat_agent/agent_status_manager.py +73 -0
  53. ara_cli/chat_agent/agent_workspace_manager.py +76 -0
  54. ara_cli/commands/__init__.py +0 -0
  55. ara_cli/commands/command.py +7 -0
  56. ara_cli/commands/extract_command.py +15 -0
  57. ara_cli/commands/load_command.py +65 -0
  58. ara_cli/commands/load_image_command.py +34 -0
  59. ara_cli/commands/read_command.py +117 -0
  60. ara_cli/completers.py +144 -0
  61. ara_cli/directory_navigator.py +37 -4
  62. ara_cli/error_handler.py +134 -0
  63. ara_cli/file_classifier.py +6 -5
  64. ara_cli/file_lister.py +1 -1
  65. ara_cli/file_loaders/__init__.py +0 -0
  66. ara_cli/file_loaders/binary_file_loader.py +33 -0
  67. ara_cli/file_loaders/document_file_loader.py +34 -0
  68. ara_cli/file_loaders/document_reader.py +245 -0
  69. ara_cli/file_loaders/document_readers.py +233 -0
  70. ara_cli/file_loaders/file_loader.py +50 -0
  71. ara_cli/file_loaders/file_loaders.py +123 -0
  72. ara_cli/file_loaders/image_processor.py +89 -0
  73. ara_cli/file_loaders/markdown_reader.py +75 -0
  74. ara_cli/file_loaders/text_file_loader.py +187 -0
  75. ara_cli/global_file_lister.py +51 -0
  76. ara_cli/list_filter.py +1 -1
  77. ara_cli/output_suppressor.py +1 -1
  78. ara_cli/prompt_extractor.py +215 -88
  79. ara_cli/prompt_handler.py +521 -134
  80. ara_cli/prompt_rag.py +2 -2
  81. ara_cli/tag_extractor.py +83 -38
  82. ara_cli/template_loader.py +245 -0
  83. ara_cli/template_manager.py +18 -13
  84. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  85. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  86. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  87. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  88. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  89. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  90. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  91. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  92. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  93. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  94. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  95. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  96. ara_cli/update_config_prompt.py +9 -3
  97. ara_cli/version.py +1 -1
  98. ara_cli-0.1.10.8.dist-info/METADATA +241 -0
  99. ara_cli-0.1.10.8.dist-info/RECORD +193 -0
  100. tests/test_ara_command_action.py +73 -59
  101. tests/test_ara_config.py +341 -36
  102. tests/test_artefact_autofix.py +1060 -0
  103. tests/test_artefact_link_updater.py +3 -3
  104. tests/test_artefact_lister.py +52 -132
  105. tests/test_artefact_renamer.py +2 -2
  106. tests/test_artefact_scan.py +327 -33
  107. tests/test_chat.py +2063 -498
  108. tests/test_file_classifier.py +24 -1
  109. tests/test_file_creator.py +3 -5
  110. tests/test_file_lister.py +1 -1
  111. tests/test_global_file_lister.py +131 -0
  112. tests/test_list_filter.py +2 -2
  113. tests/test_prompt_handler.py +746 -0
  114. tests/test_tag_extractor.py +19 -13
  115. tests/test_template_loader.py +192 -0
  116. tests/test_template_manager.py +5 -4
  117. tests/test_update_config_prompt.py +2 -2
  118. ara_cli/ara_command_parser.py +0 -327
  119. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  120. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  121. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  122. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  123. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  124. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  125. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  126. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  127. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  128. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  129. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  130. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  131. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  132. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  133. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  134. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  135. ara_cli/templates/template.businessgoal +0 -10
  136. ara_cli/templates/template.capability +0 -10
  137. ara_cli/templates/template.epic +0 -15
  138. ara_cli/templates/template.example +0 -6
  139. ara_cli/templates/template.feature +0 -26
  140. ara_cli/templates/template.issue +0 -14
  141. ara_cli/templates/template.keyfeature +0 -15
  142. ara_cli/templates/template.task +0 -6
  143. ara_cli/templates/template.userstory +0 -17
  144. ara_cli/templates/template.vision +0 -14
  145. ara_cli-0.1.9.69.dist-info/METADATA +0 -16
  146. ara_cli-0.1.9.69.dist-info/RECORD +0 -158
  147. tests/test_ara_autofix.py +0 -219
  148. {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/WHEEL +0 -0
  149. {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/entry_points.txt +0 -0
  150. {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,27 @@
1
+ from ara_cli.error_handler import AraError
2
+ from ara_cli.artefact_scan import check_file
3
+ from ara_cli.artefact_fuzzy_search import (
4
+ find_closest_name_matches,
5
+ extract_artefact_names_of_classifier,
6
+ )
7
+ from ara_cli.file_classifier import FileClassifier
8
+ from ara_cli.artefact_reader import ArtefactReader
9
+ from ara_cli.artefact_models.artefact_load import artefact_from_content
10
+ from ara_cli.artefact_models.artefact_model import Artefact
11
+ from typing import Optional, Dict, List, Tuple
12
+ import difflib
1
13
  import os
2
- from typing import Dict, List, Tuple
14
+ import re
15
+
16
+
17
+ def populate_classified_artefact_info(
18
+ classified_artefact_info: Optional[dict], force: bool = False
19
+ ):
20
+ if not classified_artefact_info or force:
21
+ file_classifier = FileClassifier(os)
22
+ classified_artefact_info = file_classifier.classify_files()
23
+ return classified_artefact_info
24
+
3
25
 
4
26
  def read_report_file():
5
27
  file_path = "incompatible_artefacts_report.md"
@@ -7,7 +29,9 @@ def read_report_file():
7
29
  with open(file_path, "r", encoding="utf-8") as f:
8
30
  content = f.read()
9
31
  except OSError:
10
- print('Artefact scan results file not found. Did you run the "ara scan" command?')
32
+ print(
33
+ 'Artefact scan results file not found. Did you run the "ara scan" command?'
34
+ )
11
35
  return None
12
36
  return content
13
37
 
@@ -17,41 +41,53 @@ def parse_report(content: str) -> Dict[str, List[Tuple[str, str]]]:
17
41
  Parses the incompatible artefacts report and returns structured data.
18
42
  Returns a dictionary where keys are artefact classifiers, and values are lists of (file_path, reason) tuples.
19
43
  """
20
- lines = content.splitlines()
21
- issues = {}
22
- current_classifier = None
23
-
24
- if not lines or lines[0] != "# Artefact Check Report":
25
- return issues
26
44
 
27
- if len(lines) >= 3 and lines[2] == "No problems found.":
28
- return issues
45
+ def is_valid_report(lines: List[str]) -> bool:
46
+ return bool(lines) and lines[0] == "# Artefact Check Report"
29
47
 
30
- for line in lines[1:]:
31
- line = line.strip()
32
- if not line:
33
- continue
48
+ def has_no_problems(lines: List[str]) -> bool:
49
+ return len(lines) >= 3 and lines[2] == "No problems found."
34
50
 
51
+ def parse_classifier(line: str) -> Optional[str]:
35
52
  if line.startswith("## "):
36
- current_classifier = line[3:].strip()
37
- issues[current_classifier] = []
53
+ return line[3:].strip()
54
+ return None
38
55
 
39
- elif line.startswith("- ") and current_classifier is not None:
40
- parts = line.split("`", 2)
41
- if len(parts) < 3:
42
- continue
56
+ def parse_issue(line: str) -> Optional[Tuple[str, str]]:
57
+ if not line.startswith("- "):
58
+ return None
59
+ parts = line.split("`", 2)
60
+ if len(parts) < 3:
61
+ return None
62
+ file_path = parts[1]
63
+ reason = parts[2].split(":", 1)[1].strip() if ":" in parts[2] else ""
64
+ return file_path, reason
43
65
 
44
- file_path = parts[1]
45
- reason = parts[2].split(":", 1)[1].strip() if ":" in parts[2] else ""
46
- issues[current_classifier].append((file_path, reason))
66
+ lines = content.splitlines()
67
+ if not is_valid_report(lines) or has_no_problems(lines):
68
+ return {}
69
+
70
+ issues = {}
71
+ current_classifier = None
47
72
 
73
+ for line in map(str.strip, lines[1:]):
74
+ if not line:
75
+ continue
76
+ classifier = parse_classifier(line)
77
+ if classifier is not None:
78
+ current_classifier = classifier
79
+ issues[current_classifier] = []
80
+ continue
81
+ issue = parse_issue(line)
82
+ if issue and current_classifier is not None:
83
+ issues[current_classifier].append(issue)
48
84
  return issues
49
85
 
50
86
 
51
87
  def read_artefact(file_path):
52
88
  """Reads the artefact text from the given file path."""
53
89
  try:
54
- with open(file_path, 'r', encoding="utf-8") as file:
90
+ with open(file_path, "r", encoding="utf-8") as file:
55
91
  return file.read()
56
92
  except FileNotFoundError:
57
93
  print(f"File not found: {file_path}")
@@ -70,8 +106,9 @@ def determine_artefact_type_and_class(classifier):
70
106
 
71
107
  artefact_class = artefact_type_mapping.get(artefact_type)
72
108
  if not artefact_class:
73
- print(f"No artefact class found for {artefact_type}")
74
- return None, None
109
+ raise AraError(f"No artefact class found for {artefact_type}")
110
+ # print(f"No artefact class found for {artefact_type}")
111
+ # return None, None
75
112
 
76
113
  return artefact_type, artefact_class
77
114
 
@@ -84,7 +121,7 @@ def construct_prompt(artefact_type, reason, file_path, artefact_text):
84
121
  "Provide the corrected artefact. Do not reformulate the artefact, "
85
122
  "just fix the pydantic model errors, use correct grammar. "
86
123
  "You should follow the name of the file "
87
- f"from its path {file_path} for naming the arteafact's title. "
124
+ f"from its path {file_path} for naming the artefact's title. "
88
125
  "You are not allowed to use file extention in the artefact title. "
89
126
  "You are not allowed to modify, delete or add tags. "
90
127
  "User tag should be '@user_<username>'. The pydantic model already provides the '@user_' prefix. "
@@ -97,43 +134,384 @@ def construct_prompt(artefact_type, reason, file_path, artefact_text):
97
134
  "then just delete those action items."
98
135
  )
99
136
 
100
- prompt += (
101
- "\nThe current artefact is:\n"
102
- "```\n"
103
- f"{artefact_text}\n"
104
- "```"
105
- )
137
+ prompt += "\nThe current artefact is:\n" "```\n" f"{artefact_text}\n" "```"
106
138
 
107
139
  return prompt
108
140
 
109
141
 
110
142
  def run_agent(prompt, artefact_class):
111
143
  from pydantic_ai import Agent
144
+
112
145
  # gpt-4o
113
146
  # anthropic:claude-3-7-sonnet-20250219
114
147
  # anthropic:claude-4-sonnet-20250514
115
- agent = Agent(model="anthropic:claude-4-sonnet-20250514",
116
- result_type=artefact_class, instrument=True)
148
+ agent = Agent(
149
+ model="anthropic:claude-4-sonnet-20250514",
150
+ output_type=artefact_class,
151
+ instrument=True,
152
+ )
117
153
  result = agent.run_sync(prompt)
118
- return result.data
154
+ return result.output
119
155
 
120
156
 
121
157
  def write_corrected_artefact(file_path, corrected_text):
122
- with open(file_path, 'w', encoding="utf-8") as file:
158
+ with open(file_path, "w", encoding="utf-8") as file:
123
159
  file.write(corrected_text)
124
160
  print(f"Fixed artefact at {file_path}")
125
161
 
126
162
 
127
- def fix_title_mismatch(file_path: str, artefact_text: str, artefact_class) -> str:
163
+ def ask_for_correct_contribution(
164
+ artefact_info: Optional[tuple[str, str]] = None
165
+ ) -> tuple[str, str]:
166
+ """
167
+ Ask the user to provide a valid contribution when no match can be found.
168
+
169
+ Args:
170
+ artefact_info: Optional tuple containing (artefact_name, artefact_classifier)
171
+
172
+ Returns:
173
+ A tuple of (name, classifier) for the contribution
174
+ """
175
+
176
+ artefact_name, artefact_classifier = (
177
+ artefact_info if artefact_info else (None, None)
178
+ )
179
+ contribution_message = (
180
+ f"of {artefact_classifier} artefact '{artefact_name}'" if artefact_name else ""
181
+ )
182
+
183
+ print(
184
+ f"Can not determine a match for contribution {contribution_message}. "
185
+ f"Please provide a valid contribution or contribution will be empty ([classifier] [file_name])."
186
+ )
187
+
188
+ user_input = input().strip()
189
+
190
+ if not user_input:
191
+ return None, None
192
+
193
+ parts = user_input.split(maxsplit=1)
194
+ if len(parts) != 2:
195
+ print("Invalid input format. Expected: <classifier> <file_name>")
196
+ return None, None
197
+
198
+ classifier, name = parts
199
+ return name, classifier
200
+
201
+
202
+ def ask_for_contribution_choice(choices: List[str], artefact_info: Optional[tuple[str, str]] = None) -> Optional[str]:
203
+ artefact_name, artefact_classifier = artefact_info if artefact_info else (None, None)
204
+ message = "Found multiple close matches for the contribution"
205
+ if artefact_name and artefact_classifier:
206
+ message += f" of the {artefact_classifier} '{artefact_name}'"
207
+ message += "."
208
+ return get_user_choice(choices, message)
209
+
210
+
211
+ def _has_valid_contribution(artefact: Artefact) -> bool:
212
+ contribution = artefact.contribution
213
+ return contribution and contribution.artefact_name and contribution.classifier
214
+
215
+
216
+ def get_user_choice(choices: List[str], message: str) -> Optional[str]:
217
+ """
218
+ Generic function to present user with a list of choices and return their selection.
219
+
220
+ Args:
221
+ choices: A list of strings representing the choices to display.
222
+ message: A message to display before listing the choices.
223
+
224
+ Returns:
225
+ The chosen item from the list or None if the input was invalid.
226
+ """
227
+ print(message)
228
+ for i, choice in enumerate(choices):
229
+ print(f"{i + 1}: {choice}")
230
+
231
+ choice_number = input("Please enter your choice (number): ")
232
+
233
+ try:
234
+ choice_index = int(choice_number) - 1
235
+ if choice_index < 0 or choice_index >= len(choices):
236
+ print("Invalid choice. Aborting operation.")
237
+ return None
238
+ return choices[choice_index]
239
+ except ValueError:
240
+ print("Invalid input. Aborting operation.")
241
+ return None
242
+
243
+
244
+ def ask_for_rule_choice(matches: List[str]) -> Optional[str]:
245
+ """Asks the user for a choice between multiple rule matches"""
246
+ message = "Multiple rule matches found:"
247
+ return get_user_choice(matches, message)
248
+
249
+
250
+ def _update_rule(
251
+ artefact: Artefact, name: str, classifier: str, classified_file_info: dict, delete_if_not_found: bool = False
252
+ ) -> None:
253
+ """Updates the rule in the contribution if a close match is found."""
254
+ rule = artefact.contribution.rule
255
+
256
+ content, artefact_data = ArtefactReader.read_artefact_data(
257
+ artefact_name=name,
258
+ classifier=classifier,
259
+ classified_file_info=classified_file_info,
260
+ )
261
+
262
+ parent = artefact_from_content(content=content)
263
+ rules = parent.rules
264
+
265
+ closest_rule_match = difflib.get_close_matches(rule, rules, cutoff=0.5)
266
+ if not closest_rule_match and delete_if_not_found:
267
+ artefact.contribution.rule = None
268
+ return
269
+ if not closest_rule_match:
270
+ return
271
+ if len(closest_rule_match) > 1:
272
+ artefact.contribution.rule = ask_for_rule_choice(closest_rule_match)
273
+ return
274
+ artefact.contribution.rule = closest_rule_match[0]
275
+
276
+
277
+ def _set_contribution_multiple_matches(
278
+ artefact: Artefact,
279
+ closest_matches: list,
280
+ artefact_tuple: tuple,
281
+ classified_file_info: dict,
282
+ ) -> tuple[Artefact, bool]:
283
+ contribution = artefact.contribution
284
+ classifier = contribution.classifier
285
+ original_name = contribution.artefact_name
286
+
287
+ closest_match = closest_matches[0]
288
+ if len(closest_matches) > 1:
289
+ closest_match = ask_for_contribution_choice(closest_matches, artefact_tuple)
290
+
291
+ if not closest_match:
292
+ print(
293
+ f"Contribution of {artefact_tuple[1]} '{artefact_tuple[0]}' will be empty."
294
+ )
295
+ artefact.contribution = None
296
+ return artefact, True
297
+
298
+ print(
299
+ f"Updating contribution of {artefact_tuple[1]} '{artefact_tuple[0]}' to {classifier} '{closest_match}'"
300
+ )
301
+ contribution.artefact_name = closest_match
302
+ artefact.contribution = contribution
303
+
304
+ if contribution.rule:
305
+ _update_rule(artefact, original_name, classifier, classified_file_info)
306
+
307
+ return artefact, True
308
+
309
+
310
+ def set_closest_contribution(
311
+ artefact: Artefact, classified_file_info=None
312
+ ) -> tuple[Artefact, bool]:
313
+ if not _has_valid_contribution(artefact):
314
+ return artefact, False
315
+ contribution = artefact.contribution
316
+ name = contribution.artefact_name
317
+ classifier = contribution.classifier
318
+ rule = contribution.rule
319
+
320
+ classified_file_info = populate_classified_artefact_info(classified_artefact_info=classified_file_info)
321
+
322
+ all_artefact_names = extract_artefact_names_of_classifier(
323
+ classified_files=classified_file_info, classifier=classifier
324
+ )
325
+ closest_matches = find_closest_name_matches(
326
+ artefact_name=name, all_artefact_names=all_artefact_names
327
+ )
328
+
329
+ artefact_tuple = (artefact.title, artefact._artefact_type().value)
330
+
331
+ if not closest_matches:
332
+ name, classifier = ask_for_correct_contribution(artefact_tuple)
333
+ if not name or not classifier:
334
+ artefact.contribution = None
335
+ return artefact, True
336
+ print(
337
+ f"Updating contribution of {artefact._artefact_type().value} '{artefact.title}' to {classifier} '{name}'"
338
+ )
339
+ contribution.artefact_name = name
340
+ contribution.classifier = classifier
341
+ artefact.contribution = contribution
342
+ return artefact, True
343
+
344
+ if closest_matches[0] == name:
345
+ return artefact, False
346
+
347
+ return _set_contribution_multiple_matches(
348
+ artefact=artefact,
349
+ closest_matches=closest_matches,
350
+ artefact_tuple=artefact_tuple,
351
+ classified_file_info=classified_file_info,
352
+ )
353
+
354
+ print(
355
+ f"Updating contribution of {artefact._artefact_type().value} '{artefact.title}' to {classifier} '{closest_match}'"
356
+ )
357
+ contribution.artefact_name = closest_match
358
+ artefact.contribution = contribution
359
+
360
+ if not rule:
361
+ return artefact, True
362
+
363
+ content, artefact = ArtefactReader.read_artefact_data(
364
+ artefact_name=name,
365
+ classifier=classifier,
366
+ classified_file_info=classified_file_info,
367
+ )
368
+ parent = artefact_from_content(content=content)
369
+ rules = parent.rules
370
+
371
+ closest_rule_match = difflib.get_close_matches(rule, rules, cutoff=0.5)
372
+ if closest_rule_match:
373
+ contribution.rule = closest_rule_match
374
+ artefact.contribution = contribution
375
+ return artefact, True
376
+
377
+
378
+ def fix_scenario_placeholder_mismatch(
379
+ file_path: str, artefact_text: str, artefact_class, **kwargs
380
+ ) -> str:
381
+ """
382
+ Converts a regular Scenario with placeholders to a Scenario Outline.
383
+ This is a deterministic fix that detects placeholders and converts the format.
384
+ """
385
+ lines = artefact_text.splitlines()
386
+ new_lines = []
387
+ i = 0
388
+
389
+ while i < len(lines):
390
+ line = lines[i]
391
+ stripped_line = line.strip()
392
+
393
+ if stripped_line.startswith('Scenario:'):
394
+ scenario_lines, next_index = _extract_scenario_block(lines, i)
395
+ processed_lines = _process_scenario_block(scenario_lines)
396
+ new_lines.extend(processed_lines)
397
+ i = next_index
398
+ else:
399
+ new_lines.append(line)
400
+ i += 1
401
+
402
+ return "\n".join(new_lines)
403
+
404
+
405
+ def _extract_scenario_block(lines: list, start_index: int) -> tuple[list, int]:
406
+ """Extract all lines belonging to a scenario block."""
407
+ scenario_lines = [lines[start_index]]
408
+ j = start_index + 1
409
+
410
+ while j < len(lines):
411
+ next_line = lines[j].strip()
412
+ if _is_scenario_boundary(next_line):
413
+ break
414
+ scenario_lines.append(lines[j])
415
+ j += 1
416
+
417
+ return scenario_lines, j
418
+
419
+
420
+ def _is_scenario_boundary(line: str) -> bool:
421
+ """Check if a line marks the boundary of a scenario block."""
422
+ boundaries = ['Scenario:', 'Scenario Outline:', 'Background:', 'Feature:']
423
+ return any(line.startswith(boundary) for boundary in boundaries)
424
+
425
+
426
+ def _process_scenario_block(scenario_lines: list) -> list:
427
+ """Process a scenario block and convert to outline if placeholders are found."""
428
+ if not scenario_lines:
429
+ return scenario_lines
430
+
431
+ first_line = scenario_lines[0]
432
+ indentation = _get_line_indentation(first_line)
433
+ placeholders = _extract_placeholders_from_scenario(scenario_lines[1:])
434
+
435
+ if not placeholders:
436
+ return scenario_lines
437
+
438
+ return _convert_to_scenario_outline(scenario_lines, placeholders, indentation)
439
+
440
+
441
+ def _get_line_indentation(line: str) -> str:
442
+ """Get the indentation of a line."""
443
+ return line[:len(line) - len(line.lstrip())]
444
+
445
+
446
+ def _extract_placeholders_from_scenario(step_lines: list) -> set:
447
+ """Extract placeholders from scenario step lines, ignoring docstrings."""
448
+ placeholders = set()
449
+ in_docstring = False
450
+
451
+ for line in step_lines:
452
+ step_line = line.strip()
453
+ if not step_line:
454
+ continue
455
+
456
+ in_docstring = _update_docstring_state(step_line, in_docstring)
457
+
458
+ if not in_docstring and '"""' not in step_line:
459
+ found = re.findall(r'<([^>]+)>', step_line)
460
+ placeholders.update(found)
461
+
462
+ return placeholders
463
+
464
+
465
+ def _update_docstring_state(line: str, current_state: bool) -> bool:
466
+ """Update the docstring state based on the current line."""
467
+ if '"""' in line:
468
+ return not current_state
469
+ return current_state
470
+
471
+
472
+ def _convert_to_scenario_outline(scenario_lines: list, placeholders: set, indentation: str) -> list:
473
+ """Convert scenario lines to scenario outline format with examples table."""
474
+ first_line = scenario_lines[0]
475
+ title = first_line.strip()[len('Scenario:'):].strip()
476
+
477
+ new_lines = [f"{indentation}Scenario Outline: {title}"]
478
+ new_lines.extend(scenario_lines[1:])
479
+ new_lines.append("")
480
+
481
+ examples_lines = _create_examples_table(placeholders, indentation)
482
+ new_lines.extend(examples_lines)
483
+
484
+ return new_lines
485
+
486
+
487
+ def _create_examples_table(placeholders: set, base_indentation: str) -> list:
488
+ """Create the Examples table for the scenario outline."""
489
+ examples_indentation = base_indentation + " "
490
+ table_indentation = examples_indentation + " "
491
+
492
+ sorted_placeholders = sorted(placeholders)
493
+ header = "| " + " | ".join(sorted_placeholders) + " |"
494
+ sample_row = "| " + " | ".join(f"<{p}_value>" for p in sorted_placeholders) + " |"
495
+
496
+ return [
497
+ f"{examples_indentation}Examples:",
498
+ f"{table_indentation}{header}",
499
+ f"{table_indentation}{sample_row}"
500
+ ]
501
+
502
+
503
+ def fix_title_mismatch(
504
+ file_path: str, artefact_text: str, artefact_class, **kwargs
505
+ ) -> str:
128
506
  """
129
507
  Deterministically fixes the title in the artefact text to match the filename.
130
508
  """
131
509
  base_name = os.path.basename(file_path)
132
510
  correct_title_underscores, _ = os.path.splitext(base_name)
133
- correct_title_spaces = correct_title_underscores.replace('_', ' ')
511
+ correct_title_spaces = correct_title_underscores.replace("_", " ")
134
512
 
135
513
  title_prefix = artefact_class._title_prefix()
136
-
514
+
137
515
  lines = artefact_text.splitlines()
138
516
  new_lines = []
139
517
  title_found_and_replaced = False
@@ -144,48 +522,251 @@ def fix_title_mismatch(file_path: str, artefact_text: str, artefact_class) -> st
144
522
  title_found_and_replaced = True
145
523
  else:
146
524
  new_lines.append(line)
147
-
525
+
148
526
  if not title_found_and_replaced:
149
- print(f"Warning: Title prefix '{title_prefix}' not found in {file_path}. Title could not be fixed.")
527
+ print(
528
+ f"Warning: Title prefix '{title_prefix}' not found in {file_path}. Title could not be fixed."
529
+ )
150
530
  return artefact_text
151
531
 
152
532
  return "\n".join(new_lines)
153
533
 
154
534
 
155
- def apply_autofix(file_path: str, classifier: str, reason: str, deterministic: bool, non_deterministic: bool) -> bool:
156
- artefact_text = read_artefact(file_path)
157
- if artefact_text is None:
158
- return False
535
+ def fix_contribution(
536
+ file_path: str,
537
+ artefact_text: str,
538
+ artefact_class: str,
539
+ classified_artefact_info: dict,
540
+ **kwargs,
541
+ ):
542
+ classified_artefact_info = populate_classified_artefact_info(classified_artefact_info=classified_artefact_info)
543
+ artefact = artefact_class.deserialize(artefact_text)
544
+ artefact, _ = set_closest_contribution(artefact)
545
+ artefact_text = artefact.serialize()
546
+ return artefact_text
547
+
548
+
549
+ def fix_rule(
550
+ file_path: str,
551
+ artefact_text: str,
552
+ artefact_class: str,
553
+ classified_artefact_info: dict,
554
+ **kwargs,
555
+ ):
556
+ classified_artefact_info = populate_classified_artefact_info(classified_artefact_info=classified_artefact_info)
557
+ artefact = artefact_class.deserialize(artefact_text)
558
+ contribution = artefact.contribution
559
+ assert contribution is not None
560
+ _update_rule(
561
+ artefact=artefact,
562
+ name=contribution.artefact_name,
563
+ classifier=contribution.classifier,
564
+ classified_file_info=classified_artefact_info,
565
+ delete_if_not_found=True
566
+ )
567
+ feedback_message = (f"Updating contribution of {artefact._artefact_type().value} "
568
+ f"'{artefact.title}' to {contribution.classifier} "
569
+ f"'{contribution.artefact_name}' ")
570
+ rule = contribution.rule
571
+ if rule:
572
+ feedback_message += f"with rule '{rule}'"
573
+ else:
574
+ feedback_message += "without a rule"
575
+ print(feedback_message)
576
+ return artefact.serialize()
577
+
578
+
579
+ def fix_misplaced_content(file_path: str, artefact_text: str, **kwargs) -> str:
580
+ """
581
+ Deterministically fixes content like 'Rule:' or 'Estimate:' misplaced in the description.
582
+ """
583
+ lines = artefact_text.splitlines()
159
584
 
160
- artefact_type, artefact_class = determine_artefact_type_and_class(classifier)
161
- if artefact_type is None or artefact_class is None:
162
- return False
585
+ desc_start_idx = -1
586
+ for i, line in enumerate(lines):
587
+ if line.strip().startswith("Description:"):
588
+ desc_start_idx = i
589
+ break
163
590
 
164
- is_deterministic_issue = "Filename-Title Mismatch" in reason
591
+ if desc_start_idx == -1:
592
+ return artefact_text # No description, nothing to fix.
165
593
 
166
- if deterministic and is_deterministic_issue:
167
- print(f"Attempting deterministic fix for {file_path}...")
168
- corrected_text = fix_title_mismatch(file_path, artefact_text, artefact_class)
169
- write_corrected_artefact(file_path, corrected_text)
594
+ pre_desc_lines = lines[:desc_start_idx]
595
+ desc_line = lines[desc_start_idx]
596
+ post_desc_lines = lines[desc_start_idx+1:]
597
+
598
+ misplaced_content = []
599
+ new_post_desc_lines = []
600
+
601
+ for line in post_desc_lines:
602
+ if line.strip().startswith("Rule:") or line.strip().startswith("Estimate:"):
603
+ misplaced_content.append(line)
604
+ else:
605
+ new_post_desc_lines.append(line)
606
+
607
+ if not misplaced_content:
608
+ return artefact_text
609
+
610
+ # Rebuild the file content
611
+ final_lines = pre_desc_lines + misplaced_content + [""] + [desc_line] + new_post_desc_lines
612
+ return "\n".join(final_lines)
613
+
614
+
615
+ def should_skip_issue(deterministic_issue, deterministic, non_deterministic, file_path) -> bool:
616
+ if not non_deterministic and not deterministic_issue:
617
+ print(f"Skipping non-deterministic fix for {file_path} as per request.")
618
+ return True
619
+ if not deterministic and deterministic_issue:
620
+ print(f"Skipping fix for {file_path} as per request flags.")
170
621
  return True
622
+ return False
623
+
624
+ def determine_attempt_count(single_pass, file_path) -> int:
625
+ if single_pass:
626
+ print(f"Single-pass mode enabled for {file_path}. Running for 1 attempt.")
627
+ return 1
628
+ return 3
629
+
630
+ def apply_deterministic_fix(
631
+ deterministic, deterministic_issue, file_path, artefact_text, artefact_class, classified_artefact_info,
632
+ deterministic_markers_to_functions, corrected_text
633
+ ) -> str:
634
+ if deterministic and deterministic_issue:
635
+ print(f"Applying deterministic fix for '{deterministic_issue}'...")
636
+ fix_function = deterministic_markers_to_functions[deterministic_issue]
637
+ return fix_function(
638
+ file_path=file_path,
639
+ artefact_text=artefact_text,
640
+ artefact_class=artefact_class,
641
+ classified_artefact_info=classified_artefact_info,
642
+ )
643
+ return corrected_text
171
644
 
172
- # Attempt non-deterministic fix if requested and the issue is NOT deterministic
173
- if non_deterministic and not is_deterministic_issue:
174
- print(f"Attempting non-deterministic (LLM) fix for {file_path}...")
175
- prompt = construct_prompt(artefact_type, reason, file_path, artefact_text)
645
+ def apply_non_deterministic_fix(
646
+ non_deterministic, deterministic_issue, corrected_text,
647
+ artefact_type, current_reason, file_path, artefact_text, artefact_class
648
+ ) -> Optional[str]:
649
+ """
650
+ Applies LLM fix. Return None in case of an exception
651
+ """
652
+ if non_deterministic and not deterministic_issue:
653
+ print("Applying non-deterministic (LLM) fix...")
654
+ prompt = construct_prompt(
655
+ artefact_type, current_reason, file_path, artefact_text
656
+ )
176
657
  try:
177
658
  corrected_artefact = run_agent(prompt, artefact_class)
178
659
  corrected_text = corrected_artefact.serialize()
179
- write_corrected_artefact(file_path, corrected_text)
180
- return True
181
660
  except Exception as e:
182
- print(f"LLM agent failed to fix artefact at {file_path}: {e}")
661
+ print(f"LLM agent failed to fix artefact at {file_path}: {e}")
662
+ return None
663
+ return corrected_text
664
+
665
+ def attempt_autofix_loop(
666
+ file_path: str,
667
+ artefact_type,
668
+ artefact_class,
669
+ deterministic_markers_to_functions,
670
+ max_attempts,
671
+ deterministic: bool,
672
+ non_deterministic: bool,
673
+ classified_artefact_info: Optional[Dict[str, List[Dict[str, str]]]],
674
+ ) -> bool:
675
+ """
676
+ Attempts to fix the artefact in a loop, up to max_attempts.
677
+ """
678
+ for attempt in range(max_attempts):
679
+ is_valid, current_reason = check_file(
680
+ file_path, artefact_class, classified_artefact_info
681
+ )
682
+
683
+ if is_valid:
684
+ print(f"✅ Artefact at {file_path} is now valid.")
685
+ return True
686
+
687
+ print(
688
+ f"Attempting to fix {file_path} (Attempt {attempt + 1}/{max_attempts})..."
689
+ )
690
+ print(f" Reason: {current_reason}")
691
+
692
+ artefact_text = read_artefact(file_path)
693
+ if artefact_text is None:
183
694
  return False
184
-
185
- # Log if a fix was skipped due to flags
186
- if is_deterministic_issue and not deterministic:
187
- print(f"Skipping deterministic fix for {file_path} as per request.")
188
- elif not is_deterministic_issue and not non_deterministic:
189
- print(f"Skipping non-deterministic fix for {file_path} as per request.")
190
695
 
191
- return False
696
+ deterministic_issue = next(
697
+ (
698
+ marker
699
+ for marker in deterministic_markers_to_functions
700
+ if marker in current_reason
701
+ ),
702
+ None,
703
+ )
704
+
705
+ if should_skip_issue(deterministic_issue, deterministic, non_deterministic, file_path):
706
+ return False
707
+
708
+ corrected_text = None
709
+
710
+ corrected_text = apply_deterministic_fix(
711
+ deterministic, deterministic_issue, file_path, artefact_text,
712
+ artefact_class, classified_artefact_info,
713
+ deterministic_markers_to_functions, corrected_text
714
+ )
715
+ corrected_text = apply_non_deterministic_fix(
716
+ non_deterministic, deterministic_issue, corrected_text,
717
+ artefact_type, current_reason, file_path, artefact_text, artefact_class
718
+ )
719
+
720
+ if corrected_text is None or corrected_text.strip() == artefact_text.strip():
721
+ print(
722
+ " Fixing attempt did not alter the file. Stopping to prevent infinite loop."
723
+ )
724
+ return False
725
+
726
+ write_corrected_artefact(file_path, corrected_text)
727
+
728
+ print(" File modified. Re-classifying artefact information for next check...")
729
+ classified_artefact_info = populate_classified_artefact_info(classified_artefact_info, force=True)
730
+
731
+ print(f"❌ Failed to fix {file_path} after {max_attempts} attempts.")
732
+ return False
733
+
734
+ def apply_autofix(
735
+ file_path: str,
736
+ classifier: str,
737
+ reason: str,
738
+ single_pass: bool = False,
739
+ deterministic: bool = True,
740
+ non_deterministic: bool = True,
741
+ classified_artefact_info: Optional[Dict[str, List[Dict[str, str]]]] = None,
742
+ ) -> bool:
743
+ """
744
+ Applies fixes to a single artefact file iteratively until it is valid
745
+ or a fix cannot be applied. If single_pass is True, it runs for only one attempt.
746
+ """
747
+ deterministic_markers_to_functions = {
748
+ "Filename-Title Mismatch": fix_title_mismatch,
749
+ "Invalid Contribution Reference": fix_contribution,
750
+ "Rule Mismatch": fix_rule,
751
+ "Scenario Contains Placeholders": fix_scenario_placeholder_mismatch,
752
+ "Found 'Rule:' inside description": fix_misplaced_content,
753
+ "Found 'Estimate:' inside description": fix_misplaced_content,
754
+ }
755
+
756
+ artefact_type, artefact_class = determine_artefact_type_and_class(classifier)
757
+ if artefact_type is None or artefact_class is None:
758
+ return False
759
+
760
+ classified_artefact_info = populate_classified_artefact_info(classified_artefact_info)
761
+ max_attempts = determine_attempt_count(single_pass, file_path)
762
+
763
+ return attempt_autofix_loop(
764
+ file_path=file_path,
765
+ artefact_type=artefact_type,
766
+ artefact_class=artefact_class,
767
+ deterministic_markers_to_functions=deterministic_markers_to_functions,
768
+ max_attempts=max_attempts,
769
+ deterministic=deterministic,
770
+ non_deterministic=non_deterministic,
771
+ classified_artefact_info=classified_artefact_info,
772
+ )