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,5 @@
1
1
  from ara_cli.artefact_models.artefact_model import Artefact, ArtefactType, Intent
2
- from pydantic import Field, field_validator
2
+ from pydantic import Field, field_validator, model_validator
3
3
  from typing import List, Tuple, Optional
4
4
 
5
5
 
@@ -48,39 +48,38 @@ class EpicIntent(Intent):
48
48
 
49
49
  @classmethod
50
50
  def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'EpicIntent':
51
- in_order_to = None
52
- as_a = None
53
- i_want = None
54
-
55
- in_order_to_prefix = "In order to "
56
- as_a_prefix = "As a "
57
- as_a_prefix_alt = "As an "
58
- i_want_prefix = "I want "
51
+ prefixes = [
52
+ ("In order to ", "in_order_to"),
53
+ ("As a ", "as_a"),
54
+ ("As an ", "as_a"),
55
+ ("I want ", "i_want"),
56
+ ]
57
+
58
+ found = {"in_order_to": None, "as_a": None, "i_want": None}
59
+
60
+ def match_and_store(line):
61
+ for prefix, field in prefixes:
62
+ if line.startswith(prefix) and found[field] is None:
63
+ found[field] = line[len(prefix):].strip()
64
+ return True
65
+ return False
59
66
 
60
67
  index = start_index
61
- while index < len(lines) and (not in_order_to or not as_a or not i_want):
62
- line = lines[index]
63
- if line.startswith(in_order_to_prefix) and not in_order_to:
64
- in_order_to = line[len(in_order_to_prefix):].strip()
65
- elif line.startswith(as_a_prefix) and not as_a:
66
- as_a = line[len(as_a_prefix):].strip()
67
- elif line.startswith(as_a_prefix_alt) and not as_a:
68
- as_a = line[len(as_a_prefix_alt):].strip()
69
- elif line.startswith(i_want_prefix) and not i_want:
70
- i_want = line[len(i_want_prefix):].strip()
68
+ while index < len(lines) and any(v is None for v in found.values()):
69
+ match_and_store(lines[index])
71
70
  index += 1
72
71
 
73
- if not in_order_to:
72
+ if not found["in_order_to"]:
74
73
  raise ValueError("Could not find 'In order to' line")
75
- if not as_a:
74
+ if not found["as_a"]:
76
75
  raise ValueError("Could not find 'As a' line")
77
- if not i_want:
76
+ if not found["i_want"]:
78
77
  raise ValueError("Could not find 'I want' line")
79
78
 
80
79
  return cls(
81
- in_order_to=in_order_to,
82
- as_a=as_a,
83
- i_want=i_want
80
+ in_order_to=found["in_order_to"],
81
+ as_a=found["as_a"],
82
+ i_want=found["i_want"]
84
83
  )
85
84
 
86
85
 
@@ -92,6 +91,15 @@ class EpicArtefact(Artefact):
92
91
  description="Rules the epic defines. It is recommended to create rules to clarify the desired outcome"
93
92
  )
94
93
 
94
+ @model_validator(mode='after')
95
+ def check_for_misplaced_rules(self) -> 'EpicArtefact':
96
+ if self.description:
97
+ desc_lines = self.description.split('\n')
98
+ for line in desc_lines:
99
+ if line.strip().startswith("Rule:"):
100
+ raise ValueError("Found 'Rule:' inside description. Rules must be defined before the 'Description:' section.")
101
+ return self
102
+
95
103
  @field_validator('artefact_type')
96
104
  def validate_artefact_type(cls, v):
97
105
  if v != ArtefactType.epic:
@@ -167,4 +175,4 @@ class EpicArtefact(Artefact):
167
175
  lines.append("")
168
176
  lines.append(description)
169
177
  lines.append("")
170
- return "\n".join(lines)
178
+ return "\n".join(lines)
@@ -48,39 +48,36 @@ class FeatureIntent(Intent):
48
48
 
49
49
  @classmethod
50
50
  def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'FeatureIntent':
51
- as_a = None
52
- i_want_to = None
53
- so_that = None
54
-
55
- as_a_prefix = "As a "
56
- as_a_prefix_alt = "As an "
57
- i_want_to_prefix = "I want to "
58
- so_that_prefix = "So that "
51
+ prefixes = [
52
+ ("As a ", "as_a"),
53
+ ("As an ", "as_a"),
54
+ ("I want to ", "i_want_to"),
55
+ ("So that ", "so_that"),
56
+ ]
57
+ found = {"as_a": None, "i_want_to": None, "so_that": None}
58
+
59
+ def match_and_store(line):
60
+ for prefix, field in prefixes:
61
+ if line.startswith(prefix) and found[field] is None:
62
+ found[field] = line[len(prefix):].strip()
63
+ return
59
64
 
60
65
  index = start_index
61
- while index < len(lines) and (not as_a or not i_want_to or not so_that):
62
- line = lines[index]
63
- if line.startswith(as_a_prefix) and not as_a:
64
- as_a = line[len(as_a_prefix):].strip()
65
- if line.startswith(as_a_prefix_alt) and not as_a:
66
- as_a = line[len(as_a_prefix_alt):].strip()
67
- if line.startswith(i_want_to_prefix) and not i_want_to:
68
- i_want_to = line[len(i_want_to_prefix):].strip()
69
- if line.startswith(so_that_prefix) and not so_that:
70
- so_that = line[len(so_that_prefix):].strip()
66
+ while index < len(lines) and any(v is None for v in found.values()):
67
+ match_and_store(lines[index].strip())
71
68
  index += 1
72
69
 
73
- if not as_a:
70
+ if not found["as_a"]:
74
71
  raise ValueError("Could not find 'As a' line")
75
- if not i_want_to:
72
+ if not found["i_want_to"]:
76
73
  raise ValueError("Could not find 'I want to' line")
77
- if not so_that:
74
+ if not found["so_that"]:
78
75
  raise ValueError("Could not find 'So that' line")
79
76
 
80
77
  return cls(
81
- as_a=as_a,
82
- i_want_to=i_want_to,
83
- so_that=so_that
78
+ as_a=found["as_a"],
79
+ i_want_to=found["i_want_to"],
80
+ so_that=found["so_that"]
84
81
  )
85
82
 
86
83
 
@@ -138,6 +135,7 @@ class Scenario(BaseModel):
138
135
  @field_validator('title')
139
136
  def validate_title(cls, v: str) -> str:
140
137
  v = v.strip()
138
+ v = v.replace('_', ' ')
141
139
  if not v:
142
140
  raise ValueError("title must not be empty")
143
141
  return v
@@ -150,6 +148,30 @@ class Scenario(BaseModel):
150
148
  raise ValueError("steps list must not be empty")
151
149
  return steps
152
150
 
151
+ @model_validator(mode='after')
152
+ def check_no_placeholders(cls, values: 'Scenario') -> 'Scenario':
153
+ """Ensure regular scenarios don't contain placeholders that should be in scenario outlines."""
154
+ placeholders = set()
155
+ for step in values.steps:
156
+ # Skip validation if step contains docstring placeholders (during parsing)
157
+ if '__DOCSTRING_PLACEHOLDER_' in step:
158
+ continue
159
+
160
+ # Skip validation if step contains docstring markers (after reinjection)
161
+ if '"""' in step:
162
+ continue
163
+
164
+ found = re.findall(r'<([^>]+)>', step)
165
+ placeholders.update(found)
166
+
167
+ if placeholders:
168
+ placeholder_list = ', '.join(f"<{p}>" for p in sorted(placeholders))
169
+ raise ValueError(
170
+ f"Scenario Contains Placeholders ({placeholder_list}) but is not a Scenario Outline. "
171
+ f"Use 'Scenario Outline:' instead of 'Scenario:' and provide an Examples table."
172
+ )
173
+ return values
174
+
153
175
  @classmethod
154
176
  def from_lines(cls, lines: List[str], start_idx: int) -> Tuple['Scenario', int]:
155
177
  """Parse a Scenario from a list of lines starting at start_idx."""
@@ -181,6 +203,7 @@ class ScenarioOutline(BaseModel):
181
203
  def validate_title(cls, v: str) -> str:
182
204
  if not v:
183
205
  raise ValueError("title must not be empty in a ScenarioOutline")
206
+ v = v.replace('_', ' ')
184
207
  return v
185
208
 
186
209
  @field_validator('steps', mode='before')
@@ -213,28 +236,54 @@ class ScenarioOutline(BaseModel):
213
236
  def from_lines(cls, lines: List[str], start_idx: int) -> Tuple['ScenarioOutline', int]:
214
237
  """Parse a ScenarioOutline from a list of lines starting at start_idx."""
215
238
 
216
- if not lines[start_idx].startswith('Scenario Outline:'):
217
- raise ValueError("Expected 'Scenario Outline:' at start index")
218
- title = lines[start_idx][len('Scenario Outline:'):].strip()
219
- steps = []
220
- idx = start_idx + 1
221
- while idx < len(lines) and not lines[idx].strip().startswith('Examples:'):
222
- if lines[idx].strip():
223
- steps.append(lines[idx].strip())
224
- idx += 1
225
- examples = []
226
- if idx < len(lines) and lines[idx].strip() == 'Examples:':
239
+ def extract_title(line: str) -> str:
240
+ if not line.startswith('Scenario Outline:'):
241
+ raise ValueError("Expected 'Scenario Outline:' at start index")
242
+ return line[len('Scenario Outline:'):].strip()
243
+
244
+ def extract_steps(lines: List[str], idx: int) -> Tuple[List[str], int]:
245
+ steps = []
246
+ while idx < len(lines) and not lines[idx].strip().startswith('Examples:'):
247
+ if lines[idx].strip():
248
+ steps.append(lines[idx].strip())
249
+ idx += 1
250
+ return steps, idx
251
+
252
+ def extract_headers(line: str) -> List[str]:
253
+ return [h.strip() for h in line.split('|') if h.strip()]
254
+
255
+ def extract_row(line: str) -> List[str]:
256
+ return [cell.strip() for cell in line.split('|') if cell.strip()]
257
+
258
+ def is_scenario_line(line: str) -> bool:
259
+ return line.startswith("Scenario:") or line.startswith("Scenario Outline:")
260
+
261
+ def extract_examples(lines: List[str], idx: int) -> Tuple[List['Example'], int]:
262
+ examples = []
263
+
264
+ if idx >= len(lines) or lines[idx].strip() != 'Examples:':
265
+ return examples, idx
266
+
227
267
  idx += 1
228
- headers = [h.strip() for h in lines[idx].split('|') if h.strip()]
268
+ headers = extract_headers(lines[idx])
229
269
  idx += 1
230
- while idx < len(lines) and lines[idx].strip():
231
- if lines[idx].strip().startswith("Scenario:") or lines[idx].strip().startswith("Scenario Outline:"):
270
+
271
+ while idx < len(lines):
272
+ current_line = lines[idx].strip()
273
+ if not current_line or is_scenario_line(current_line):
232
274
  break
233
- row = [cell.strip()
234
- for cell in lines[idx].split('|') if cell.strip()]
275
+
276
+ row = extract_row(lines[idx])
235
277
  example = Example.from_row(headers, row)
236
278
  examples.append(example)
237
279
  idx += 1
280
+
281
+ return examples, idx
282
+
283
+ title = extract_title(lines[start_idx])
284
+ steps, idx = extract_steps(lines, start_idx + 1)
285
+ examples, idx = extract_examples(lines, idx)
286
+
238
287
  return cls(title=title, steps=steps, examples=examples), idx
239
288
 
240
289
 
@@ -252,6 +301,37 @@ class FeatureArtefact(Artefact):
252
301
  f"FeatureArtefact must have artefact_type of '{ArtefactType.feature}', not '{v}'")
253
302
  return v
254
303
 
304
+ @classmethod
305
+ def _deserialize_description(cls, lines: List[str]) -> (Optional[str], List[str]):
306
+ description_start = cls._description_starts_with()
307
+ scenario_markers = ["Scenario:", "Scenario Outline:"]
308
+
309
+ start_index = -1
310
+ for i, line in enumerate(lines):
311
+ if line.startswith(description_start):
312
+ start_index = i
313
+ break
314
+
315
+ if start_index == -1:
316
+ return None, lines
317
+
318
+ end_index = len(lines)
319
+ for i in range(start_index + 1, len(lines)):
320
+ if any(lines[i].startswith(marker) for marker in scenario_markers):
321
+ end_index = i
322
+ break
323
+
324
+ first_line_content = lines[start_index][len(description_start):].strip()
325
+
326
+ description_lines_list = [first_line_content] if first_line_content else []
327
+ description_lines_list.extend(lines[start_index+1:end_index])
328
+
329
+ description = "\n".join(description_lines_list).strip() or None
330
+
331
+ remaining_lines = lines[:start_index] + lines[end_index:]
332
+
333
+ return description, remaining_lines
334
+
255
335
  @classmethod
256
336
  def _title_prefix(cls) -> str:
257
337
  return "Feature:"
@@ -289,12 +369,10 @@ class FeatureArtefact(Artefact):
289
369
 
290
370
  def _serialize_scenario_outline(self, scenario: ScenarioOutline) -> str:
291
371
  """Serialize a ScenarioOutline with aligned examples."""
292
- lines = []
293
- lines.append(f" Scenario Outline: {scenario.title}")
294
- for step in scenario.steps:
295
- lines.append(f" {step}")
296
-
297
- if scenario.examples:
372
+ def serialize_scenario_examples():
373
+ nonlocal lines, scenario
374
+ if not scenario:
375
+ return
298
376
  headers = self._extract_placeholders(scenario.steps)
299
377
 
300
378
  rows = [headers]
@@ -320,6 +398,13 @@ class FeatureArtefact(Artefact):
320
398
  for formatted_row in formatted_rows:
321
399
  lines.append(f" {formatted_row}")
322
400
 
401
+ lines = []
402
+ lines.append(f" Scenario Outline: {scenario.title}")
403
+ for step in scenario.steps:
404
+ lines.append(f" {step}")
405
+
406
+ serialize_scenario_examples()
407
+
323
408
  return "\n".join(lines)
324
409
 
325
410
  def _extract_placeholders(self, steps):
@@ -363,27 +448,43 @@ class FeatureArtefact(Artefact):
363
448
 
364
449
  @classmethod
365
450
  def deserialize(cls, text: str) -> 'FeatureArtefact':
366
- fields = super()._parse_common_fields(text)
367
-
368
- intent = FeatureIntent.deserialize(text)
369
- background = cls.deserialize_background(text)
370
- scenarios = cls.deserialize_scenarios(text)
451
+ """
452
+ Deserializes the feature file using a robust extract-and-reinject strategy.
453
+ 1. Hides all docstrings by replacing them with placeholders.
454
+ 2. Parses the sanitized text using the original, simple parsing logic.
455
+ 3. Re-injects the original docstring content back into the parsed objects.
456
+ This prevents the parser from ever being confused by content within docstrings.
457
+ """
458
+ # 1. Hide all docstrings from the entire file text first.
459
+ sanitized_text, docstrings = cls._hide_docstrings(text)
460
+
461
+ # 2. Perform the original parsing logic on the SANITIZED text.
462
+ # This part of the code is now "safe" because it will never see a docstring.
463
+ fields = super()._parse_common_fields(sanitized_text)
464
+ intent = FeatureIntent.deserialize(sanitized_text)
465
+ background = cls.deserialize_background(sanitized_text)
466
+ scenarios = cls.deserialize_scenarios(sanitized_text)
371
467
 
372
- fields['scenarios'] = scenarios
373
- fields['background'] = background
374
468
  fields['intent'] = intent
469
+ fields['background'] = background
470
+ fields['scenarios'] = scenarios
471
+
472
+ # 3. Re-inject the docstrings back into the parsed scenarios.
473
+ if fields['scenarios'] and docstrings:
474
+ for scenario in fields['scenarios']:
475
+ if isinstance(scenario, (Scenario, ScenarioOutline)):
476
+ scenario.steps = cls._reinject_docstrings_into_steps(scenario.steps, docstrings)
375
477
 
376
478
  return cls(**fields)
377
479
 
378
480
  @classmethod
379
481
  def deserialize_scenarios(cls, text):
380
- lines = [line.strip()
381
- for line in text.strip().splitlines() if line.strip()]
382
-
482
+ if not text: return []
483
+ lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
383
484
  scenarios = []
384
485
  idx = 0
385
486
  while idx < len(lines):
386
- line = lines[idx].strip()
487
+ line = lines[idx]
387
488
  if line.startswith('Scenario:'):
388
489
  scenario, next_idx = Scenario.from_lines(lines, idx)
389
490
  scenarios.append(scenario)
@@ -398,16 +499,54 @@ class FeatureArtefact(Artefact):
398
499
 
399
500
  @classmethod
400
501
  def deserialize_background(cls, text):
401
- lines = [line.strip()
402
- for line in text.strip().splitlines() if line.strip()]
403
-
502
+ if not text: return None
503
+ lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
404
504
  background = None
405
505
  idx = 0
406
506
  while idx < len(lines):
407
- line = lines[idx].strip()
507
+ line = lines[idx]
408
508
  if line.startswith('Background:'):
409
- background, next_idx = Background.from_lines(lines, idx)
509
+ background, _ = Background.from_lines(lines, idx)
410
510
  break
411
- else:
412
- idx += 1
511
+ idx += 1
413
512
  return background
513
+
514
+
515
+ @staticmethod
516
+ def _hide_docstrings(text: str) -> Tuple[str, Dict[str, str]]:
517
+ """
518
+ Finds all docstring blocks ('''...''') in the text,
519
+ replaces them with a unique placeholder, and returns the sanitized
520
+ text and a dictionary mapping placeholders to the original docstrings.
521
+ """
522
+ docstrings = {}
523
+ placeholder_template = "__DOCSTRING_PLACEHOLDER_{}__"
524
+
525
+ def replacer(match):
526
+ # This function is called for each found docstring.
527
+ key = placeholder_template.format(len(docstrings))
528
+ docstrings[key] = match.group(0) # Store the full matched docstring
529
+ return key
530
+
531
+ # The regex finds ''' followed by any character (including newlines)
532
+ # in a non-greedy way (.*?) until the next '''.
533
+ sanitized_text = re.sub(r'"""[\s\S]*?"""', replacer, text)
534
+
535
+ return sanitized_text, docstrings
536
+
537
+ @staticmethod
538
+ def _reinject_docstrings_into_steps(steps: List[str], docstrings: Dict[str, str]) -> List[str]:
539
+ """
540
+ Iterates through a list of steps, finds any placeholders,
541
+ and replaces them with their original docstring content.
542
+ """
543
+ rehydrated_steps = []
544
+ for step in steps:
545
+ for key, value in docstrings.items():
546
+ if key in step:
547
+ # Replace the placeholder with the original, full docstring block.
548
+ # This handles cases where the step is just the placeholder,
549
+ # or the placeholder is at the end of a line (e.g., "Then I see... __PLACEHOLDER__").
550
+ step = step.replace(key, value)
551
+ rehydrated_steps.append(step)
552
+ return rehydrated_steps
@@ -47,39 +47,36 @@ class KeyfeatureIntent(Intent):
47
47
 
48
48
  @classmethod
49
49
  def deserialize_from_lines(cls, lines: List[str], start_index: int = 0) -> 'KeyfeatureIntent':
50
- in_order_to = None
51
- as_a = None
52
- i_want = None
53
-
54
- in_order_to_prefix = "In order to "
55
- as_a_prefix = "As a "
56
- as_a_prefix_alt = "As an "
57
- i_want_prefix = "I want "
50
+ prefixes = [
51
+ ("In order to ", "in_order_to"),
52
+ ("As a ", "as_a"),
53
+ ("As an ", "as_a"),
54
+ ("I want ", "i_want"),
55
+ ]
56
+ found = {"in_order_to": None, "as_a": None, "i_want": None}
57
+
58
+ def match_and_store(line):
59
+ for prefix, field in prefixes:
60
+ if line.startswith(prefix) and found[field] is None:
61
+ found[field] = line[len(prefix):].strip()
62
+ return
58
63
 
59
64
  index = start_index
60
- while index < len(lines) and (not in_order_to or not as_a or not i_want):
61
- line = lines[index]
62
- if line.startswith(in_order_to_prefix) and not in_order_to:
63
- in_order_to = line[len(in_order_to_prefix):].strip()
64
- elif line.startswith(as_a_prefix) and not as_a:
65
- as_a = line[len(as_a_prefix):].strip()
66
- elif line.startswith(as_a_prefix_alt) and not as_a:
67
- as_a = line[len(as_a_prefix_alt):].strip()
68
- elif line.startswith(i_want_prefix) and not i_want:
69
- i_want = line[len(i_want_prefix):].strip()
65
+ while index < len(lines) and any(v is None for v in found.values()):
66
+ match_and_store(lines[index])
70
67
  index += 1
71
68
 
72
- if not in_order_to:
69
+ if not found["in_order_to"]:
73
70
  raise ValueError("Could not find 'In order to' line")
74
- if not as_a:
71
+ if not found["as_a"]:
75
72
  raise ValueError("Could not find 'As a' line")
76
- if not i_want:
73
+ if not found["i_want"]:
77
74
  raise ValueError("Could not find 'I want' line")
78
75
 
79
76
  return cls(
80
- in_order_to=in_order_to,
81
- as_a=as_a,
82
- i_want=i_want
77
+ in_order_to=found["in_order_to"],
78
+ as_a=found["as_a"],
79
+ i_want=found["i_want"]
83
80
  )
84
81
 
85
82
 
@@ -15,4 +15,4 @@ def as_a_serializer(as_a):
15
15
  else:
16
16
  as_a_prefix = "As a"
17
17
 
18
- return f"{as_a_prefix} {role}"
18
+ return f"{as_a_prefix} {role}".strip()
@@ -33,34 +33,100 @@ class ActionItem(BaseModel):
33
33
  return v
34
34
 
35
35
  @classmethod
36
- def deserialize(cls, line: str) -> Optional['ActionItem']:
37
- if not line:
36
+ def deserialize(cls, text: str) -> Optional['ActionItem']:
37
+ if not text:
38
38
  return None
39
- match = re.match(r'\[@(to-do|in-progress|done)\]\s+(.*)', line.strip())
39
+
40
+ lines = text.strip().split('\n')
41
+ first_line = lines[0]
42
+
43
+ match = re.match(r'\[@(.*?)\]\s+(.*)', first_line)
40
44
  if not match:
41
45
  return None
42
- status, text = match.groups()
43
- return cls(status=status, text=text)
46
+
47
+ status, first_line_text = match.groups()
48
+
49
+ # Validate the status before creating the ActionItem
50
+ if status not in ["to-do", "in-progress", "done"]:
51
+ raise ValueError(f"invalid status '{status}' in action item. Allowed values are 'to-do', 'in-progress', 'done'")
52
+
53
+ # If there are multiple lines, join them
54
+ if len(lines) > 1:
55
+ all_text = '\n'.join([first_line_text] + lines[1:])
56
+ else:
57
+ all_text = first_line_text
58
+
59
+ return cls(status=status, text=all_text)
44
60
 
45
61
  def serialize(self) -> str:
46
- return f"[@{self.status}] {self.text}"
62
+ lines = self.text.split('\n')
63
+ # First line includes the status marker
64
+ first_line = f"[@{self.status}] {lines[0]}"
65
+ if len(lines) == 1:
66
+ return first_line
67
+
68
+ # Additional lines follow without status marker
69
+ result_lines = [first_line] + lines[1:]
70
+ return '\n'.join(result_lines)
47
71
 
48
72
 
49
73
  class TaskArtefact(Artefact):
50
74
  artefact_type: ArtefactType = ArtefactType.task
51
75
  action_items: List[ActionItem] = Field(default_factory=list)
52
76
 
77
+ @classmethod
78
+ def _is_action_item_start(cls, line: str) -> bool:
79
+ return line.startswith('[@')
80
+
81
+ @classmethod
82
+ def _is_section_start(cls, line: str, description_marker: str, contribution_marker: str) -> bool:
83
+ return (
84
+ line.startswith(description_marker) or
85
+ line.startswith(contribution_marker)
86
+ )
87
+
88
+ @classmethod
89
+ def _collect_action_item_lines(cls, lines, start_idx, description_marker, contribution_marker):
90
+ action_item_lines = [lines[start_idx]]
91
+ j = start_idx + 1
92
+ while j < len(lines):
93
+ next_line = lines[j]
94
+ if (
95
+ cls._is_action_item_start(next_line) or
96
+ cls._is_section_start(next_line, description_marker, contribution_marker)
97
+ ):
98
+ break
99
+ action_item_lines.append(next_line)
100
+ j += 1
101
+ return action_item_lines, j
102
+
53
103
  @classmethod
54
104
  def _deserialize_action_items(cls, text) -> Tuple[List[ActionItem], List[str]]:
55
105
  lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
56
-
57
- remaining_lines = []
58
106
  action_items = []
59
- for line in lines:
60
- if line.startswith('[@'):
61
- action_items.append(ActionItem.deserialize(line))
62
- continue
63
- remaining_lines.append(line)
107
+ remaining_lines = []
108
+ i = 0
109
+ contribution_marker = cls._contribution_starts_with()
110
+ description_marker = cls._description_starts_with()
111
+
112
+ while i < len(lines):
113
+ line = lines[i]
114
+ if cls._is_action_item_start(line):
115
+ action_item_lines, next_idx = cls._collect_action_item_lines(
116
+ lines, i, description_marker, contribution_marker
117
+ )
118
+ action_item_text = '\n'.join(action_item_lines)
119
+ try:
120
+ action_item = ActionItem.deserialize(action_item_text)
121
+ if action_item:
122
+ action_items.append(action_item)
123
+ except ValueError as e:
124
+ raise ValueError(f"Error parsing action item: {e}")
125
+ i = next_idx
126
+ else:
127
+ remaining_lines.append(line)
128
+ i += 1
129
+
64
130
  return action_items, remaining_lines
65
131
 
66
132
  @classmethod
@@ -82,7 +148,9 @@ class TaskArtefact(Artefact):
82
148
  return ArtefactType.task
83
149
 
84
150
  def _serialize_action_items(self) -> str:
85
- action_item_lines = [action_item.serialize() for action_item in self.action_items]
151
+ action_item_lines = []
152
+ for action_item in self.action_items:
153
+ action_item_lines.append(action_item.serialize())
86
154
  return "\n".join(action_item_lines)
87
155
 
88
156
  def serialize(self) -> str:
@@ -106,4 +174,4 @@ class TaskArtefact(Artefact):
106
174
  lines.append(description)
107
175
  lines.append("")
108
176
 
109
- return "\n".join(lines)
177
+ return "\n".join(lines)