esbmc_ai 0.5.2.dev2__tar.gz → 0.5.2.dev4__tar.gz

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 (42) hide show
  1. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/PKG-INFO +1 -1
  2. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/__about__.py +1 -1
  3. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/ai_models.py +15 -8
  4. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/chats/key_template_renderer.py +37 -2
  5. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/fix_code_command.py +2 -3
  6. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/log_handlers.py +1 -1
  7. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/solution.py +145 -127
  8. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/verifiers/esbmc.py +4 -4
  9. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/.gitignore +0 -0
  10. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/CLA.md +0 -0
  11. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/LICENSE +0 -0
  12. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/README.md +0 -0
  13. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/__init__.py +0 -0
  14. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/__main__.py +0 -0
  15. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/addon_loader.py +0 -0
  16. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/base_component.py +0 -0
  17. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/chat_command.py +0 -0
  18. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/chats/__init__.py +0 -0
  19. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/chats/solution_generator.py +0 -0
  20. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/chats/template_funcs.py +0 -0
  21. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/chats/template_key_provider.py +0 -0
  22. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/command_result.py +0 -0
  23. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/__init__.py +0 -0
  24. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/debug_config.py +0 -0
  25. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/exit_command.py +0 -0
  26. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/help_command.py +0 -0
  27. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/help_config.py +0 -0
  28. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/commands/license_command.py +0 -0
  29. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/component_manager.py +0 -0
  30. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/config.py +0 -0
  31. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/issue.py +0 -0
  32. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/loading_widget.py +0 -0
  33. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/log_categories.py +0 -0
  34. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/log_utils.py +0 -0
  35. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/program_trace.py +0 -0
  36. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/prompt_utils.py +0 -0
  37. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/singleton.py +0 -0
  38. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/verifier_output.py +0 -0
  39. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/verifiers/__init__.py +0 -0
  40. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/verifiers/base_source_verifier.py +0 -0
  41. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/esbmc_ai/verifiers/clang.py +0 -0
  42. {esbmc_ai-0.5.2.dev2 → esbmc_ai-0.5.2.dev4}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: esbmc_ai
3
- Version: 0.5.2.dev2
3
+ Version: 0.5.2.dev4
4
4
  Summary: LLM driven development and automatic repair kit.
5
5
  Project-URL: Documentation, https://github.com/esbmc/esbmc-ai/wiki
6
6
  Project-URL: Homepage, https://github.com/esbmc/esbmc-ai
@@ -1,4 +1,4 @@
1
1
  # Author: Yiannis Charalambous
2
2
 
3
- __version__ = "0.5.2.dev2"
3
+ __version__ = "0.5.2.dev4"
4
4
  __author__: str = "Yiannis Charalambous"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any
4
4
  from uuid import UUID
5
- from langchain_core.messages import BaseMessage
5
+ from langchain_core.messages import AIMessage, BaseMessage
6
6
  from langchain_core.outputs import ChatGeneration, Generation, LLMResult
7
7
  from typing_extensions import override
8
8
  import structlog
@@ -26,12 +26,17 @@ class LoggingCallbackHandler(BaseCallbackHandler):
26
26
  category=LogCategories.CHAT,
27
27
  prefix_name=ai_model,
28
28
  )
29
+ # Track last printed message index per group: {group_idx: last_msg_idx}
30
+ self._last_printed_idx: dict[int, int] = {}
29
31
 
30
32
  @staticmethod
31
33
  def _get_msg_formatted(
32
34
  group_idx: int, msg_idx: int, msg: BaseMessage | Generation
33
35
  ) -> str:
34
- return f"MSG {group_idx}-{msg_idx} {msg.type.capitalize()}: {msg.text}"
36
+ base: str = f"MSG {group_idx}-{msg_idx} {msg.type.capitalize()}:"
37
+ if isinstance(msg, AIMessage) and msg.tool_calls:
38
+ return f"{base} <message-body>{msg.text}</message-body>\nTool Call: {msg.tool_calls}"
39
+ return f"{base} <message-body>{msg.text}</message-body>"
35
40
 
36
41
  # @override
37
42
  # def on_llm_start(
@@ -134,12 +139,16 @@ class LoggingCallbackHandler(BaseCallbackHandler):
134
139
  _ = run_id, parent_run_id, tags, metadata, kwargs
135
140
  self.logger.debug("=" * 80)
136
141
  self.logger.debug("Invoke Chat Model LLM")
137
- for idx, msg_group in enumerate(messages):
142
+ for group_idx, msg_group in enumerate(messages):
143
+ last_printed = self._last_printed_idx.get(group_idx, -1)
138
144
  self.logger.debug("=" * 80)
139
- for msg_idx, msg in enumerate(msg_group):
140
- self.logger.debug(self._get_msg_formatted(idx, msg_idx, msg))
145
+ for msg_idx, msg in enumerate(
146
+ msg_group[last_printed + 1 :], start=last_printed + 1
147
+ ):
148
+ self.logger.debug(self._get_msg_formatted(group_idx, msg_idx, msg))
141
149
  if msg_idx < len(msg_group) - 1:
142
150
  self.logger.debug("-" * 80)
151
+ self._last_printed_idx[group_idx] = len(msg_group) - 1
143
152
 
144
153
 
145
154
  class AIModel:
@@ -154,9 +163,7 @@ class AIModel:
154
163
  temperature: float | None = None,
155
164
  url: str | None = None,
156
165
  ) -> BaseChatModel:
157
- handler: BaseCallbackHandler = LoggingCallbackHandler(
158
- ai_model=f"{provider}:{model}"
159
- )
166
+ handler: BaseCallbackHandler = LoggingCallbackHandler(ai_model=model)
160
167
 
161
168
  chat_model: BaseChatModel = init_chat_model(
162
169
  model=model,
@@ -3,16 +3,51 @@
3
3
  """Template renderer that integrates TemplateKeyProvider with ChatPromptTemplate."""
4
4
 
5
5
  from typing import Any, Sequence, override
6
+ from jinja2.sandbox import SandboxedEnvironment
6
7
  from langchain_core.messages import BaseMessage
7
8
  from langchain_core.prompt_values import ChatPromptValue
8
9
  from langchain_core.prompts import ChatPromptTemplate
9
10
  from langchain_core.prompts.chat import MessageLikeRepresentation
10
- from langchain_core.prompts.string import PromptTemplateFormat
11
- from pydantic import PrivateAttr
11
+ from langchain_core.prompts.string import PromptTemplateFormat, DEFAULT_FORMATTER_MAPPING
12
+ from pydantic import PrivateAttr, BaseModel
12
13
  from esbmc_ai.chats.template_key_provider import TemplateKeyProvider
13
14
  from .template_funcs import get_func_mapping
14
15
 
15
16
 
17
+ class _PermissiveSandboxedEnvironment(SandboxedEnvironment):
18
+ """Custom Jinja2 sandbox that allows attribute access on trusted objects.
19
+
20
+ This environment allows attribute access on Pydantic models and other
21
+ trusted objects while still providing basic sandboxing protections.
22
+ """
23
+
24
+ def is_safe_attribute(self, obj: Any, attr: str, value: Any) -> bool:
25
+ """Allow attribute access on Pydantic models and common types."""
26
+ # Allow attribute access on Pydantic models
27
+ if isinstance(obj, BaseModel):
28
+ return True
29
+ # Allow attribute access on dicts, lists, and other safe types
30
+ if isinstance(obj, (dict, list, tuple, str, int, float, bool, type(None))):
31
+ return True
32
+ # Default to parent's behavior for other types
33
+ return super().is_safe_attribute(obj, attr, value)
34
+
35
+
36
+ def _permissive_jinja2_formatter(template: str, **kwargs: Any) -> str:
37
+ """Format a template using a permissive Jinja2 sandbox.
38
+
39
+ Unlike LangChain's default restricted sandbox, this allows attribute
40
+ access on trusted objects like Pydantic models.
41
+ """
42
+ env = _PermissiveSandboxedEnvironment()
43
+ return env.from_string(template).render(**kwargs)
44
+
45
+
46
+ # Override LangChain's default jinja2 formatter with our permissive one
47
+ # This allows nested attribute access on Pydantic models in templates
48
+ DEFAULT_FORMATTER_MAPPING["jinja2"] = _permissive_jinja2_formatter
49
+
50
+
16
51
  class KeyTemplateRenderer(ChatPromptTemplate):
17
52
  """Derives ChatPromptTemplate and automatically provides template keys via
18
53
  TemplateKeyProvider. This is to force standarization across types and keys."""
@@ -130,11 +130,10 @@ class FixCodeCommand(ChatCommand):
130
130
  )
131
131
  # Handle kwargs
132
132
  source_file: SourceFile = SourceFile.load(
133
- self.global_config.solution.filenames[0],
134
- Path(os.getcwd()),
133
+ self.global_config.solution.filenames[0]
135
134
  )
136
135
  self.original_source_file = SourceFile(
137
- source_file.file_path, source_file.base_path, source_file.content
136
+ file_path=source_file.file_path, content=source_file.content
138
137
  )
139
138
  self.anim = (
140
139
  LoadingWidget() if self.global_config.loading_hints else BaseLoadingWidget()
@@ -62,7 +62,7 @@ class CategoryFileHandler(logging.Handler):
62
62
  if isinstance(category, Enum):
63
63
  category = category.value
64
64
 
65
- assert isinstance(category, str)
65
+ assert isinstance(category, str), f"Category is not string: {category}"
66
66
 
67
67
  # Skip uncategorized if desired
68
68
  if (
@@ -2,7 +2,8 @@
2
2
 
3
3
  """Keeps track of all the source files that ESBMC-AI is targeting."""
4
4
 
5
- from os import getcwd, walk
5
+ from os import walk
6
+ from os.path import commonpath
6
7
  from pathlib import Path
7
8
  from subprocess import PIPE, STDOUT, run, CompletedProcess
8
9
  from tempfile import NamedTemporaryFile, TemporaryDirectory
@@ -13,8 +14,9 @@ from langchain_core.language_models import BaseChatModel
13
14
  from langchain_core.load.serializable import Serializable
14
15
  from pydantic import Field, PrivateAttr
15
16
  import lizard
17
+ from structlog.stdlib import get_logger
16
18
 
17
- from esbmc_ai.log_utils import get_log_level, print_horizontal_line
19
+ from esbmc_ai.log_utils import LogCategories, get_log_level, print_horizontal_line
18
20
 
19
21
  _SourceFileFormatStyles = Literal["markdown", "xml", "plain"]
20
22
 
@@ -46,8 +48,7 @@ class SourceFile(Serializable):
46
48
  """Represents a source file in the Solution. Contains methods to manipulate
47
49
  and get information about different versions."""
48
50
 
49
- file_path: Path = Field(description="Relative file path")
50
- base_path: Path = Field(description="Base directory path")
51
+ file_path: Path = Field(description="Absolute file path")
51
52
  content: str = Field(description="File content")
52
53
 
53
54
  @staticmethod
@@ -71,28 +72,23 @@ class SourceFile(Serializable):
71
72
  return "\n".join(lines)
72
73
 
73
74
  @staticmethod
74
- def load(file_path: Path, base_path: Path) -> "SourceFile":
75
+ def load(file_path: Path) -> "SourceFile":
75
76
  """Creates a new source file by loading the content from disk."""
76
- if file_path.is_relative_to(base_path):
77
- file_path = file_path.relative_to(base_path)
78
- with open(base_path / file_path, "r") as file:
79
- return SourceFile(file_path, base_path, file.read())
77
+ abs_path = file_path.absolute()
78
+ with open(abs_path, "r") as file:
79
+ return SourceFile(file_path=abs_path, content=file.read())
80
80
 
81
81
  def __init__(
82
82
  self,
83
83
  file_path: Path,
84
- base_path: Path,
85
84
  content: str,
86
85
  **kwargs: Any,
87
86
  ) -> None:
88
- if file_path.is_absolute():
89
- raise ValueError(
90
- f"SourceFile requires a relative file path, got absolute: {file_path}. "
91
- f"Use SourceFile.load() to create from absolute paths."
92
- )
87
+ # Ensure path is absolute
88
+ if not file_path.is_absolute():
89
+ file_path = file_path.absolute()
93
90
  super().__init__(
94
91
  file_path=file_path,
95
- base_path=base_path,
96
92
  content=content,
97
93
  **kwargs,
98
94
  )
@@ -105,11 +101,6 @@ class SourceFile(Serializable):
105
101
  def __str__(self) -> str:
106
102
  return f"SourceFile({self.file_path})"
107
103
 
108
- @property
109
- def abs_path(self) -> Path:
110
- """Returns the abs path"""
111
- return self.base_path / self.file_path
112
-
113
104
  @property
114
105
  def file_extension(self) -> str:
115
106
  """Returns the file extension to the file."""
@@ -199,10 +190,10 @@ class SourceFile(Serializable):
199
190
  def verify_file_integrity(self) -> bool:
200
191
  """Verifies that this file matches with the file on disk and does not
201
192
  contain any differences."""
202
- if not (self.abs_path.exists() and self.abs_path.is_file()):
193
+ if not (self.file_path.exists() and self.file_path.is_file()):
203
194
  return False
204
195
 
205
- with open(self.abs_path, "r") as file:
196
+ with open(self.file_path, "r") as file:
206
197
  content: str = str(file.read())
207
198
 
208
199
  return content == self.content
@@ -233,6 +224,7 @@ class SourceFile(Serializable):
233
224
  style: _SourceFileFormatStyles = "markdown",
234
225
  include_line_numbers: bool = False,
235
226
  max_lines: int | None = None,
227
+ working_dir: Path | None = None,
236
228
  ) -> str:
237
229
  """Flexible formatting with options for use in LangChain prompts.
238
230
 
@@ -262,12 +254,15 @@ class SourceFile(Serializable):
262
254
  content = "\n".join(f"{i+1:4d} | {line}" for i, line in enumerate(lines))
263
255
 
264
256
  # Format based on style
257
+ show_path: Path = (
258
+ self.file_path.relative_to(working_dir) if working_dir else self.file_path
259
+ )
265
260
  if style == "markdown":
266
- result = f"{self.file_path}\n```{lang}\n{content}\n```"
261
+ result = f"{show_path}\n```{lang}\n{content}\n```"
267
262
  elif style == "xml":
268
- result = f"<file path='{self.file_path}'>\n{content}\n</file>"
263
+ result = f"<file path='{show_path}'>\n{content}\n</file>"
269
264
  elif style == "plain":
270
- result = f"File: {self.file_path}\n{content}"
265
+ result = f"File: {show_path}\n{content}"
271
266
 
272
267
  return result
273
268
 
@@ -276,84 +271,96 @@ class Solution(Serializable):
276
271
  """Represents a solution, that is a collection of all the source files that
277
272
  ESBMC-AI will be involved in analyzing."""
278
273
 
279
- base_dir: Path = Field(description="Base directory path")
280
274
  _files: list[SourceFile] = PrivateAttr(default_factory=list)
281
- _include_dirs: dict[Path, list[SourceFile]] = PrivateAttr(default_factory=dict)
275
+ _include_dirs: list[Path] = PrivateAttr(default_factory=list)
282
276
 
283
277
  @staticmethod
284
- def from_dir(
285
- path: Path,
286
- file_paths: list[Path] | None = None,
278
+ def from_paths(
279
+ *paths: Path,
287
280
  include_dirs: list[Path] | None = None,
288
281
  ) -> "Solution":
289
- """Creates a solution from a base directory. Can append with files manually."""
290
- path = path.absolute()
282
+ """Creates a solution from one or more paths (files or directories).
291
283
 
292
- if file_paths:
293
- return Solution(files=file_paths, base_dir=path, include_dirs=include_dirs)
284
+ Directories are scanned recursively for all files.
294
285
 
286
+ Args:
287
+ *paths: One or more file or directory paths
288
+ include_dirs: Optional include directories for C/C++ compilation
289
+
290
+ Example:
291
+ Solution.from_paths(Path("file1.c"), Path("file2.c"))
292
+ Solution.from_paths(Path("/project"))
293
+ Solution.from_paths(Path("src"), Path("tests/test.c"))
294
+ """
295
295
  result: list[Path] = []
296
- for dir_path, _, files in walk(path):
297
- result.extend(Path(dir_path) / file for file in files)
298
296
 
299
- return Solution(files=result, base_dir=path, include_dirs=include_dirs)
297
+ for path in paths:
298
+ abs_path = path.absolute()
299
+ if abs_path.is_file():
300
+ result.append(abs_path)
301
+ elif abs_path.is_dir():
302
+ for dir_path, _, files in walk(abs_path):
303
+ result.extend(Path(dir_path) / file for file in files)
304
+ else:
305
+ raise ValueError(
306
+ f"Path does not exist or is neither file nor directory: {path}"
307
+ )
308
+
309
+ return Solution(files=result, include_dirs=include_dirs)
300
310
 
301
311
  def __init__(
302
312
  self,
303
313
  files: list[Path] | None = None,
304
- base_dir: Path = Path(getcwd()),
305
314
  include_dirs: list[Path] | None = None,
306
315
  **kwargs: Any,
307
316
  ) -> None:
308
- """Creates a new solution with a base directory."""
309
- super().__init__(base_dir=base_dir, **kwargs)
317
+ """Creates a new solution from file paths."""
318
+ super().__init__(**kwargs)
310
319
 
311
320
  # Initialize private attributes
312
321
  self._files = []
313
- self._include_dirs = {}
322
+ self._include_dirs = []
314
323
 
315
324
  # If files are loaded
316
325
  if files:
317
326
  for file_path in files:
318
- # Check if file exists (handle both absolute and relative paths)
319
- full_path = (
320
- file_path
321
- if file_path.is_absolute()
322
- else (self.base_dir / file_path)
327
+ # Convert to absolute path
328
+ abs_path = (
329
+ file_path if file_path.is_absolute() else file_path.absolute()
323
330
  )
324
- if not full_path.is_file():
331
+ if not abs_path.is_file():
325
332
  raise ValueError(f"Path is not a file: {file_path}")
326
- # load_source_file() only accepts relative paths, so we need to
327
- # normalize absolute paths to relative ones here
328
- if file_path.is_absolute():
329
- if file_path.is_relative_to(self.base_dir):
330
- file_path = file_path.relative_to(self.base_dir)
331
- else:
332
- raise ValueError(
333
- f"File path '{file_path}' is not within base directory '{self.base_dir}'"
334
- )
335
- self.load_source_file(file_path)
333
+ self._files.append(SourceFile.load(abs_path))
336
334
 
337
335
  if include_dirs:
338
336
  for d in include_dirs:
339
- if not d.is_dir():
337
+ abs_dir = d if d.is_absolute() else d.absolute()
338
+ if not abs_dir.is_dir():
340
339
  raise ValueError(f"Path is not a directory: {d}")
341
- include_files: list[Path] = [p for p in d.rglob("*") if p.is_file()]
342
- self._include_dirs[d] = [
343
- SourceFile.load(file_path=p, base_path=self.base_dir)
344
- for p in include_files
345
- ]
340
+ self._include_dirs.append(abs_dir)
346
341
 
347
342
  @property
348
- def include_dirs(self) -> dict[Path, list[SourceFile]]:
349
- """Used by C based verifiers."""
350
- return self._include_dirs
343
+ def include_dirs(self) -> list[Path]:
344
+ """Include directories for C based verifiers."""
345
+ return list(self._include_dirs)
351
346
 
352
347
  @property
353
348
  def files(self) -> list[SourceFile]:
354
349
  """Will return a list of the files. Returns by value."""
355
350
  return list(self._files)
356
351
 
352
+ @property
353
+ def working_dir(self) -> Path:
354
+ """Computes the common parent directory of all files."""
355
+ if not self._files:
356
+ return Path.cwd()
357
+
358
+ paths = [str(f.file_path) for f in self._files]
359
+ common = commonpath(paths)
360
+
361
+ # If only one file, commonpath returns the file itself, so use parent
362
+ return Path(common).parent if len(paths) == 1 else Path(common)
363
+
357
364
  def get_files_by_ext(self, included_ext: list[str]) -> list[SourceFile]:
358
365
  """Gets the files that are only specified in the included extensions. File
359
366
  extensions that have a . prefix are trimmed so they still work."""
@@ -401,43 +408,48 @@ class Solution(Serializable):
401
408
  """Saves the solution in a temporary directory it creates while preserving
402
409
  the file structure."""
403
410
  with TemporaryDirectory(
404
- prefix="esbmc-ai", suffix=self.base_dir.name, delete=False
411
+ prefix="esbmc-ai-", suffix="-" + self.working_dir.name, delete=False
405
412
  ) as temp_dir:
406
413
  return self.save_solution(Path(temp_dir))
407
414
 
408
415
  def save_solution(self, path: Path) -> "Solution":
409
- """Saves the solution to path, and then returns it. The solution's base
410
- directory is replaced by the final component of path."""
411
- base_dir_path: Path = path
416
+ """Saves the solution to path, preserving relative structure.
417
+
418
+ Files are saved relative to their common parent (working_dir)."""
419
+ get_logger().bind(category=LogCategories.SYSTEM).info(
420
+ f"Saving solution to {path}"
421
+ )
422
+
423
+ dest_path: Path = path.absolute()
424
+ dest_path.mkdir(parents=True, exist_ok=True)
425
+
426
+ # Get common parent to preserve relative structure
427
+ common_parent = self.working_dir
412
428
 
413
- # Copy individual source files.
429
+ # Copy individual source files preserving structure
414
430
  new_file_paths: list[Path] = []
415
431
  for source_file in self.files:
416
- # Handle absolute paths by flattening to just the filename
417
- if source_file.file_path.is_absolute():
418
- relative_path = Path(source_file.file_path.name)
419
- else:
420
- relative_path = source_file.file_path
432
+ # Get path relative to common parent
433
+ relative_path = source_file.file_path.relative_to(common_parent)
434
+ new_path: Path = dest_path / relative_path
421
435
 
422
- new_file_paths.append(relative_path)
423
- new_path: Path = base_dir_path / relative_path
424
436
  # Write new file
425
437
  new_path.parent.mkdir(parents=True, exist_ok=True)
426
438
  source_file.save_file(new_path)
439
+ new_file_paths.append(new_path)
427
440
 
428
- # Copy include directories there
441
+ # Copy include directories
442
+ new_include_dirs: list[Path] = []
429
443
  for d in self.include_dirs:
430
- new_dir: Path = base_dir_path / d
444
+ new_dir: Path = dest_path / d.name
431
445
  copytree(src=d, dst=new_dir, symlinks=True, dirs_exist_ok=True)
446
+ new_include_dirs.append(new_dir)
432
447
 
433
- sol = Solution(
448
+ return Solution(
434
449
  files=new_file_paths,
435
- base_dir=Path(base_dir_path),
436
- include_dirs=list(self.include_dirs.keys()),
450
+ include_dirs=new_include_dirs,
437
451
  )
438
452
 
439
- return sol
440
-
441
453
  def verify_solution_integrity(self) -> bool:
442
454
  """Verifies if the content of the solution match with the files on disk.
443
455
  If that's not the case, then the solution should be saved on disk before
@@ -467,23 +479,9 @@ class Solution(Serializable):
467
479
  self.load_source_file(f)
468
480
 
469
481
  def load_source_file(self, file_path: Path) -> None:
470
- """Add a source file to the solution. If content is provided then it will
471
- not be loaded."""
472
- # Get the relative path to the base dir.
473
- if file_path.is_absolute():
474
- raise ValueError(
475
- f"Cannot load absolute path '{file_path}' into Solution. "
476
- f"All file paths must be relative to the base directory '{self.base_dir}'. "
477
- f"This is an internal error - paths should be normalized before reaching this point."
478
- )
479
- with open(self.base_dir / file_path, "r") as file:
480
- self._files.append(
481
- SourceFile(
482
- file_path=file_path,
483
- base_path=self.base_dir,
484
- content=file.read(),
485
- )
486
- )
482
+ """Add a source file to the solution by loading it from disk."""
483
+ abs_path = file_path if file_path.is_absolute() else file_path.absolute()
484
+ self._files.append(SourceFile.load(abs_path))
487
485
 
488
486
  def get_diff(self, other: "Solution") -> str:
489
487
  """Gets the diff with another solution. The following params are used: -ruN"""
@@ -495,10 +493,10 @@ class Solution(Serializable):
495
493
  other.save_temp()
496
494
 
497
495
  cmd = [
498
- "patch",
496
+ "diff",
499
497
  "-ruN", # (r)ecursive, (u) additional context, (N) treat absent files as empty
500
- self.base_dir,
501
- other.base_dir,
498
+ str(self.working_dir),
499
+ str(other.working_dir),
502
500
  ]
503
501
 
504
502
  process: CompletedProcess = run(
@@ -512,11 +510,18 @@ class Solution(Serializable):
512
510
  # https://askubuntu.com/questions/698784/exit-code-of-diff
513
511
  if process.returncode == 2:
514
512
  raise ValueError(
515
- f"Diff for {self.base_dir} and {other.base_dir} failed (exit 2)."
513
+ f"Diff for {self.working_dir} and {other.working_dir} failed (exit 2)."
516
514
  )
517
515
 
518
516
  return process.stdout.decode("utf-8")
519
517
 
518
+ def save_diff(self, path: Path, solution: "Solution") -> Path:
519
+ """Saves the solution as a patch of the original."""
520
+ diff: str = self.get_diff(solution)
521
+ with open(path, "w") as file:
522
+ file.write(diff)
523
+ return path
524
+
520
525
  def resolve(self, file: SourceFile | Path) -> SourceFile | None:
521
526
  """Resolve a Path or SourceFile to its corresponding SourceFile in the solution.
522
527
 
@@ -527,23 +532,14 @@ class Solution(Serializable):
527
532
  The SourceFile if found, None otherwise
528
533
  """
529
534
  if isinstance(file, SourceFile):
530
- # For SourceFile, compare by absolute path
531
- for f in self._files:
532
- if f.abs_path == file.abs_path:
533
- return f
534
- return None
535
-
536
- # For Path, handle both absolute and relative
537
- path = Path(file)
538
- if path.is_absolute():
539
- for f in self._files:
540
- if f.abs_path == path:
541
- return f
535
+ search_path = file.file_path
542
536
  else:
543
- for f in self._files:
544
- if f.file_path == path:
545
- return f
537
+ # Convert to absolute path for comparison
538
+ search_path = Path(file).absolute()
546
539
 
540
+ for f in self._files:
541
+ if f.file_path == search_path:
542
+ return f
547
543
  return None
548
544
 
549
545
  def __contains__(self, file: SourceFile | Path) -> bool:
@@ -574,6 +570,28 @@ class Solution(Serializable):
574
570
  raise KeyError(f"Path not found in solution: {path}")
575
571
  return result
576
572
 
573
+ def __add__(self, other: "Solution") -> "Solution":
574
+ """Combine two solutions by merging their files and include directories.
575
+
576
+ Args:
577
+ other: Another Solution instance to combine with this one
578
+
579
+ Returns:
580
+ A new Solution containing all files and include_dirs from both solutions
581
+
582
+ Example:
583
+ solution_c = solution_a + solution_b
584
+ """
585
+ if not isinstance(other, Solution):
586
+ return NotImplemented
587
+
588
+ return Solution(
589
+ # Combine files - create new list to avoid modifying originals
590
+ files=[source_file.file_path for source_file in self._files + other._files],
591
+ # Combine include_dirs - deduplicate by converting to set and back
592
+ include_dirs=list(set(self._include_dirs + other._include_dirs)),
593
+ )
594
+
577
595
  def patch_solution(self, patch: str) -> None:
578
596
  """Patches the solution using the patch command."""
579
597
  if not self.verify_solution_integrity():
@@ -586,10 +604,10 @@ class Solution(Serializable):
586
604
 
587
605
  cmd = [
588
606
  "patch",
589
- "-d", # Change to base dir first
590
- self.base_dir,
607
+ "-d", # Change to working dir first
608
+ str(self.working_dir),
591
609
  "-i", # Flag to specify patch file
592
- Path(patch_file.name).absolute(),
610
+ str(Path(patch_file.name).absolute()),
593
611
  ]
594
612
 
595
613
  process: CompletedProcess = run(
@@ -610,7 +628,7 @@ class Solution(Serializable):
610
628
  + process.stdout.decode("utf-8")
611
629
  )
612
630
  raise ValueError(
613
- f"Patch failed for some files in solution {self.base_dir}"
631
+ f"Patch failed for some files in solution {self.working_dir}"
614
632
  )
615
633
  case 2:
616
634
  print_horizontal_line(get_log_level(0))
@@ -619,5 +637,5 @@ class Solution(Serializable):
619
637
  + process.stdout.decode("utf-8")
620
638
  )
621
639
  raise ValueError(
622
- f"Patch failed SERIOUSLY in solution {self.base_dir}"
640
+ f"Patch failed SERIOUSLY in solution {self.working_dir}"
623
641
  )
@@ -151,7 +151,7 @@ class ESBMCOutputParser:
151
151
  @staticmethod
152
152
  def _should_include_trace(trace: ProgramTrace, solution: Solution) -> bool:
153
153
  """Determine if a trace should be included based on solution files."""
154
- return (solution.base_dir / trace.path).exists() and trace.path in solution
154
+ return (solution.working_dir / trace.path).exists() and trace.path in solution
155
155
 
156
156
  @staticmethod
157
157
  def filter_traces(esbmc_output: "ESBMCOutput", solution: Solution) -> "ESBMCOutput":
@@ -668,7 +668,7 @@ class ESBMC(BaseSourceVerifier):
668
668
  str(file.file_path) for file in solution.get_files_by_ext(["c", "cpp"])
669
669
  )
670
670
  # Header files/dir
671
- esbmc_cmd.extend("-I" + str(d) for d in solution.include_dirs.keys())
671
+ esbmc_cmd.extend("-I" + str(d) for d in solution.include_dirs)
672
672
 
673
673
  # Add timeout suffix for parameter.
674
674
  if timeout:
@@ -686,12 +686,12 @@ class ESBMC(BaseSourceVerifier):
686
686
  # Measure execution time
687
687
  start_time = perf_counter()
688
688
 
689
- # Run ESBMC from solution base_dir and get output
689
+ # Run ESBMC from solution working_dir and get output
690
690
  process: CompletedProcess = run(
691
691
  esbmc_cmd,
692
692
  stdout=PIPE,
693
693
  stderr=STDOUT,
694
- cwd=solution.base_dir,
694
+ cwd=solution.working_dir,
695
695
  timeout=process_timeout,
696
696
  check=False,
697
697
  )
File without changes
File without changes
File without changes
File without changes