Skip to content

Commit 1b9f59a

Browse files
Serhan-Asadclaude
andauthored
test: add failing tests for circular include detection in non-recursive mode (#553) (#554)
* test: add failing tests for circular include detection in non-recursive mode (#553) Add 6 unit tests and 6 E2E tests that detect the bug where circular <include> tags cause an infinite loop in the default (non-recursive) preprocess path. The _seen set is only populated inside `if recursive:` blocks, making cycle detection dead code in non-recursive mode. Unit tests (test_circular_includes.py): - 4 circular tests that fail with timeout (infinite loop) - 2 regression guards that pass (non-circular includes still work) E2E tests (test_e2e_issue_553_circular_includes_non_recursive.py): - 4 subprocess-based CLI tests that fail with timeout - 2 regression guards that pass All circular tests are verified to fail on current code and will pass once the fix is implemented. Refs #553 * fix: add circular include cycle detection in non-recursive preprocess path (#553) Populate _seen tracking in the non-recursive convergence loops of process_include_tags and process_backtick_includes. Previously _seen was only updated inside `if recursive:` blocks, making the cycle check dead code in non-recursive (default) mode and causing infinite loops. Also clean up unused _timeout/signal/contextlib imports in tests and make E2E output file assertion unconditional. Fixes #553 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review — add PYTHONPATH to E2E subprocess tests and replace pytest-timeout markers with signal-based timeouts - E2E tests now set PYTHONPATH to project root, matching other E2E tests in the repo (prevents ModuleNotFoundError in CI) - Replace @pytest.mark.timeout(10) with @_with_timeout(10) using SIGALRM since pytest-timeout is not a declared dependency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e85268 commit 1b9f59a

File tree

3 files changed

+393
-6
lines changed

3 files changed

+393
-6
lines changed

pdd/preprocess.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,30 @@ def replace_include(match):
197197
console.print(f"[bold red]Error processing include:[/bold red] {str(e)}")
198198
_dbg(f"Error processing backtick include {file_path}: {e}")
199199
return f"```[Error processing include: {file_path}]```"
200+
_expanded_prior = set()
201+
_expanded_current = set()
202+
203+
def replace_include_tracked(match):
204+
file_path = match.group(1).strip()
205+
try:
206+
full_path = get_file_path(file_path)
207+
resolved = os.path.realpath(full_path)
208+
if resolved in _expanded_prior:
209+
raise ValueError(f"Circular include detected: {file_path} is already in the include chain")
210+
_expanded_current.add(resolved)
211+
except (FileNotFoundError, ValueError):
212+
raise
213+
except Exception:
214+
pass
215+
return replace_include(match)
216+
200217
prev_text = ""
201218
current_text = text
202219
while prev_text != current_text:
203220
prev_text = current_text
204-
current_text = re.sub(pattern, replace_include, current_text, flags=re.DOTALL)
221+
_expanded_current = set()
222+
current_text = re.sub(pattern, replace_include_tracked, current_text, flags=re.DOTALL)
223+
_expanded_prior.update(_expanded_current)
205224
return current_text
206225

207226
def process_xml_tags(text: str, recursive: bool, _seen: Optional[set] = None) -> str:
@@ -290,16 +309,35 @@ def replace_include(match):
290309
console.print(f"[bold red]Error processing include:[/bold red] {str(e)}")
291310
_dbg(f"Error processing XML include {file_path}: {e}")
292311
return f"[Error processing include: {file_path}]"
312+
_expanded_prior = set()
313+
_expanded_current = set()
314+
315+
def replace_include_tracked(match):
316+
file_path = match.group(1).strip()
317+
try:
318+
full_path = get_file_path(file_path)
319+
resolved = os.path.realpath(full_path)
320+
if resolved in _expanded_prior:
321+
raise ValueError(f"Circular include detected: {file_path} is already in the include chain")
322+
_expanded_current.add(resolved)
323+
except (FileNotFoundError, ValueError):
324+
raise
325+
except Exception:
326+
pass
327+
return replace_include(match)
328+
293329
prev_text = ""
294330
current_text = text
295331
while prev_text != current_text:
296332
prev_text = current_text
333+
_expanded_current = set()
297334
code_spans = _extract_code_spans(current_text)
298335
def replace_include_with_spans(match):
299336
if _intersects_any_span(match.start(), match.end(), code_spans):
300337
return match.group(0)
301-
return replace_include(match)
338+
return replace_include_tracked(match)
302339
current_text = re.sub(pattern, replace_include_with_spans, current_text, flags=re.DOTALL)
340+
_expanded_prior.update(_expanded_current)
303341
return current_text
304342

305343
def process_pdd_tags(text: str) -> str:

tests/test_circular_includes.py

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,42 @@
11
"""
2-
Tests for circular <include> detection in pdd/preprocess.py (Issue #521).
2+
Tests for circular <include> detection in pdd/preprocess.py (Issues #521, #553).
33
4-
The preprocessor has no cycle detection — circular includes recurse ~82 times
5-
until Python's recursion limit, then the broad `except Exception` swallows
6-
the RecursionError and returns corrupted output with exit code 0.
4+
Issue #521: Circular includes in the recursive path must be detected.
5+
Issue #553: Circular includes in the non-recursive (default) path must also
6+
be detected. The _seen set is only populated inside `if recursive:` blocks,
7+
so the non-recursive convergence loop has no cycle guard and loops forever.
78
89
These tests verify that circular includes produce an error (exception or
910
error marker in output), NOT silently corrupted content.
1011
"""
1112

1213
import os
14+
import signal
1315
import pytest
1416
from unittest.mock import patch, mock_open, MagicMock
1517
from pdd.preprocess import preprocess
1618

19+
20+
def _timeout_handler(signum, frame):
21+
raise TimeoutError("Test timed out — possible infinite loop")
22+
23+
24+
def _with_timeout(seconds=10):
25+
"""Decorator that enforces a real timeout using SIGALRM (Unix only)."""
26+
def decorator(func):
27+
def wrapper(*args, **kwargs):
28+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
29+
signal.alarm(seconds)
30+
try:
31+
return func(*args, **kwargs)
32+
finally:
33+
signal.alarm(0)
34+
signal.signal(signal.SIGALRM, old_handler)
35+
wrapper.__name__ = func.__name__
36+
wrapper.__doc__ = func.__doc__
37+
return wrapper
38+
return decorator
39+
1740
# Store original so we can restore
1841
_original_pdd_path = os.environ.get('PDD_PATH')
1942

@@ -194,3 +217,135 @@ def test_diamond_includes_not_falsely_flagged(self):
194217
assert 'C' in result
195218
# D is included twice (via B and via C) — that's fine, not circular
196219
assert result.count('Shared') == 2
220+
221+
222+
class TestCircularIncludesNonRecursive:
223+
"""Issue #553: Circular includes must be detected in the non-recursive (default) path.
224+
225+
PR #528 added cycle detection for recursive=True, but the non-recursive path
226+
(which uses a while-loop for convergence) never populates the _seen set,
227+
so circular includes cause an infinite loop instead of raising ValueError.
228+
"""
229+
230+
def setup_method(self):
231+
set_pdd_path('/mock/path')
232+
233+
def teardown_method(self):
234+
if _original_pdd_path is not None:
235+
os.environ['PDD_PATH'] = _original_pdd_path
236+
elif 'PDD_PATH' in os.environ:
237+
del os.environ['PDD_PATH']
238+
239+
@_with_timeout(10)
240+
def test_circular_xml_includes_non_recursive_must_error(self):
241+
"""A→B→A circular XML include with recursive=False must raise ValueError, not loop forever."""
242+
file_map = {
243+
'a.txt': 'Hello\n<include>b.txt</include>',
244+
'b.txt': 'World\n<include>a.txt</include>',
245+
}
246+
with patch('builtins.open', mock_open()) as m:
247+
m.side_effect = _make_mock_open(file_map)
248+
249+
# Buggy code: infinite loop in the while-loop → timeout → test fails
250+
# Fixed code: ValueError("Circular include detected: ...") → test passes
251+
with pytest.raises(ValueError, match="Circular include detected"):
252+
preprocess(
253+
'<include>a.txt</include>',
254+
recursive=False,
255+
double_curly_brackets=False,
256+
)
257+
258+
@_with_timeout(10)
259+
def test_circular_backtick_includes_non_recursive_must_error(self):
260+
"""A→B→A circular backtick include with recursive=False must raise ValueError."""
261+
file_map = {
262+
'x.txt': 'Foo\n```<y.txt>```',
263+
'y.txt': 'Bar\n```<x.txt>```',
264+
}
265+
with patch('builtins.open', mock_open()) as m:
266+
m.side_effect = _make_mock_open(file_map)
267+
268+
# Buggy code: infinite loop in process_backtick_includes while-loop
269+
# Fixed code: ValueError raised
270+
with pytest.raises(ValueError, match="Circular include detected"):
271+
preprocess(
272+
'```<x.txt>```',
273+
recursive=False,
274+
double_curly_brackets=False,
275+
)
276+
277+
@_with_timeout(10)
278+
def test_self_referencing_include_non_recursive_must_error(self):
279+
"""A file that includes itself must be detected in non-recursive mode."""
280+
file_map = {
281+
'self.txt': 'Content\n<include>self.txt</include>',
282+
}
283+
with patch('builtins.open', mock_open()) as m:
284+
m.side_effect = _make_mock_open(file_map)
285+
286+
with pytest.raises(ValueError, match="Circular include detected"):
287+
preprocess(
288+
'<include>self.txt</include>',
289+
recursive=False,
290+
double_curly_brackets=False,
291+
)
292+
293+
@_with_timeout(10)
294+
def test_three_file_cycle_non_recursive_must_error(self):
295+
"""A→B→C→A three-file cycle must be detected in non-recursive mode."""
296+
file_map = {
297+
'a.txt': 'AAA\n<include>b.txt</include>',
298+
'b.txt': 'BBB\n<include>c.txt</include>',
299+
'c.txt': 'CCC\n<include>a.txt</include>',
300+
}
301+
with patch('builtins.open', mock_open()) as m:
302+
m.side_effect = _make_mock_open(file_map)
303+
304+
with pytest.raises(ValueError, match="Circular include detected"):
305+
preprocess(
306+
'<include>a.txt</include>',
307+
recursive=False,
308+
double_curly_brackets=False,
309+
)
310+
311+
def test_non_circular_includes_non_recursive_still_work(self):
312+
"""Non-circular includes (A→B→C, no cycle) must still work in non-recursive mode."""
313+
file_map = {
314+
'top.txt': 'Top\n<include>mid.txt</include>',
315+
'mid.txt': 'Mid\n<include>leaf.txt</include>',
316+
'leaf.txt': 'Leaf',
317+
}
318+
with patch('builtins.open', mock_open()) as m:
319+
m.side_effect = _make_mock_open(file_map)
320+
321+
result = preprocess(
322+
'<include>top.txt</include>',
323+
recursive=False,
324+
double_curly_brackets=False,
325+
)
326+
327+
assert 'Top' in result
328+
assert 'Mid' in result
329+
assert 'Leaf' in result
330+
331+
def test_diamond_includes_non_recursive_not_falsely_flagged(self):
332+
"""Diamond pattern (A→B, A→C, B→D, C→D) is NOT circular and must work."""
333+
file_map = {
334+
'a.txt': '<include>b.txt</include>\n<include>c.txt</include>',
335+
'b.txt': 'B\n<include>d.txt</include>',
336+
'c.txt': 'C\n<include>d.txt</include>',
337+
'd.txt': 'Shared',
338+
}
339+
with patch('builtins.open', mock_open()) as m:
340+
m.side_effect = _make_mock_open(file_map)
341+
342+
result = preprocess(
343+
'<include>a.txt</include>',
344+
recursive=False,
345+
double_curly_brackets=False,
346+
)
347+
348+
assert 'B' in result
349+
assert 'C' in result
350+
# D is included twice (via B and via C) — that's fine, not circular
351+
assert result.count('Shared') == 2

0 commit comments

Comments
 (0)