ara-cli 0.1.10.0__py3-none-any.whl → 0.1.13.3__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.
Files changed (140) hide show
  1. ara_cli/__init__.py +51 -6
  2. ara_cli/__main__.py +270 -103
  3. ara_cli/ara_command_action.py +106 -63
  4. ara_cli/ara_config.py +187 -128
  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/config.py +221 -0
  11. ara_cli/ara_subcommands/convert.py +43 -0
  12. ara_cli/ara_subcommands/create.py +75 -0
  13. ara_cli/ara_subcommands/delete.py +22 -0
  14. ara_cli/ara_subcommands/extract.py +22 -0
  15. ara_cli/ara_subcommands/fetch.py +41 -0
  16. ara_cli/ara_subcommands/fetch_agents.py +22 -0
  17. ara_cli/ara_subcommands/fetch_scripts.py +19 -0
  18. ara_cli/ara_subcommands/fetch_templates.py +19 -0
  19. ara_cli/ara_subcommands/list.py +139 -0
  20. ara_cli/ara_subcommands/list_tags.py +25 -0
  21. ara_cli/ara_subcommands/load.py +48 -0
  22. ara_cli/ara_subcommands/prompt.py +136 -0
  23. ara_cli/ara_subcommands/read.py +47 -0
  24. ara_cli/ara_subcommands/read_status.py +20 -0
  25. ara_cli/ara_subcommands/read_user.py +20 -0
  26. ara_cli/ara_subcommands/reconnect.py +27 -0
  27. ara_cli/ara_subcommands/rename.py +22 -0
  28. ara_cli/ara_subcommands/scan.py +14 -0
  29. ara_cli/ara_subcommands/set_status.py +22 -0
  30. ara_cli/ara_subcommands/set_user.py +22 -0
  31. ara_cli/ara_subcommands/template.py +16 -0
  32. ara_cli/artefact_autofix.py +154 -63
  33. ara_cli/artefact_converter.py +256 -0
  34. ara_cli/artefact_models/artefact_model.py +106 -25
  35. ara_cli/artefact_models/artefact_templates.py +20 -10
  36. ara_cli/artefact_models/epic_artefact_model.py +11 -2
  37. ara_cli/artefact_models/feature_artefact_model.py +31 -1
  38. ara_cli/artefact_models/userstory_artefact_model.py +15 -3
  39. ara_cli/artefact_scan.py +2 -2
  40. ara_cli/chat.py +283 -80
  41. ara_cli/chat_agent/__init__.py +0 -0
  42. ara_cli/chat_agent/agent_process_manager.py +155 -0
  43. ara_cli/chat_script_runner/__init__.py +0 -0
  44. ara_cli/chat_script_runner/script_completer.py +23 -0
  45. ara_cli/chat_script_runner/script_finder.py +41 -0
  46. ara_cli/chat_script_runner/script_lister.py +36 -0
  47. ara_cli/chat_script_runner/script_runner.py +36 -0
  48. ara_cli/chat_web_search/__init__.py +0 -0
  49. ara_cli/chat_web_search/web_search.py +263 -0
  50. ara_cli/commands/agent_run_command.py +98 -0
  51. ara_cli/commands/fetch_agents_command.py +106 -0
  52. ara_cli/commands/fetch_scripts_command.py +43 -0
  53. ara_cli/commands/fetch_templates_command.py +39 -0
  54. ara_cli/commands/fetch_templates_commands.py +39 -0
  55. ara_cli/commands/list_agents_command.py +39 -0
  56. ara_cli/commands/read_command.py +17 -4
  57. ara_cli/completers.py +180 -0
  58. ara_cli/constants.py +2 -0
  59. ara_cli/directory_navigator.py +37 -4
  60. ara_cli/file_loaders/text_file_loader.py +2 -2
  61. ara_cli/global_file_lister.py +5 -15
  62. ara_cli/llm_utils.py +58 -0
  63. ara_cli/prompt_chat.py +20 -4
  64. ara_cli/prompt_extractor.py +199 -76
  65. ara_cli/prompt_handler.py +160 -59
  66. ara_cli/tag_extractor.py +38 -18
  67. ara_cli/template_loader.py +3 -2
  68. ara_cli/template_manager.py +52 -21
  69. ara_cli/templates/global-scripts/hello_global.py +1 -0
  70. ara_cli/templates/prompt-modules/commands/add_scenarios_for_new_behaviour.feature_creation_agent.commands.md +1 -0
  71. ara_cli/templates/prompt-modules/commands/align_feature_with_implementation_changes.interview_agent.commands.md +1 -0
  72. ara_cli/templates/prompt-modules/commands/analyze_codebase_and_plan_tasks.interview_agent.commands.md +1 -0
  73. ara_cli/templates/prompt-modules/commands/choose_best_parent_artefact.interview_agent.commands.md +1 -0
  74. ara_cli/templates/prompt-modules/commands/create_tasks_from_artefact_content.interview_agent.commands.md +1 -0
  75. ara_cli/templates/prompt-modules/commands/create_tests_for_uncovered_modules.test_generation_agent.commands.md +1 -0
  76. ara_cli/templates/prompt-modules/commands/derive_features_from_video_description.feature_creation_agent.commands.md +1 -0
  77. ara_cli/templates/prompt-modules/commands/describe_agent_capabilities.agent.commands.md +1 -0
  78. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  79. ara_cli/templates/prompt-modules/commands/execute_scoped_todos_in_task.interview_agent.commands.md +1 -0
  80. ara_cli/templates/prompt-modules/commands/explain_single_file_purpose.interview_agent.commands.md +1 -0
  81. ara_cli/templates/prompt-modules/commands/extract_file_information_bullets.interview_agent.commands.md +1 -0
  82. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  83. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  84. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  85. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  86. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  87. ara_cli/templates/prompt-modules/commands/fix_failing_behave_step_definitions.interview_agent.commands.md +1 -0
  88. ara_cli/templates/prompt-modules/commands/fix_failing_pytest_tests.interview_agent.commands.md +1 -0
  89. ara_cli/templates/prompt-modules/commands/general_instruction_policy.commands.md +47 -0
  90. ara_cli/templates/prompt-modules/commands/generate_and_fix_pytest_tests.test_generation_agent.commands.md +1 -0
  91. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  92. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  93. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  94. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  95. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  96. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  97. ara_cli/templates/prompt-modules/commands/suggest_next_story_child_tasks.interview_agent.commands.md +1 -0
  98. ara_cli/templates/prompt-modules/commands/summarize_or_transcribe_media.interview_agent.commands.md +1 -0
  99. ara_cli/templates/prompt-modules/commands/update_feature_to_match_implementation.feature_creation_agent.commands.md +1 -0
  100. ara_cli/templates/prompt-modules/commands/update_user_story_with_requirements.interview_agent.commands.md +1 -0
  101. ara_cli/version.py +1 -1
  102. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/METADATA +34 -1
  103. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/RECORD +123 -54
  104. tests/test_ara_command_action.py +31 -19
  105. tests/test_ara_config.py +177 -90
  106. tests/test_artefact_autofix.py +170 -97
  107. tests/test_artefact_autofix_integration.py +495 -0
  108. tests/test_artefact_converter.py +357 -0
  109. tests/test_artefact_extraction.py +564 -0
  110. tests/test_artefact_scan.py +1 -1
  111. tests/test_chat.py +162 -126
  112. tests/test_chat_givens_images.py +603 -0
  113. tests/test_chat_script_runner.py +454 -0
  114. tests/test_global_file_lister.py +1 -1
  115. tests/test_llm_utils.py +164 -0
  116. tests/test_prompt_chat.py +343 -0
  117. tests/test_prompt_extractor.py +683 -0
  118. tests/test_prompt_handler.py +12 -4
  119. tests/test_tag_extractor.py +19 -13
  120. tests/test_web_search.py +467 -0
  121. ara_cli/ara_command_parser.py +0 -605
  122. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  123. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  124. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  125. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  126. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  127. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  128. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  129. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  130. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  131. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  132. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  133. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  134. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  135. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  136. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  137. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  138. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/WHEEL +0 -0
  139. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/entry_points.txt +0 -0
  140. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,564 @@
1
+ """
2
+ Unit tests for artefact extraction functionality.
3
+
4
+ These tests cover the functionality tested by:
5
+ - _agile_artefact_extraction.feature
6
+ - agile_artefact_extraction_force.feature
7
+ - agile_artefact_extraction_override.feature
8
+ """
9
+
10
+ import pytest
11
+ import os
12
+ import tempfile
13
+ from unittest.mock import patch, MagicMock, mock_open
14
+
15
+ from ara_cli.prompt_extractor import (
16
+ _find_extract_token,
17
+ _extract_file_path,
18
+ _find_artefact_class,
19
+ _apply_replacements,
20
+ create_file_if_not_exist,
21
+ determine_should_create,
22
+ handle_existing_file,
23
+ FenceDetector,
24
+ extract_responses,
25
+ _perform_extraction_for_block,
26
+ _process_document_blocks,
27
+ )
28
+
29
+
30
+ # =============================================================================
31
+ # Tests for FenceDetector class (artefact marking functionality)
32
+ # =============================================================================
33
+
34
+
35
+ class TestFenceDetector:
36
+ """Tests for the FenceDetector class used in extraction."""
37
+
38
+ def test_is_extract_fence_with_valid_fence(self):
39
+ """Detects valid extract fence markers."""
40
+ source_lines = ["```python", "# [x] extract", "print('hello')", "```"]
41
+ detector = FenceDetector(source_lines)
42
+ assert detector.is_extract_fence(0) is True
43
+
44
+ def test_is_extract_fence_with_triple_tilde(self):
45
+ """Detects triple tilde fence markers."""
46
+ source_lines = ["~~~", "# [x] extract", "some content", "~~~"]
47
+ detector = FenceDetector(source_lines)
48
+ assert detector.is_extract_fence(0) is True
49
+
50
+ def test_is_extract_fence_without_extract_marker(self):
51
+ """Returns False for fence without extract marker."""
52
+ source_lines = ["```python", "print('hello')", "```"]
53
+ detector = FenceDetector(source_lines)
54
+ assert detector.is_extract_fence(0) is False
55
+
56
+ def test_is_extract_fence_non_fence_line(self):
57
+ """Returns False for non-fence lines."""
58
+ source_lines = ["some regular text", "# [x] extract"]
59
+ detector = FenceDetector(source_lines)
60
+ assert detector.is_extract_fence(0) is False
61
+
62
+ def test_find_matching_fence_end_simple(self):
63
+ """Finds matching end fence for simple block."""
64
+ source_lines = ["```", "# [x] extract", "content", "```"]
65
+ detector = FenceDetector(source_lines)
66
+ assert detector.find_matching_fence_end(0) == 3
67
+
68
+ def test_find_matching_fence_end_with_language(self):
69
+ """Finds matching end fence with language specifier."""
70
+ source_lines = ["```python", "# [x] extract", "print('hello')", "```"]
71
+ detector = FenceDetector(source_lines)
72
+ assert detector.find_matching_fence_end(0) == 3
73
+
74
+ def test_find_matching_fence_end_indented(self):
75
+ """Finds matching end fence for indented blocks."""
76
+ source_lines = [" ```", " # [x] extract", " content", " ```"]
77
+ detector = FenceDetector(source_lines)
78
+ assert detector.find_matching_fence_end(0) == 3
79
+
80
+ def test_find_matching_fence_end_no_match(self):
81
+ """Returns -1 when no matching fence end found."""
82
+ source_lines = ["```", "# [x] extract", "content without closing fence"]
83
+ detector = FenceDetector(source_lines)
84
+ assert detector.find_matching_fence_end(0) == -1
85
+
86
+
87
+ # =============================================================================
88
+ # Tests for extraction helper functions
89
+ # =============================================================================
90
+
91
+
92
+ class TestExtractionHelpers:
93
+ """Tests for extraction helper functions."""
94
+
95
+ def test_extract_file_path_valid(self):
96
+ """Extracts file path from content lines."""
97
+ content_lines = ["# filename: path/to/file.py", "content"]
98
+ result = _extract_file_path(content_lines)
99
+ assert result == "path/to/file.py"
100
+
101
+ def test_extract_file_path_with_spaces(self):
102
+ """Extracts file path with surrounding spaces."""
103
+ content_lines = ["# filename: file.txt ", "content"]
104
+ result = _extract_file_path(content_lines)
105
+ assert result == "file.txt"
106
+
107
+ def test_extract_file_path_no_match(self):
108
+ """Returns None when no filename found."""
109
+ content_lines = ["no filename here", "content"]
110
+ result = _extract_file_path(content_lines)
111
+ assert result is None
112
+
113
+ def test_extract_file_path_empty_lines(self):
114
+ """Returns None for empty content lines."""
115
+ result = _extract_file_path([])
116
+ assert result is None
117
+
118
+ def test_apply_replacements_single(self):
119
+ """Applies single replacement correctly."""
120
+ content = "# [x] extract\ncode here"
121
+ replacements = [("# [x] extract", "# [v] extract")]
122
+ result = _apply_replacements(content, replacements)
123
+ assert "# [v] extract" in result
124
+ assert "# [x] extract" not in result
125
+
126
+ def test_apply_replacements_multiple(self):
127
+ """Applies multiple replacements correctly."""
128
+ content = "```\n# [x] extract\ncode1\n```\n\n```\n# [x] extract\ncode2\n```"
129
+ replacements = [
130
+ ("```\n# [x] extract\ncode1\n```", "```\n# [v] extract\ncode1\n```"),
131
+ ("```\n# [x] extract\ncode2\n```", "```\n# [v] extract\ncode2\n```"),
132
+ ]
133
+ result = _apply_replacements(content, replacements)
134
+ assert result.count("# [v] extract") == 2
135
+ assert "# [x] extract" not in result
136
+
137
+
138
+ # =============================================================================
139
+ # Tests for file creation logic (force flag functionality)
140
+ # =============================================================================
141
+
142
+
143
+ class TestFileCreation:
144
+ """Tests for file creation with force flag."""
145
+
146
+ @patch("builtins.input", return_value="y")
147
+ def test_determine_should_create_with_user_confirmation(self, mock_input):
148
+ """User confirms file creation."""
149
+ result = determine_should_create(skip_query=False)
150
+ assert result is True
151
+ mock_input.assert_called_once()
152
+
153
+ @patch("builtins.input", return_value="n")
154
+ def test_determine_should_create_user_declines(self, mock_input):
155
+ """User declines file creation."""
156
+ result = determine_should_create(skip_query=False)
157
+ assert result is False
158
+
159
+ def test_determine_should_create_skips_query_when_force(self):
160
+ """Force flag bypasses user confirmation."""
161
+ result = determine_should_create(skip_query=True)
162
+ assert result is True
163
+
164
+ @patch("builtins.input", return_value="y")
165
+ def test_create_file_if_not_exist_creates_file(self, mock_input):
166
+ """Creates file when it doesn't exist and user confirms."""
167
+ with tempfile.TemporaryDirectory() as tmpdir:
168
+ file_path = os.path.join(tmpdir, "new_file.txt")
169
+ content = "test content"
170
+
171
+ create_file_if_not_exist(file_path, content, skip_query=False)
172
+
173
+ assert os.path.exists(file_path)
174
+ with open(file_path, "r") as f:
175
+ assert f.read() == content
176
+
177
+ def test_create_file_if_not_exist_with_force(self):
178
+ """Creates file without user prompt when force is True."""
179
+ with tempfile.TemporaryDirectory() as tmpdir:
180
+ file_path = os.path.join(tmpdir, "new_file.txt")
181
+ content = "test content"
182
+
183
+ create_file_if_not_exist(file_path, content, skip_query=True)
184
+
185
+ assert os.path.exists(file_path)
186
+ with open(file_path, "r") as f:
187
+ assert f.read() == content
188
+
189
+ def test_create_file_if_not_exist_creates_directories(self):
190
+ """Creates parent directories if they don't exist."""
191
+ with tempfile.TemporaryDirectory() as tmpdir:
192
+ file_path = os.path.join(tmpdir, "subdir", "deep", "file.txt")
193
+ content = "nested content"
194
+
195
+ create_file_if_not_exist(file_path, content, skip_query=True)
196
+
197
+ assert os.path.exists(file_path)
198
+ with open(file_path, "r") as f:
199
+ assert f.read() == content
200
+
201
+
202
+ # =============================================================================
203
+ # Tests for handle_existing_file (override functionality)
204
+ # =============================================================================
205
+
206
+
207
+ class TestHandleExistingFile:
208
+ """Tests for handling existing files during extraction."""
209
+
210
+ @patch("builtins.input", return_value="y")
211
+ def test_handle_existing_file_creates_when_not_exists(self, mock_input):
212
+ """Creates file when it doesn't exist."""
213
+ with tempfile.TemporaryDirectory() as tmpdir:
214
+ file_path = os.path.join(tmpdir, "new_file.txt")
215
+ content = "new content"
216
+
217
+ handle_existing_file(file_path, content, skip_query=False, write=False)
218
+
219
+ assert os.path.exists(file_path)
220
+
221
+ def test_handle_existing_file_creates_with_force(self):
222
+ """Creates file with force flag (skip_query=True)."""
223
+ with tempfile.TemporaryDirectory() as tmpdir:
224
+ file_path = os.path.join(tmpdir, "new_file.txt")
225
+ content = "new content"
226
+
227
+ handle_existing_file(file_path, content, skip_query=True, write=False)
228
+
229
+ assert os.path.exists(file_path)
230
+ with open(file_path, "r") as f:
231
+ assert f.read() == content
232
+
233
+ def test_handle_existing_file_overwrites_with_write_flag(self):
234
+ """Overwrites existing file when write flag is True."""
235
+ with tempfile.TemporaryDirectory() as tmpdir:
236
+ file_path = os.path.join(tmpdir, "existing.txt")
237
+
238
+ # Create existing file
239
+ with open(file_path, "w") as f:
240
+ f.write("old content")
241
+
242
+ new_content = "new overwritten content"
243
+ handle_existing_file(file_path, new_content, skip_query=False, write=True)
244
+
245
+ with open(file_path, "r") as f:
246
+ assert f.read() == new_content
247
+
248
+
249
+ # =============================================================================
250
+ # Tests for extract_responses (full extraction workflow)
251
+ # =============================================================================
252
+
253
+
254
+ class TestExtractResponses:
255
+ """Tests for the main extract_responses function."""
256
+
257
+ def test_extract_responses_marks_blocks(self):
258
+ """Extracted blocks are marked with [v] instead of [x]."""
259
+ with tempfile.TemporaryDirectory() as tmpdir:
260
+ chat_file = os.path.join(tmpdir, "chat.md")
261
+ target_file = os.path.join(tmpdir, "output.txt")
262
+
263
+ content = f"""# ara prompt:
264
+ Some conversation
265
+
266
+ ```
267
+ # [x] extract
268
+ # filename: {target_file}
269
+ print('hello world')
270
+ ```
271
+
272
+ More conversation
273
+ """
274
+ with open(chat_file, "w") as f:
275
+ f.write(content)
276
+
277
+ extract_responses(chat_file, relative_to_ara_root=False, force=True)
278
+
279
+ with open(chat_file, "r") as f:
280
+ updated_content = f.read()
281
+
282
+ assert "# [v] extract" in updated_content
283
+ assert "# [x] extract" not in updated_content
284
+
285
+ def test_extract_responses_creates_target_file(self):
286
+ """Target file is created with extracted content."""
287
+ with tempfile.TemporaryDirectory() as tmpdir:
288
+ chat_file = os.path.join(tmpdir, "chat.md")
289
+ target_file = os.path.join(tmpdir, "output.py")
290
+
291
+ content = f"""```
292
+ # [x] extract
293
+ # filename: {target_file}
294
+ print('hello world')
295
+ ```
296
+ """
297
+ with open(chat_file, "w") as f:
298
+ f.write(content)
299
+
300
+ extract_responses(chat_file, relative_to_ara_root=False, force=True)
301
+
302
+ assert os.path.exists(target_file)
303
+ with open(target_file, "r") as f:
304
+ assert "print('hello world')" in f.read()
305
+
306
+ def test_extract_responses_handles_missing_file(self, capsys):
307
+ """Handles missing document file gracefully."""
308
+ extract_responses("nonexistent_file.md", force=True)
309
+ captured = capsys.readouterr()
310
+ assert "File not found" in captured.out
311
+
312
+ def test_extract_responses_multiple_blocks(self):
313
+ """Extracts multiple blocks from same document."""
314
+ with tempfile.TemporaryDirectory() as tmpdir:
315
+ chat_file = os.path.join(tmpdir, "chat.md")
316
+ file1 = os.path.join(tmpdir, "file1.txt")
317
+ file2 = os.path.join(tmpdir, "file2.txt")
318
+
319
+ content = f"""```
320
+ # [x] extract
321
+ # filename: {file1}
322
+ content 1
323
+ ```
324
+
325
+ ```
326
+ # [x] extract
327
+ # filename: {file2}
328
+ content 2
329
+ ```
330
+ """
331
+ with open(chat_file, "w") as f:
332
+ f.write(content)
333
+
334
+ extract_responses(chat_file, relative_to_ara_root=False, force=True)
335
+
336
+ assert os.path.exists(file1)
337
+ assert os.path.exists(file2)
338
+
339
+ with open(chat_file, "r") as f:
340
+ updated_content = f.read()
341
+ assert updated_content.count("# [v] extract") == 2
342
+
343
+ @pytest.mark.parametrize(
344
+ "classifier, template_body",
345
+ [
346
+ ("businessgoal", "Businessgoal: sample businessgoal\nIn order to impress\nAs a person\nI want world domination\nDescription:"),
347
+ ("capability", "Capability: sample capability\nContributes to\nTo be able to do things\nDescription:"),
348
+ ("epic", "Epic: sample epic\nIn order to make criminals think twice before breaking the law\nAs a Batman\nI want all the gadgets\nDescription:"),
349
+ ("example", "Example: sample example\nIllustrates\nDescription:"),
350
+ ("feature", "Feature: sample feature\nAs a Batman\nI want to inspire fear\nSo that criminals don't break the law in the first place\nContributes to\nDescription:"),
351
+ ("issue", "Issue: sample issue\nContributes to\nadditional description here\nDescription:"),
352
+ ("keyfeature", "Keyfeature: sample keyfeature\nIn order to impress\nAs a person\nI want world domination\nDescription:"),
353
+ ("task", "Task: sample task\nContributes to\nDescription:"),
354
+ ("userstory", "Userstory: sample userstory\nAs a user\nI want to do things\nSo that valid\nDescription:"),
355
+ ("vision", "Vision: sample vision\nContributes to\nFor blah\nWho blahs\nThe blah is a blah\nThat blah\nUnlike blah\nOur product blah\nDescription:"),
356
+ ],
357
+ )
358
+ def test_extract_responses_all_types(self, classifier, template_body):
359
+ """Extracts all supported artefact types correctly."""
360
+ with tempfile.TemporaryDirectory() as tmpdir:
361
+ chat_file = os.path.join(tmpdir, "chat.md")
362
+
363
+ target_file = os.path.join(tmpdir, "ara", f"{classifier}s", f"sample_{classifier}.{classifier}")
364
+
365
+ # Ensure target directory exists for extraction logic that doesn't create it recursively in all paths?
366
+ # Actually extract_responses creates directories.
367
+
368
+ content = f"""# ara prompt:
369
+ Chat content
370
+
371
+ ```
372
+ # [x] extract
373
+ # filename: {target_file}
374
+ @creator_unknown
375
+ {template_body}
376
+ ```
377
+
378
+ End chat
379
+ """
380
+ with open(chat_file, "w") as f:
381
+ f.write(content)
382
+
383
+ extract_responses(chat_file, relative_to_ara_root=False, force=True)
384
+
385
+ assert os.path.exists(target_file), f"Failed to create {classifier} file"
386
+
387
+ with open(target_file, "r") as f:
388
+ extracted_content = f.read()
389
+
390
+ # Verify some key part of the content exists
391
+ assert f"sample {classifier}" in extracted_content
392
+
393
+ with open(chat_file, "r") as f:
394
+ updated_chat = f.read()
395
+
396
+ assert "# [v] extract" in updated_chat
397
+
398
+
399
+ # =============================================================================
400
+ # Tests for process_document_blocks
401
+ # =============================================================================
402
+
403
+
404
+ class TestProcessDocumentBlocks:
405
+ """Tests for processing document blocks."""
406
+
407
+ def test_process_document_blocks_returns_replacements(self):
408
+ """Returns list of replacements for extracted blocks."""
409
+ with tempfile.TemporaryDirectory() as tmpdir:
410
+ original_dir = os.getcwd()
411
+ os.chdir(tmpdir)
412
+
413
+ try:
414
+ source_lines = [
415
+ "```",
416
+ "# [x] extract",
417
+ "# filename: test_output.txt",
418
+ "test content",
419
+ "```",
420
+ ]
421
+
422
+ replacements = _process_document_blocks(
423
+ source_lines, force=True, write=False
424
+ )
425
+
426
+ assert len(replacements) == 1
427
+ original, modified = replacements[0]
428
+ assert "# [x] extract" in original
429
+ assert "# [v] extract" in modified
430
+ finally:
431
+ os.chdir(original_dir)
432
+
433
+ def test_process_document_blocks_no_extract_markers(self):
434
+ """Returns empty list when no extract markers found."""
435
+ source_lines = ["```python", "print('hello')", "```"]
436
+
437
+ replacements = _process_document_blocks(source_lines, force=True, write=False)
438
+ assert replacements == []
439
+
440
+
441
+ # =============================================================================
442
+ # Tests for artefact class detection
443
+ # =============================================================================
444
+
445
+
446
+ class TestArtefactClassDetection:
447
+ """Tests for detecting artefact class from content."""
448
+
449
+ def test_find_artefact_class_feature(self):
450
+ """Detects Feature artefact class."""
451
+ from ara_cli.artefact_models.feature_artefact_model import FeatureArtefact
452
+
453
+ content_lines = ["Feature: sample feature", "As a user"]
454
+ result = _find_artefact_class(content_lines)
455
+ assert result == FeatureArtefact
456
+
457
+ def test_find_artefact_class_task(self):
458
+ """Detects Task artefact class."""
459
+ from ara_cli.artefact_models.task_artefact_model import TaskArtefact
460
+
461
+ content_lines = ["Task: sample task", "Contributes to"]
462
+ result = _find_artefact_class(content_lines)
463
+ assert result == TaskArtefact
464
+
465
+ def test_find_artefact_class_with_tag(self):
466
+ """Detects artefact class even with tags on first line."""
467
+ from ara_cli.artefact_models.feature_artefact_model import FeatureArtefact
468
+
469
+ content_lines = ["@creator_unknown", "Feature: sample feature"]
470
+ result = _find_artefact_class(content_lines)
471
+ assert result == FeatureArtefact
472
+
473
+ def test_find_artefact_class_unknown(self):
474
+ """Returns None for unknown artefact type."""
475
+ content_lines = ["Unknown: something", "random content"]
476
+ result = _find_artefact_class(content_lines)
477
+ assert result is None
478
+
479
+
480
+ # =============================================================================
481
+ # Tests for extraction with different artefact types
482
+ # =============================================================================
483
+
484
+
485
+ class TestArtefactTypeExtraction:
486
+ """Tests extraction for different artefact types (businessgoal, capability, etc.)."""
487
+
488
+ @pytest.fixture
489
+ def temp_ara_structure(self):
490
+ """Creates a temporary ara directory structure."""
491
+ with tempfile.TemporaryDirectory() as tmpdir:
492
+ ara_dir = os.path.join(tmpdir, "ara")
493
+ os.makedirs(os.path.join(ara_dir, "businessgoals"))
494
+ os.makedirs(os.path.join(ara_dir, "capabilities"))
495
+ os.makedirs(os.path.join(ara_dir, "epics"))
496
+ os.makedirs(os.path.join(ara_dir, "features"))
497
+ os.makedirs(os.path.join(ara_dir, "tasks"))
498
+ yield tmpdir
499
+
500
+ @pytest.mark.parametrize(
501
+ "classifier,prefix",
502
+ [
503
+ ("businessgoal", "Businessgoal:"),
504
+ ("capability", "Capability:"),
505
+ ("epic", "Epic:"),
506
+ ("feature", "Feature:"),
507
+ ("task", "Task:"),
508
+ ("keyfeature", "Keyfeature:"),
509
+ ("userstory", "Userstory:"),
510
+ ("vision", "Vision:"),
511
+ ("example", "Example:"),
512
+ ("issue", "Issue:"),
513
+ ],
514
+ )
515
+ def test_artefact_prefix_recognition(self, classifier, prefix):
516
+ """Recognizes different artefact type prefixes."""
517
+ content_lines = [f"{prefix} sample {classifier}", "Contributes to"]
518
+ # Just verify it doesn't crash - detailed validation is done in artefact model tests
519
+ result = _find_artefact_class(content_lines)
520
+ # Some artefact types may not be in the mapping
521
+ # This test ensures we don't crash during recognition
522
+
523
+
524
+ # =============================================================================
525
+ # Tests for find_extract_token (markdown parsing)
526
+ # =============================================================================
527
+
528
+
529
+ class TestFindExtractToken:
530
+ """Tests for finding extract tokens in markdown."""
531
+
532
+ def test_find_extract_token_found(self):
533
+ """Finds extract token in tokens list."""
534
+ from markdown_it import MarkdownIt
535
+
536
+ md = MarkdownIt()
537
+ content = "```\n# [x] extract\ncontent\n```"
538
+ tokens = md.parse(content)
539
+
540
+ result = _find_extract_token(tokens)
541
+ assert result is not None
542
+ assert "# [x] extract" in result.content
543
+
544
+ def test_find_extract_token_not_found(self):
545
+ """Returns None when no extract token present."""
546
+ from markdown_it import MarkdownIt
547
+
548
+ md = MarkdownIt()
549
+ content = "```\nsome code\n```"
550
+ tokens = md.parse(content)
551
+
552
+ result = _find_extract_token(tokens)
553
+ assert result is None
554
+
555
+ def test_find_extract_token_with_regular_text(self):
556
+ """Returns None for regular text without code blocks."""
557
+ from markdown_it import MarkdownIt
558
+
559
+ md = MarkdownIt()
560
+ content = "Just some regular markdown text."
561
+ tokens = md.parse(content)
562
+
563
+ result = _find_extract_token(tokens)
564
+ assert result is None
@@ -132,7 +132,7 @@ def test_is_rule_valid_rule_is_none():
132
132
  "parent,expected",
133
133
  [
134
134
  (None, True), # parent is None
135
- (MagicMock(rules=None), True), # parent.rules is None
135
+ (MagicMock(rules=None), False), # parent.rules is None
136
136
  ],
137
137
  )
138
138
  def test_is_rule_valid_parent_or_rules_none(parent, expected):