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.
- ara_cli/__init__.py +18 -2
- ara_cli/__main__.py +248 -62
- ara_cli/ara_command_action.py +155 -86
- ara_cli/ara_config.py +226 -80
- ara_cli/ara_subcommands/__init__.py +0 -0
- ara_cli/ara_subcommands/autofix.py +26 -0
- ara_cli/ara_subcommands/chat.py +27 -0
- ara_cli/ara_subcommands/classifier_directory.py +16 -0
- ara_cli/ara_subcommands/common.py +100 -0
- ara_cli/ara_subcommands/create.py +75 -0
- ara_cli/ara_subcommands/delete.py +22 -0
- ara_cli/ara_subcommands/extract.py +22 -0
- ara_cli/ara_subcommands/fetch_templates.py +14 -0
- ara_cli/ara_subcommands/list.py +65 -0
- ara_cli/ara_subcommands/list_tags.py +25 -0
- ara_cli/ara_subcommands/load.py +48 -0
- ara_cli/ara_subcommands/prompt.py +136 -0
- ara_cli/ara_subcommands/read.py +47 -0
- ara_cli/ara_subcommands/read_status.py +20 -0
- ara_cli/ara_subcommands/read_user.py +20 -0
- ara_cli/ara_subcommands/reconnect.py +27 -0
- ara_cli/ara_subcommands/rename.py +22 -0
- ara_cli/ara_subcommands/scan.py +14 -0
- ara_cli/ara_subcommands/set_status.py +22 -0
- ara_cli/ara_subcommands/set_user.py +22 -0
- ara_cli/ara_subcommands/template.py +16 -0
- ara_cli/artefact_autofix.py +649 -68
- ara_cli/artefact_creator.py +8 -11
- ara_cli/artefact_deleter.py +2 -4
- ara_cli/artefact_fuzzy_search.py +22 -10
- ara_cli/artefact_link_updater.py +4 -4
- ara_cli/artefact_lister.py +29 -55
- ara_cli/artefact_models/artefact_data_retrieval.py +23 -0
- ara_cli/artefact_models/artefact_load.py +11 -3
- ara_cli/artefact_models/artefact_model.py +146 -39
- ara_cli/artefact_models/artefact_templates.py +70 -44
- ara_cli/artefact_models/businessgoal_artefact_model.py +23 -25
- ara_cli/artefact_models/epic_artefact_model.py +34 -26
- ara_cli/artefact_models/feature_artefact_model.py +203 -64
- ara_cli/artefact_models/keyfeature_artefact_model.py +21 -24
- ara_cli/artefact_models/serialize_helper.py +1 -1
- ara_cli/artefact_models/task_artefact_model.py +83 -15
- ara_cli/artefact_models/userstory_artefact_model.py +37 -27
- ara_cli/artefact_models/vision_artefact_model.py +23 -42
- ara_cli/artefact_reader.py +92 -91
- ara_cli/artefact_renamer.py +8 -4
- ara_cli/artefact_scan.py +66 -3
- ara_cli/chat.py +622 -162
- ara_cli/chat_agent/__init__.py +0 -0
- ara_cli/chat_agent/agent_communicator.py +62 -0
- ara_cli/chat_agent/agent_process_manager.py +211 -0
- ara_cli/chat_agent/agent_status_manager.py +73 -0
- ara_cli/chat_agent/agent_workspace_manager.py +76 -0
- ara_cli/commands/__init__.py +0 -0
- ara_cli/commands/command.py +7 -0
- ara_cli/commands/extract_command.py +15 -0
- ara_cli/commands/load_command.py +65 -0
- ara_cli/commands/load_image_command.py +34 -0
- ara_cli/commands/read_command.py +117 -0
- ara_cli/completers.py +144 -0
- ara_cli/directory_navigator.py +37 -4
- ara_cli/error_handler.py +134 -0
- ara_cli/file_classifier.py +6 -5
- ara_cli/file_lister.py +1 -1
- ara_cli/file_loaders/__init__.py +0 -0
- ara_cli/file_loaders/binary_file_loader.py +33 -0
- ara_cli/file_loaders/document_file_loader.py +34 -0
- ara_cli/file_loaders/document_reader.py +245 -0
- ara_cli/file_loaders/document_readers.py +233 -0
- ara_cli/file_loaders/file_loader.py +50 -0
- ara_cli/file_loaders/file_loaders.py +123 -0
- ara_cli/file_loaders/image_processor.py +89 -0
- ara_cli/file_loaders/markdown_reader.py +75 -0
- ara_cli/file_loaders/text_file_loader.py +187 -0
- ara_cli/global_file_lister.py +51 -0
- ara_cli/list_filter.py +1 -1
- ara_cli/output_suppressor.py +1 -1
- ara_cli/prompt_extractor.py +215 -88
- ara_cli/prompt_handler.py +521 -134
- ara_cli/prompt_rag.py +2 -2
- ara_cli/tag_extractor.py +83 -38
- ara_cli/template_loader.py +245 -0
- ara_cli/template_manager.py +18 -13
- ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
- ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
- ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
- ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
- ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
- ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
- ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
- ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
- ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
- ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
- ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
- ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
- ara_cli/update_config_prompt.py +9 -3
- ara_cli/version.py +1 -1
- ara_cli-0.1.10.8.dist-info/METADATA +241 -0
- ara_cli-0.1.10.8.dist-info/RECORD +193 -0
- tests/test_ara_command_action.py +73 -59
- tests/test_ara_config.py +341 -36
- tests/test_artefact_autofix.py +1060 -0
- tests/test_artefact_link_updater.py +3 -3
- tests/test_artefact_lister.py +52 -132
- tests/test_artefact_renamer.py +2 -2
- tests/test_artefact_scan.py +327 -33
- tests/test_chat.py +2063 -498
- tests/test_file_classifier.py +24 -1
- tests/test_file_creator.py +3 -5
- tests/test_file_lister.py +1 -1
- tests/test_global_file_lister.py +131 -0
- tests/test_list_filter.py +2 -2
- tests/test_prompt_handler.py +746 -0
- tests/test_tag_extractor.py +19 -13
- tests/test_template_loader.py +192 -0
- tests/test_template_manager.py +5 -4
- tests/test_update_config_prompt.py +2 -2
- ara_cli/ara_command_parser.py +0 -327
- ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
- ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
- ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
- ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
- ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
- ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
- ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
- ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
- ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
- ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
- ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
- ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
- ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
- ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
- ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
- ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
- ara_cli/templates/template.businessgoal +0 -10
- ara_cli/templates/template.capability +0 -10
- ara_cli/templates/template.epic +0 -15
- ara_cli/templates/template.example +0 -6
- ara_cli/templates/template.feature +0 -26
- ara_cli/templates/template.issue +0 -14
- ara_cli/templates/template.keyfeature +0 -15
- ara_cli/templates/template.task +0 -6
- ara_cli/templates/template.userstory +0 -17
- ara_cli/templates/template.vision +0 -14
- ara_cli-0.1.9.69.dist-info/METADATA +0 -16
- ara_cli-0.1.9.69.dist-info/RECORD +0 -158
- tests/test_ara_autofix.py +0 -219
- {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/WHEEL +0 -0
- {ara_cli-0.1.9.69.dist-info → ara_cli-0.1.10.8.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
62
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
62
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 =
|
|
268
|
+
headers = extract_headers(lines[idx])
|
|
229
269
|
idx += 1
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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]
|
|
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
|
-
|
|
402
|
-
|
|
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]
|
|
507
|
+
line = lines[idx]
|
|
408
508
|
if line.startswith('Background:'):
|
|
409
|
-
background,
|
|
509
|
+
background, _ = Background.from_lines(lines, idx)
|
|
410
510
|
break
|
|
411
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 (
|
|
61
|
-
|
|
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
|
|
|
@@ -33,34 +33,100 @@ class ActionItem(BaseModel):
|
|
|
33
33
|
return v
|
|
34
34
|
|
|
35
35
|
@classmethod
|
|
36
|
-
def deserialize(cls,
|
|
37
|
-
if not
|
|
36
|
+
def deserialize(cls, text: str) -> Optional['ActionItem']:
|
|
37
|
+
if not text:
|
|
38
38
|
return None
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 = [
|
|
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)
|