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

@@ -575,6 +575,7 @@ def scan_action(args):
575
575
 
576
576
  def autofix_action(args):
577
577
  from ara_cli.artefact_autofix import parse_report, apply_autofix, read_report_file
578
+ from ara_cli.file_classifier import FileClassifier
578
579
 
579
580
  # If the user passes --non-deterministic, only_deterministic_fix becomes False.
580
581
  # If the user passes --deterministic, only_non_deterministic_fix becomes False.
@@ -591,6 +592,9 @@ def autofix_action(args):
591
592
  print("No issues found in the report. Nothing to fix.")
592
593
  return
593
594
 
595
+ file_classifier = FileClassifier(os)
596
+ classified_artefact_info = file_classifier.classify_files()
597
+
594
598
  # print("\nStarting autofix process...")
595
599
  for classifier, files in issues.items():
596
600
  print(f"\nClassifier: {classifier}")
@@ -600,8 +604,8 @@ def autofix_action(args):
600
604
  classifier,
601
605
  reason,
602
606
  deterministic=run_deterministic,
603
- non_deterministic=run_non_deterministic
607
+ non_deterministic=run_non_deterministic,
608
+ classified_artefact_info=classified_artefact_info
604
609
  )
605
610
 
606
611
  print("\nAutofix process completed. Please review the changes.")
607
-
ara_cli/ara_config.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import List, Dict, Union, Optional
1
+ from typing import List, Dict, Optional
2
2
  from pydantic import BaseModel
3
3
  import json
4
4
  import os
@@ -10,6 +10,13 @@ from functools import lru_cache
10
10
  DEFAULT_CONFIG_LOCATION = "./ara/.araconfig/ara_config.json"
11
11
 
12
12
 
13
+ class LLMConfigItem(BaseModel):
14
+ provider: str
15
+ model: str
16
+ temperature: float
17
+ max_tokens: Optional[int] = None
18
+
19
+
13
20
  class ARAconfig(BaseModel):
14
21
  ext_code_dirs: List[Dict[str, str]] = [
15
22
  {"source_dir_1": "./src"},
@@ -36,42 +43,49 @@ class ARAconfig(BaseModel):
36
43
  "*.jpg",
37
44
  "*.jpeg",
38
45
  ]
39
- llm_config: Dict[str, Dict[str, Union[str, float]]] = {
46
+ llm_config: Dict[str, LLMConfigItem] = {
40
47
  "gpt-4o": {
41
48
  "provider": "openai",
42
49
  "model": "openai/gpt-4o",
43
- "temperature": 0.8
50
+ "temperature": 0.8,
51
+ "max_tokens": 16384
44
52
  },
45
53
  "gpt-4.1": {
46
54
  "provider": "openai",
47
55
  "model": "openai/gpt-4.1",
48
56
  "temperature": 0.8,
57
+ "max_tokens": 1024
49
58
  },
50
59
  "o3-mini": {
51
60
  "provider": "openai",
52
61
  "model": "openai/o3-mini",
53
62
  "temperature": 1.0,
63
+ "max_tokens": 1024
54
64
  },
55
65
  "opus-4": {
56
66
  "provider": "anthropic",
57
67
  "model": "anthropic/claude-opus-4-20250514",
58
68
  "temperature": 0.8,
69
+ "max_tokens": 32000
59
70
  },
60
71
  "sonnet-4": {
61
72
  "provider": "anthropic",
62
73
  "model": "anthropic/claude-sonnet-4-20250514",
63
74
  "temperature": 0.8,
75
+ "max_tokens": 1024
64
76
  },
65
77
  "together-ai-llama-2": {
66
78
  "provider": "together_ai",
67
79
  "model": "together_ai/togethercomputer/llama-2-70b",
68
80
  "temperature": 0.8,
81
+ "max_tokens": 1024
69
82
  },
70
83
  "groq-llama-3": {
71
84
  "provider": "groq",
72
85
  "model": "groq/llama3-70b-8192",
73
86
  "temperature": 0.8,
74
- },
87
+ "max_tokens": 1024
88
+ }
75
89
  }
76
90
  default_llm: Optional[str] = "gpt-4o"
77
91
 
@@ -99,7 +113,7 @@ def read_data(filepath: str) -> ARAconfig:
99
113
  default_config = ARAconfig()
100
114
 
101
115
  with open(filepath, "w") as file:
102
- json.dump(default_config.model_dump(), file, indent=4)
116
+ json.dump(default_config.model_dump(mode='json'), file, indent=4)
103
117
 
104
118
  print(
105
119
  f"ara-cli configuration file '{filepath}' created with default configuration. Please modify it as needed and re-run your command"
@@ -113,7 +127,7 @@ def read_data(filepath: str) -> ARAconfig:
113
127
  # Function to save the modified configuration back to the JSON file
114
128
  def save_data(filepath: str, config: ARAconfig):
115
129
  with open(filepath, "w") as file:
116
- json.dump(config.dict(), file, indent=4)
130
+ json.dump(config.model_dump(mode='json'), file, indent=4)
117
131
 
118
132
 
119
133
  # Singleton for configuration management
@@ -129,4 +143,4 @@ class ConfigManager:
129
143
  makedirs(config_dir)
130
144
 
131
145
  cls._config_instance = read_data(filepath)
132
- return cls._config_instance
146
+ return cls._config_instance
@@ -1,5 +1,15 @@
1
+ from ara_cli.artefact_fuzzy_search import (
2
+ find_closest_name_matches,
3
+ extract_artefact_names_of_classifier,
4
+ )
5
+ from ara_cli.file_classifier import FileClassifier
6
+ from ara_cli.artefact_reader import ArtefactReader
7
+ from ara_cli.artefact_models.artefact_load import artefact_from_content
8
+ from ara_cli.artefact_models.artefact_model import Artefact
9
+ from typing import Optional, Dict, List, Tuple
10
+ import difflib
1
11
  import os
2
- from typing import Dict, List, Tuple
12
+
3
13
 
4
14
  def read_report_file():
5
15
  file_path = "incompatible_artefacts_report.md"
@@ -7,7 +17,9 @@ def read_report_file():
7
17
  with open(file_path, "r", encoding="utf-8") as f:
8
18
  content = f.read()
9
19
  except OSError:
10
- print('Artefact scan results file not found. Did you run the "ara scan" command?')
20
+ print(
21
+ 'Artefact scan results file not found. Did you run the "ara scan" command?'
22
+ )
11
23
  return None
12
24
  return content
13
25
 
@@ -23,9 +35,11 @@ def parse_report(content: str) -> Dict[str, List[Tuple[str, str]]]:
23
35
 
24
36
  if not lines or lines[0] != "# Artefact Check Report":
25
37
  return issues
38
+ return issues
26
39
 
27
40
  if len(lines) >= 3 and lines[2] == "No problems found.":
28
41
  return issues
42
+ return issues
29
43
 
30
44
  for line in lines[1:]:
31
45
  line = line.strip()
@@ -51,7 +65,7 @@ def parse_report(content: str) -> Dict[str, List[Tuple[str, str]]]:
51
65
  def read_artefact(file_path):
52
66
  """Reads the artefact text from the given file path."""
53
67
  try:
54
- with open(file_path, 'r', encoding="utf-8") as file:
68
+ with open(file_path, "r", encoding="utf-8") as file:
55
69
  return file.read()
56
70
  except FileNotFoundError:
57
71
  print(f"File not found: {file_path}")
@@ -84,7 +98,7 @@ def construct_prompt(artefact_type, reason, file_path, artefact_text):
84
98
  "Provide the corrected artefact. Do not reformulate the artefact, "
85
99
  "just fix the pydantic model errors, use correct grammar. "
86
100
  "You should follow the name of the file "
87
- f"from its path {file_path} for naming the arteafact's title. "
101
+ f"from its path {file_path} for naming the artefact's title. "
88
102
  "You are not allowed to use file extention in the artefact title. "
89
103
  "You are not allowed to modify, delete or add tags. "
90
104
  "User tag should be '@user_<username>'. The pydantic model already provides the '@user_' prefix. "
@@ -97,43 +111,234 @@ def construct_prompt(artefact_type, reason, file_path, artefact_text):
97
111
  "then just delete those action items."
98
112
  )
99
113
 
100
- prompt += (
101
- "\nThe current artefact is:\n"
102
- "```\n"
103
- f"{artefact_text}\n"
104
- "```"
105
- )
114
+ prompt += "\nThe current artefact is:\n" "```\n" f"{artefact_text}\n" "```"
106
115
 
107
116
  return prompt
108
117
 
109
118
 
110
119
  def run_agent(prompt, artefact_class):
111
120
  from pydantic_ai import Agent
121
+
112
122
  # gpt-4o
113
123
  # anthropic:claude-3-7-sonnet-20250219
114
124
  # anthropic:claude-4-sonnet-20250514
115
- agent = Agent(model="anthropic:claude-4-sonnet-20250514",
116
- result_type=artefact_class, instrument=True)
125
+ agent = Agent(
126
+ model="anthropic:claude-4-sonnet-20250514",
127
+ result_type=artefact_class,
128
+ instrument=True,
129
+ )
117
130
  result = agent.run_sync(prompt)
118
131
  return result.data
119
132
 
120
133
 
121
134
  def write_corrected_artefact(file_path, corrected_text):
122
- with open(file_path, 'w', encoding="utf-8") as file:
135
+ with open(file_path, "w", encoding="utf-8") as file:
123
136
  file.write(corrected_text)
124
137
  print(f"Fixed artefact at {file_path}")
125
138
 
126
139
 
127
- def fix_title_mismatch(file_path: str, artefact_text: str, artefact_class) -> str:
140
+ def ask_for_correct_contribution(
141
+ artefact_info: Optional[tuple[str, str]] = None
142
+ ) -> tuple[str, str]:
143
+ """
144
+ Ask the user to provide a valid contribution when no match can be found.
145
+
146
+ Args:
147
+ artefact_info: Optional tuple containing (artefact_name, artefact_classifier)
148
+
149
+ Returns:
150
+ A tuple of (name, classifier) for the contribution
151
+ """
152
+
153
+ artefact_name, artefact_classifier = (
154
+ artefact_info if artefact_info else (None, None)
155
+ )
156
+ contribution_message = (
157
+ f"of {artefact_classifier} artefact '{artefact_name}'" if artefact_name else ""
158
+ )
159
+
160
+ print(
161
+ f"Can not determine a match for contribution {contribution_message}. "
162
+ f"Please provide a valid contribution or contribution will be empty (<classifier> <file_name>)."
163
+ )
164
+
165
+ user_input = input().strip()
166
+
167
+ if not user_input:
168
+ return None, None
169
+
170
+ parts = user_input.split(maxsplit=1)
171
+ if len(parts) != 2:
172
+ print("Invalid input format. Expected: <classifier> <file_name>")
173
+ return None, None
174
+
175
+ classifier, name = parts
176
+ return name, classifier
177
+
178
+
179
+ def ask_for_contribution_choice(
180
+ choices, artefact_info: Optional[tuple[str, str]] = None
181
+ ) -> Optional[str]:
182
+ artefact_name, artefact_classifier = artefact_info
183
+ message = "Found multiple close matches for the contribution"
184
+ if artefact_name and artefact_classifier:
185
+ message += f" of the {artefact_classifier} '{artefact_name}'"
186
+ print(f"{message}.")
187
+ for i, contribution in enumerate(choices):
188
+ print(f"{i + 1}: {contribution}")
189
+ choice_number = input(
190
+ "Please choose the artefact to use for contribution (enter number): "
191
+ )
192
+ try:
193
+ choice_index = int(choice_number) - 1
194
+ if choice_index < 0 or choice_index >= len(choices):
195
+ print("Invalid choice. Aborting contribution choice.")
196
+ return None
197
+ choice = choices[choice_index]
198
+ except ValueError:
199
+ print("Invalid input. Aborting contribution choice.")
200
+ return None
201
+ return choice
202
+
203
+
204
+ def _has_valid_contribution(artefact: Artefact) -> bool:
205
+ contribution = artefact.contribution
206
+ return contribution and contribution.artefact_name and contribution.classifier
207
+
208
+
209
+ def _update_rule(
210
+ artefact: Artefact, name: str, classifier: str, classified_file_info: dict
211
+ ) -> None:
212
+ """Updates the rule in the contribution if a close match is found."""
213
+ rule = artefact.contribution.rule
214
+
215
+ content, artefact_data = ArtefactReader.read_artefact(
216
+ artefact_name=name,
217
+ classifier=classifier,
218
+ classified_file_info=classified_file_info,
219
+ )
220
+
221
+ parent = artefact_from_content(content=content)
222
+ rules = parent.rules
223
+
224
+ closest_rule_match = difflib.get_close_matches(rule, rules, cutoff=0.5)
225
+ if closest_rule_match:
226
+ artefact.contribution.rule = closest_rule_match[0]
227
+
228
+
229
+ def _set_contribution_multiple_matches(
230
+ artefact: Artefact,
231
+ closest_matches: list,
232
+ artefact_tuple: tuple,
233
+ classified_file_info: dict,
234
+ ) -> tuple[Artefact, bool]:
235
+ contribution = artefact.contribution
236
+ classifier = contribution.classifier
237
+ original_name = contribution.artefact_name
238
+
239
+ closest_match = closest_matches[0]
240
+ if len(closest_matches) > 1:
241
+ closest_match = ask_for_contribution_choice(closest_matches, artefact_tuple)
242
+
243
+ if not closest_match:
244
+ print(
245
+ f"Contribution of {artefact_tuple[1]} '{artefact_tuple[0]}' will be empty."
246
+ )
247
+ artefact.contribution = None
248
+ return artefact, True
249
+
250
+ print(
251
+ f"Updating contribution of {artefact_tuple[1]} '{artefact_tuple[0]}' to {classifier} '{closest_match}'"
252
+ )
253
+ contribution.artefact_name = closest_match
254
+ artefact.contribution = contribution
255
+
256
+ if contribution.rule:
257
+ _update_rule(artefact, original_name, classifier, classified_file_info)
258
+
259
+ return artefact, True
260
+
261
+
262
+ def set_closest_contribution(
263
+ artefact: Artefact, classified_file_info=None
264
+ ) -> tuple[Artefact, bool]:
265
+ if not _has_valid_contribution(artefact):
266
+ return artefact, False
267
+ contribution = artefact.contribution
268
+ name = contribution.artefact_name
269
+ classifier = contribution.classifier
270
+ rule = contribution.rule
271
+
272
+ if not classified_file_info:
273
+ file_classifier = FileClassifier(os)
274
+ classified_file_info = file_classifier.classify_files()
275
+
276
+ all_artefact_names = extract_artefact_names_of_classifier(
277
+ classified_files=classified_file_info, classifier=classifier
278
+ )
279
+ closest_matches = find_closest_name_matches(
280
+ artefact_name=name, all_artefact_names=all_artefact_names
281
+ )
282
+
283
+ artefact_tuple = (artefact.title, artefact._artefact_type().value)
284
+
285
+ if not closest_matches:
286
+ name, classifier = ask_for_correct_contribution(artefact_tuple)
287
+ if not name or not classifier:
288
+ artefact.contribution = None
289
+ return artefact, True
290
+ print(f"Updating contribution of {artefact._artefact_type().value} '{artefact.title}' to {classifier} '{name}'")
291
+ contribution.artefact_name = name
292
+ contribution.classifier = classifier
293
+ artefact.contribution = contribution
294
+ return artefact, True
295
+
296
+ if closest_matches[0] == name:
297
+ return artefact, False
298
+
299
+ return _set_contribution_multiple_matches(
300
+ artefact=artefact,
301
+ closest_matches=closest_matches,
302
+ artefact_tuple=artefact_tuple,
303
+ classified_file_info=classified_file_info,
304
+ )
305
+
306
+ print(
307
+ f"Updating contribution of {artefact._artefact_type().value} '{artefact.title}' to {classifier} '{closest_match}'"
308
+ )
309
+ contribution.artefact_name = closest_match
310
+ artefact.contribution = contribution
311
+
312
+ if not rule:
313
+ return artefact, True
314
+
315
+ content, artefact = ArtefactReader.read_artefact(
316
+ artefact_name=name,
317
+ classifier=classifier,
318
+ classified_file_info=classified_file_info,
319
+ )
320
+ parent = artefact_from_content(content=content)
321
+ rules = parent.rules
322
+
323
+ closest_rule_match = difflib.get_close_matches(rule, rules, cutoff=0.5)
324
+ if closest_rule_match:
325
+ contribution.rule = closest_rule_match
326
+ artefact.contribution = contribution
327
+ return artefact, True
328
+
329
+
330
+ def fix_title_mismatch(
331
+ file_path: str, artefact_text: str, artefact_class, **kwargs
332
+ ) -> str:
128
333
  """
129
334
  Deterministically fixes the title in the artefact text to match the filename.
130
335
  """
131
336
  base_name = os.path.basename(file_path)
132
337
  correct_title_underscores, _ = os.path.splitext(base_name)
133
- correct_title_spaces = correct_title_underscores.replace('_', ' ')
338
+ correct_title_spaces = correct_title_underscores.replace("_", " ")
134
339
 
135
340
  title_prefix = artefact_class._title_prefix()
136
-
341
+
137
342
  lines = artefact_text.splitlines()
138
343
  new_lines = []
139
344
  title_found_and_replaced = False
@@ -144,15 +349,40 @@ def fix_title_mismatch(file_path: str, artefact_text: str, artefact_class) -> st
144
349
  title_found_and_replaced = True
145
350
  else:
146
351
  new_lines.append(line)
147
-
352
+
148
353
  if not title_found_and_replaced:
149
- print(f"Warning: Title prefix '{title_prefix}' not found in {file_path}. Title could not be fixed.")
354
+ print(
355
+ f"Warning: Title prefix '{title_prefix}' not found in {file_path}. Title could not be fixed."
356
+ )
150
357
  return artefact_text
151
358
 
152
359
  return "\n".join(new_lines)
153
360
 
154
361
 
155
- def apply_autofix(file_path: str, classifier: str, reason: str, deterministic: bool, non_deterministic: bool) -> bool:
362
+ def fix_contribution(
363
+ file_path: str,
364
+ artefact_text: str,
365
+ artefact_class: str,
366
+ classified_artefact_info: dict,
367
+ **kwargs,
368
+ ):
369
+ if not classified_artefact_info:
370
+ file_classifier = FileClassifier(os)
371
+ classified_artefact_info = file_classifier.classify_files()
372
+ artefact = artefact_class.deserialize(artefact_text)
373
+ artefact, _ = set_closest_contribution(artefact)
374
+ artefact_text = artefact.serialize()
375
+ return artefact_text
376
+
377
+
378
+ def apply_autofix(
379
+ file_path: str,
380
+ classifier: str,
381
+ reason: str,
382
+ deterministic: bool = True,
383
+ non_deterministic: bool = True,
384
+ classified_artefact_info: Optional[Dict[str, List[Dict[str, str]]]] = None,
385
+ ) -> bool:
156
386
  artefact_text = read_artefact(file_path)
157
387
  if artefact_text is None:
158
388
  return False
@@ -161,11 +391,36 @@ def apply_autofix(file_path: str, classifier: str, reason: str, deterministic: b
161
391
  if artefact_type is None or artefact_class is None:
162
392
  return False
163
393
 
164
- is_deterministic_issue = "Filename-Title Mismatch" in reason
394
+ if classified_artefact_info is None:
395
+ file_classifier = FileClassifier(os)
396
+ classified_file_info = file_classifier.classified_files()
397
+
398
+ deterministic_markers_to_functions = {
399
+ "Filename-Title Mismatch": fix_title_mismatch,
400
+ "Invalid Contribution Reference": fix_contribution,
401
+ }
402
+
403
+ try:
404
+ deterministic_issue = next(
405
+ (
406
+ marker
407
+ for marker in deterministic_markers_to_functions.keys()
408
+ if marker in reason
409
+ ),
410
+ None,
411
+ )
412
+ except StopIteration:
413
+ pass
414
+ is_deterministic_issue = deterministic_issue is not None
165
415
 
166
416
  if deterministic and is_deterministic_issue:
167
417
  print(f"Attempting deterministic fix for {file_path}...")
168
- corrected_text = fix_title_mismatch(file_path, artefact_text, artefact_class)
418
+ corrected_text = deterministic_markers_to_functions[deterministic_issue](
419
+ file_path=file_path,
420
+ artefact_text=artefact_text,
421
+ artefact_class=artefact_class,
422
+ classified_artefact_info=classified_artefact_info,
423
+ )
169
424
  write_corrected_artefact(file_path, corrected_text)
170
425
  return True
171
426
 
@@ -181,11 +436,11 @@ def apply_autofix(file_path: str, classifier: str, reason: str, deterministic: b
181
436
  except Exception as e:
182
437
  print(f"LLM agent failed to fix artefact at {file_path}: {e}")
183
438
  return False
184
-
439
+
185
440
  # Log if a fix was skipped due to flags
186
441
  if is_deterministic_issue and not deterministic:
187
442
  print(f"Skipping deterministic fix for {file_path} as per request.")
188
443
  elif not is_deterministic_issue and not non_deterministic:
189
444
  print(f"Skipping non-deterministic fix for {file_path} as per request.")
190
445
 
191
- return False
446
+ return False
@@ -33,12 +33,17 @@ def suggest_close_name_matches_for_parent(artefact_name: str, all_artefact_names
33
33
  )
34
34
 
35
35
 
36
- def find_closest_name_match(artefact_name: str, all_artefact_names: list[str]) -> Optional[str]:
37
- closest_matches = difflib.get_close_matches(artefact_name, all_artefact_names, cutoff=0.5, n=1)
36
+ def find_closest_name_matches(artefact_name: str, all_artefact_names: list[str]) -> Optional[str]:
37
+ closest_matches = difflib.get_close_matches(artefact_name, all_artefact_names, cutoff=0.5)
38
38
  if not closest_matches:
39
39
  return None
40
- closest_match = closest_matches[0]
41
- return closest_match
40
+ return closest_matches
41
+
42
+
43
+ def extract_artefact_names_of_classifier(classified_files: dict[str, list[dict]], classifier: str):
44
+ artefact_info_of_classifier = classified_files.get(classifier, [])
45
+ titles = list(map(lambda artefact: artefact['title'], artefact_info_of_classifier))
46
+ return titles
42
47
 
43
48
 
44
49
  def find_closest_rule(parent_artefact: 'Artefact', rule: str):
@@ -363,27 +363,43 @@ class FeatureArtefact(Artefact):
363
363
 
364
364
  @classmethod
365
365
  def deserialize(cls, text: str) -> 'FeatureArtefact':
366
- fields = super()._parse_common_fields(text)
366
+ """
367
+ Deserializes the feature file using a robust extract-and-reinject strategy.
368
+ 1. Hides all docstrings by replacing them with placeholders.
369
+ 2. Parses the sanitized text using the original, simple parsing logic.
370
+ 3. Re-injects the original docstring content back into the parsed objects.
371
+ This prevents the parser from ever being confused by content within docstrings.
372
+ """
373
+ # 1. Hide all docstrings from the entire file text first.
374
+ sanitized_text, docstrings = cls._hide_docstrings(text)
375
+
376
+ # 2. Perform the original parsing logic on the SANITIZED text.
377
+ # This part of the code is now "safe" because it will never see a docstring.
378
+ fields = super()._parse_common_fields(sanitized_text)
379
+ intent = FeatureIntent.deserialize(sanitized_text)
380
+ background = cls.deserialize_background(sanitized_text)
381
+ scenarios = cls.deserialize_scenarios(sanitized_text)
367
382
 
368
- intent = FeatureIntent.deserialize(text)
369
- background = cls.deserialize_background(text)
370
- scenarios = cls.deserialize_scenarios(text)
371
-
372
- fields['scenarios'] = scenarios
373
- fields['background'] = background
374
383
  fields['intent'] = intent
384
+ fields['background'] = background
385
+ fields['scenarios'] = scenarios
386
+
387
+ # 3. Re-inject the docstrings back into the parsed scenarios.
388
+ if fields['scenarios'] and docstrings:
389
+ for scenario in fields['scenarios']:
390
+ if isinstance(scenario, (Scenario, ScenarioOutline)):
391
+ scenario.steps = cls._reinject_docstrings_into_steps(scenario.steps, docstrings)
375
392
 
376
393
  return cls(**fields)
377
394
 
378
395
  @classmethod
379
396
  def deserialize_scenarios(cls, text):
380
- lines = [line.strip()
381
- for line in text.strip().splitlines() if line.strip()]
382
-
397
+ if not text: return []
398
+ lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
383
399
  scenarios = []
384
400
  idx = 0
385
401
  while idx < len(lines):
386
- line = lines[idx].strip()
402
+ line = lines[idx]
387
403
  if line.startswith('Scenario:'):
388
404
  scenario, next_idx = Scenario.from_lines(lines, idx)
389
405
  scenarios.append(scenario)
@@ -398,16 +414,54 @@ class FeatureArtefact(Artefact):
398
414
 
399
415
  @classmethod
400
416
  def deserialize_background(cls, text):
401
- lines = [line.strip()
402
- for line in text.strip().splitlines() if line.strip()]
403
-
417
+ if not text: return None
418
+ lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
404
419
  background = None
405
420
  idx = 0
406
421
  while idx < len(lines):
407
- line = lines[idx].strip()
422
+ line = lines[idx]
408
423
  if line.startswith('Background:'):
409
- background, next_idx = Background.from_lines(lines, idx)
424
+ background, _ = Background.from_lines(lines, idx)
410
425
  break
411
- else:
412
- idx += 1
426
+ idx += 1
413
427
  return background
428
+
429
+
430
+ @staticmethod
431
+ def _hide_docstrings(text: str) -> Tuple[str, Dict[str, str]]:
432
+ """
433
+ Finds all docstring blocks ('''...''') in the text,
434
+ replaces them with a unique placeholder, and returns the sanitized
435
+ text and a dictionary mapping placeholders to the original docstrings.
436
+ """
437
+ docstrings = {}
438
+ placeholder_template = "__DOCSTRING_PLACEHOLDER_{}__"
439
+
440
+ def replacer(match):
441
+ # This function is called for each found docstring.
442
+ key = placeholder_template.format(len(docstrings))
443
+ docstrings[key] = match.group(0) # Store the full matched docstring
444
+ return key
445
+
446
+ # The regex finds ''' followed by any character (including newlines)
447
+ # in a non-greedy way (.*?) until the next '''.
448
+ sanitized_text = re.sub(r'"""[\s\S]*?"""', replacer, text)
449
+
450
+ return sanitized_text, docstrings
451
+
452
+ @staticmethod
453
+ def _reinject_docstrings_into_steps(steps: List[str], docstrings: Dict[str, str]) -> List[str]:
454
+ """
455
+ Iterates through a list of steps, finds any placeholders,
456
+ and replaces them with their original docstring content.
457
+ """
458
+ rehydrated_steps = []
459
+ for step in steps:
460
+ for key, value in docstrings.items():
461
+ if key in step:
462
+ # Replace the placeholder with the original, full docstring block.
463
+ # This handles cases where the step is just the placeholder,
464
+ # or the placeholder is at the end of a line (e.g., "Then I see... __PLACEHOLDER__").
465
+ step = step.replace(key, value)
466
+ rehydrated_steps.append(step)
467
+ return rehydrated_steps
@@ -10,13 +10,14 @@ import re
10
10
 
11
11
  class ArtefactReader:
12
12
  @staticmethod
13
- def read_artefact(artefact_name, classifier) -> tuple[str, dict[str, str]]:
13
+ def read_artefact(artefact_name, classifier, classified_file_info = None) -> tuple[str, dict[str, str]]:
14
14
  if not Classifier.is_valid_classifier(classifier):
15
15
  print("Invalid classifier provided. Please provide a valid classifier.")
16
16
  return None, None
17
17
 
18
- file_classifier = FileClassifier(os)
19
- classified_file_info = file_classifier.classify_files()
18
+ if not classified_file_info:
19
+ file_classifier = FileClassifier(os)
20
+ classified_file_info = file_classifier.classify_files()
20
21
  artefact_info_of_classifier = classified_file_info.get(classifier, [])
21
22
 
22
23
  for artefact_info in artefact_info_of_classifier: