symtest-cli 1.3.0__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 (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ @file compare.py
6
+ @brief Command for comparing files in symtest
7
+ @author Xiaotong Wang
8
+ @date 2024
9
+ """
10
+
11
+ import sys
12
+ import os
13
+ import argparse
14
+ import logging
15
+ from pathlib import Path
16
+ from ..file_comparator.factory import ComparatorFactory
17
+ from ..file_comparator.result import ComparisonResult
18
+
19
+ logger = logging.getLogger("symtest.commands.compare")
20
+
21
+
22
+ def parse_arguments():
23
+ """Parse command line arguments"""
24
+ parser = argparse.ArgumentParser(description="Compare two files.")
25
+ parser.add_argument("file1", help="Path to the first file")
26
+ parser.add_argument("file2", help="Path to the second file")
27
+ parser.add_argument("--start-line", type=int, default=1, help="Starting line number (1-based)")
28
+ parser.add_argument("--end-line", type=int, help="Ending line number (1-based)")
29
+ parser.add_argument("--start-column", type=int, default=1, help="Starting column number (1-based)")
30
+ parser.add_argument("--end-column", type=int, help="Ending column number (1-based)")
31
+ parser.add_argument("--file-type", help="Type of the files to compare", default="auto")
32
+ parser.add_argument("--encoding", default="utf-8", help="File encoding for text files")
33
+ parser.add_argument("--chunk-size", type=int, default=8192, help="Chunk size for binary comparison")
34
+ parser.add_argument("--output-format", choices=["text", "json", "html"], default="text",
35
+ help="Output format for the comparison result")
36
+ parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
37
+ parser.add_argument("--debug", action="store_true", help="Enable debug mode with detailed logging")
38
+ parser.add_argument("--similarity", action="store_true",
39
+ help="When comparing binary files, compute and show similarity index")
40
+ parser.add_argument("--num-threads", type=int, default=4, help="Number of threads for parallel processing")
41
+
42
+ # CSV comparison options
43
+ csv_group = parser.add_argument_group('CSV comparison options')
44
+ csv_group.add_argument("--csv-rtol", type=float, default=1e-5,
45
+ help="Relative tolerance for numerical comparison in CSV files")
46
+ csv_group.add_argument("--csv-atol", type=float, default=1e-8,
47
+ help="Absolute tolerance for numerical comparison in CSV files")
48
+ csv_group.add_argument("--csv-delimiter", default=",",
49
+ help="CSV field delimiter (default: comma)")
50
+ csv_group.add_argument("--csv-quotechar", default='"',
51
+ help="Character used for quoting fields in CSV (default: double quote)")
52
+ csv_group.add_argument("--csv-data-filter", type=str,
53
+ help="Data filter to apply before comparison. "
54
+ "Example: '>1e-6', '<=0.01', 'abs>1e-9'. "
55
+ "Filters out numeric cells that do not meet the criteria "
56
+ "from BOTH files before comparison.")
57
+
58
+ # JSON comparison options
59
+ json_group = parser.add_argument_group('JSON comparison options')
60
+ json_group.add_argument("--json-compare-mode", choices=["exact", "key-based"], default="exact",
61
+ help="JSON comparison mode: exact (default) or key-based")
62
+ json_group.add_argument("--json-key-field", help="Key field(s) to use for key-based JSON comparison")
63
+
64
+ # H5 comparison options
65
+ h5_group = parser.add_argument_group('HDF5 comparison options')
66
+ h5_group.add_argument("--h5-table", help="Comma-separated list of table names to compare in HDF5 files")
67
+ h5_group.add_argument("--h5-table-regex", help="Comma-separated list of regular expression patterns to match table names in HDF5 files. Each pattern is matched independently.")
68
+ h5_group.add_argument("--h5-structure-only", action="store_true",
69
+ help="Only compare HDF5 file structure without comparing content")
70
+ h5_group.add_argument("--h5-show-content-diff", action="store_true",
71
+ help="Show detailed content differences when content differs")
72
+ h5_group.add_argument("--h5-rtol", type=float, default=1e-5,
73
+ help="Relative tolerance for numerical comparison in HDF5 files")
74
+ h5_group.add_argument("--h5-atol", type=float, default=1e-8,
75
+ help="Absolute tolerance for numerical comparison in HDF5 files")
76
+ h5_group.add_argument("--h5-data-filter", type=str,
77
+ help="Data filter to apply before comparison. "
78
+ "Example: '>1e-6', '<=0.01', 'abs>1e-9'. "
79
+ "Filters out data that does not meet the criteria from BOTH files before comparison.")
80
+ h5_group.add_argument("--h5-no-expand-path", dest="h5_expand_path", action="store_false",
81
+ help="Do not expand HDF5 group paths to compare all sub-items.")
82
+
83
+ return parser.parse_args()
84
+
85
+ def detect_file_type(file_path):
86
+ """Detect the type of file based on its extension"""
87
+ ext = Path(file_path).suffix.lower()
88
+ if ext == '.json':
89
+ return 'json'
90
+ elif ext == '.xml':
91
+ return 'xml'
92
+ elif ext in ['.h5', '.hdf5']:
93
+ return 'h5'
94
+ elif ext == '.csv':
95
+ return 'csv'
96
+ elif ext in ['.txt', '.py', '.md', '.html', '.css', '.js']:
97
+ return 'text'
98
+ else:
99
+ return 'binary'
100
+
101
+ def format_result(result, output_format):
102
+ """Format the comparison result according to the specified output format"""
103
+ if output_format == "json":
104
+ return result.to_json()
105
+ elif output_format == "html":
106
+ return result.to_html()
107
+ else:
108
+ return str(result)
109
+
110
+ def run_comparison(args, logger=None):
111
+ """Execute file comparison from a parsed arguments namespace.
112
+
113
+ This function can be called from both the standalone compare-files entry point
114
+ and the symtest compare subcommand.
115
+
116
+ Returns:
117
+ int: Exit code (0 if files are identical, 1 otherwise)
118
+ """
119
+ if logger is None:
120
+ logger = logging.getLogger("symtest.commands.compare")
121
+
122
+ if args.debug:
123
+ logger.setLevel(logging.DEBUG)
124
+ logger.debug("Debug mode enabled")
125
+
126
+ # Adjust for 0-based indexing
127
+ start_line = max(0, args.start_line - 1)
128
+ end_line = None if args.end_line is None else max(0, args.end_line - 1)
129
+ start_column = max(0, args.start_column - 1)
130
+ end_column = None if args.end_column is None else max(0, args.end_column - 1)
131
+
132
+ # Resolve file paths
133
+ file1_path = Path(args.file1).resolve()
134
+ file2_path = Path(args.file2).resolve()
135
+
136
+ if not file1_path.exists():
137
+ raise ValueError(f"File not found: {file1_path}")
138
+ if not file2_path.exists():
139
+ raise ValueError(f"File not found: {file2_path}")
140
+
141
+ # Determine file type
142
+ file_type = args.file_type
143
+ if file_type == "auto":
144
+ file_type = detect_file_type(file1_path)
145
+ logger.info(f"Auto-detected file type: {file_type}")
146
+
147
+ # Prepare comparator kwargs
148
+ comparator_kwargs = {
149
+ "encoding": args.encoding,
150
+ "chunk_size": args.chunk_size,
151
+ "verbose": args.verbose or args.debug,
152
+ "num_threads": args.num_threads
153
+ }
154
+
155
+ # Add file type specific arguments
156
+ if file_type == "json":
157
+ comparator_kwargs["compare_mode"] = args.json_compare_mode
158
+ if args.json_key_field:
159
+ key_fields = [field.strip() for field in args.json_key_field.split(',')]
160
+ comparator_kwargs["key_field"] = key_fields[0] if len(key_fields) == 1 else key_fields
161
+
162
+ if file_type == "csv":
163
+ comparator_kwargs["rtol"] = args.csv_rtol
164
+ comparator_kwargs["atol"] = args.csv_atol
165
+ comparator_kwargs["delimiter"] = args.csv_delimiter
166
+ comparator_kwargs["quotechar"] = args.csv_quotechar
167
+ if args.csv_data_filter:
168
+ comparator_kwargs["data_filter"] = args.csv_data_filter
169
+
170
+ if file_type == "h5":
171
+ if args.h5_table:
172
+ tables = [table.strip() for table in args.h5_table.split(',')]
173
+ comparator_kwargs["tables"] = tables
174
+ if args.h5_table_regex:
175
+ comparator_kwargs["table_regex"] = args.h5_table_regex
176
+ comparator_kwargs["structure_only"] = args.h5_structure_only
177
+ comparator_kwargs["show_content_diff"] = args.h5_show_content_diff
178
+ comparator_kwargs["rtol"] = args.h5_rtol
179
+ comparator_kwargs["atol"] = args.h5_atol
180
+ if args.h5_data_filter:
181
+ comparator_kwargs["data_filter"] = args.h5_data_filter
182
+ comparator_kwargs["expand_path"] = args.h5_expand_path
183
+
184
+ if file_type == "binary":
185
+ comparator_kwargs["similarity"] = args.similarity
186
+
187
+ # Create comparator and perform comparison
188
+ comparator = ComparatorFactory.create_comparator(file_type, **comparator_kwargs)
189
+ result = comparator.compare_files(
190
+ file1_path,
191
+ file2_path,
192
+ start_line,
193
+ end_line,
194
+ start_column,
195
+ end_column
196
+ )
197
+
198
+ # Output result
199
+ output = format_result(result, args.output_format)
200
+ print(output)
201
+
202
+ return 0 if result.identical else 1
203
+
204
+
205
+ def main():
206
+ """Main entry point for the compare-files command"""
207
+ try:
208
+ args = parse_arguments()
209
+ exit_code = run_comparison(args)
210
+ sys.exit(exit_code)
211
+
212
+ except ValueError as ve:
213
+ logger.error(f"ValueError: {ve}")
214
+ sys.exit(1)
215
+ except Exception as e:
216
+ logger.exception(f"An unexpected error occurred")
217
+ sys.exit(1)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
@@ -0,0 +1,7 @@
1
+ """
2
+ Configuration management module for the CLI Testing Framework.
3
+
4
+ Provides:
5
+ - import_expander – recursive expansion of ``import`` references in config files
6
+ - config_io – unified config read/write + validation
7
+ """
@@ -0,0 +1,346 @@
1
+ """
2
+ Unified configuration I/O with import-expansion support.
3
+
4
+ Provides ``load_config`` and ``save_config`` as the canonical read/write
5
+ entry points for test configuration files, with transparent import expansion.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Union
15
+
16
+ from .import_expander import expand_imports, _load_raw_config
17
+ from .inheritance_expander import resolve_inheritance
18
+
19
+ logger = logging.getLogger("symtest.config.config_io")
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Public API
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def load_config(
27
+ config_file_path: Union[str, Path],
28
+ *,
29
+ expand: bool = True,
30
+ ) -> Dict[str, Any]:
31
+ """Load and (optionally) expand import references in a config file.
32
+
33
+ Parameters
34
+ ----------
35
+ config_file_path:
36
+ Path to the config file (JSON or YAML).
37
+ expand:
38
+ If ``True`` (default), recursively expand ``import`` references.
39
+
40
+ Returns
41
+ -------
42
+ A config dict with ``test_cases`` (and optionally ``setup``).
43
+ """
44
+ path = Path(config_file_path).resolve()
45
+
46
+ if not path.exists():
47
+ raise FileNotFoundError(f"Config file not found: {path}")
48
+
49
+ config = _load_raw_config(path)
50
+
51
+ if expand:
52
+ config = expand_imports(config, path)
53
+ config = resolve_inheritance(config)
54
+
55
+ return config
56
+
57
+
58
+ def save_config(
59
+ config: Dict[str, Any],
60
+ file_path: Union[str, Path],
61
+ ) -> None:
62
+ """Save a config dict to file, choosing the format by extension.
63
+
64
+ Parameters
65
+ ----------
66
+ config:
67
+ Config dict (must contain at least ``test_cases``).
68
+ file_path:
69
+ Output path; ``.json`` → JSON, ``.yaml``/``.yml`` → YAML.
70
+ """
71
+ path = Path(file_path)
72
+ ext = path.suffix.lower()
73
+
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+
76
+ if ext == ".json":
77
+ with open(path, "w", encoding="utf-8") as f:
78
+ json.dump(config, f, indent=2, ensure_ascii=False)
79
+ elif ext in (".yaml", ".yml"):
80
+ import yaml
81
+
82
+ with open(path, "w", encoding="utf-8") as f:
83
+ yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
84
+ else:
85
+ raise ValueError(
86
+ f"Unsupported output format: {ext} (expected .json, .yaml, or .yml)"
87
+ )
88
+
89
+ logger.info("Config saved to: %s", path)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Validation
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _validate_required_fields(test_case: Dict[str, Any], index: int, source: str) -> List[str]:
97
+ """Check required fields for a single test case dict. Returns list of error messages."""
98
+ errors: List[str] = []
99
+ prefix = f"[{source}] case #{index}"
100
+
101
+ name = test_case.get("name", "<unnamed>")
102
+ prefix = f"[{source}] case '{name}'"
103
+
104
+ if "steps" in test_case:
105
+ # Sequence mode
106
+ for si, step in enumerate(test_case["steps"]):
107
+ for field in ("command", "args", "expected"):
108
+ if field not in step:
109
+ errors.append(f"{prefix} step {si}: missing required field '{field}'")
110
+ else:
111
+ # Single-command mode
112
+ for field in ("name", "command", "args", "expected"):
113
+ if field not in test_case:
114
+ errors.append(f"{prefix}: missing required field '{field}'")
115
+
116
+ return errors
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Warning-level checks (do not affect ``valid``)
121
+ # ---------------------------------------------------------------------------
122
+
123
+ def _command_warning(command: Any, workspace: Path) -> Optional[str]:
124
+ """Return a warning message if a command is clearly not executable.
125
+
126
+ Heuristic only — shell builtins, ``{placeholder}`` commands, and anything
127
+ resolvable via PATH pass silently.
128
+ """
129
+ if not isinstance(command, str) or not command.strip():
130
+ return "empty command"
131
+
132
+ from ..core.execution import _SHELL_BUILTINS
133
+
134
+ first = command.strip().split()[0]
135
+ if "{" in first or "}" in first:
136
+ return None # variable placeholder; cannot resolve statically
137
+ if first.lower() in _SHELL_BUILTINS:
138
+ return None
139
+ if Path(first).is_absolute() or "/" in first or "\\" in first:
140
+ # Path-like command: resolve relative to the workspace
141
+ candidate = Path(first)
142
+ if not candidate.is_absolute():
143
+ candidate = workspace / candidate
144
+ if not candidate.exists():
145
+ return f"command not found: '{first}'"
146
+ return None
147
+ if shutil.which(first) is None:
148
+ return f"command not found on PATH: '{first}'"
149
+ return None
150
+
151
+
152
+ def _baseline_warnings(expected: Any, prefix: str, workspace: Path) -> List[str]:
153
+ """Warn when a compare_files baseline file does not exist.
154
+
155
+ ``actual`` files are produced by the test command, so only ``baseline``
156
+ is checked. Placeholders (``{var}``) are skipped.
157
+ """
158
+ warnings: List[str] = []
159
+ if not isinstance(expected, dict):
160
+ return warnings
161
+ compare_specs = expected.get("compare_files") or []
162
+ for spec in compare_specs:
163
+ if not isinstance(spec, dict):
164
+ continue
165
+ baseline = spec.get("baseline")
166
+ if not baseline or not isinstance(baseline, str) or "{" in baseline:
167
+ continue
168
+ candidate = Path(baseline)
169
+ if not candidate.is_absolute():
170
+ candidate = workspace / candidate
171
+ if not candidate.exists():
172
+ warnings.append(f"{prefix}: baseline file not found: '{baseline}'")
173
+ return warnings
174
+
175
+
176
+ def _validate_case_warnings(
177
+ test_case: Dict[str, Any], source: str, workspace: Path,
178
+ ) -> List[str]:
179
+ """Warning-level checks for a single test case dict."""
180
+ warnings: List[str] = []
181
+ name = test_case.get("name", "<unnamed>")
182
+ prefix = f"[{source}] case '{name}'"
183
+
184
+ if "steps" in test_case:
185
+ for si, step in enumerate(test_case.get("steps", [])):
186
+ if not isinstance(step, dict):
187
+ continue
188
+ step_prefix = f"{prefix} step {si}"
189
+ cmd_warn = _command_warning(step.get("command"), workspace)
190
+ if cmd_warn:
191
+ warnings.append(f"{step_prefix}: {cmd_warn}")
192
+ warnings.extend(
193
+ _baseline_warnings(step.get("expected"), step_prefix, workspace)
194
+ )
195
+ warnings.extend(
196
+ _baseline_warnings(test_case.get("expected"), prefix, workspace)
197
+ )
198
+ else:
199
+ cmd_warn = _command_warning(test_case.get("command"), workspace)
200
+ if cmd_warn:
201
+ warnings.append(f"{prefix}: {cmd_warn}")
202
+ warnings.extend(
203
+ _baseline_warnings(test_case.get("expected"), prefix, workspace)
204
+ )
205
+
206
+ return warnings
207
+
208
+
209
+ def validate_config(
210
+ config_file_path: Union[str, Path],
211
+ workspace: Optional[str] = None,
212
+ ) -> Dict[str, Any]:
213
+ """Validate a test config file (with import expansion) without running tests.
214
+
215
+ Checks (errors — affect ``valid``):
216
+ - JSON/YAML syntax correctness (implicit from successful load)
217
+ - Required fields on every test case
218
+ - Import target existence
219
+ - Circular import detection
220
+ - extends target existence
221
+ - Circular extends inheritance detection
222
+
223
+ Warning-level checks (reported in ``warnings``, do not affect ``valid``):
224
+ - ``command`` executability (PATH lookup / path existence)
225
+ - ``compare_files`` baseline file existence
226
+
227
+ Returns a dict with keys ``valid`` (bool), ``errors`` (list),
228
+ ``warnings`` (list), and ``summary`` (dict with file/case counts).
229
+ """
230
+ path = Path(config_file_path).resolve()
231
+ errors: List[str] = []
232
+ warnings: List[str] = []
233
+ files_loaded: List[str] = []
234
+ total_cases = 0
235
+ ws = Path(workspace).resolve() if workspace else Path.cwd()
236
+
237
+ try:
238
+ config = _load_raw_config(path)
239
+ except Exception as exc:
240
+ return {
241
+ "valid": False,
242
+ "errors": [f"Syntax error in {path}: {exc}"],
243
+ "warnings": [],
244
+ "summary": {"files": 0, "cases": 0},
245
+ }
246
+
247
+ # Collect all files and cases (walk the import tree)
248
+ all_cases: List[tuple] = [] # (case_dict, source_path)
249
+
250
+ def _walk(current_path: Path, current_config: Dict[str, Any], visited: set) -> None:
251
+ nonlocal total_cases
252
+ canonical = str(current_path.resolve())
253
+ if canonical in visited:
254
+ errors.append(f"Circular import detected: {canonical}")
255
+ return
256
+ visited.add(canonical)
257
+ files_loaded.append(str(current_path))
258
+
259
+ for idx, item in enumerate(current_config.get("test_cases", [])):
260
+ if "import" in item:
261
+ sub_path = (current_path.parent / item["import"]).resolve()
262
+ if not sub_path.exists():
263
+ errors.append(
264
+ f"Import target not found: {sub_path} "
265
+ f"(referenced by {current_path})"
266
+ )
267
+ continue
268
+ try:
269
+ sub_config = _load_raw_config(sub_path)
270
+ _walk(sub_path, sub_config, visited)
271
+ except Exception as exc:
272
+ errors.append(f"Error loading {sub_path}: {exc}")
273
+ else:
274
+ is_abstract = item.get("abstract", False)
275
+ has_extends = "extends" in item
276
+ source = str(current_path)
277
+
278
+ all_cases.append((item, source))
279
+
280
+ if is_abstract:
281
+ # Abstract cases are templates — skip counting and field validation
282
+ continue
283
+
284
+ total_cases += 1
285
+ # extends cases inherit fields from parent; skip required-field checks
286
+ if not has_extends:
287
+ errors.extend(_validate_required_fields(item, idx, source))
288
+ warnings.extend(_validate_case_warnings(item, source, ws))
289
+
290
+ _walk(path, config, set())
291
+
292
+ # ── Inheritance validation (extends targets + cycle detection) ──
293
+ def _validate_inheritance() -> None:
294
+ """Check extends target existence and circular inheritance chains."""
295
+ names: Dict[str, Dict[str, Any]] = {}
296
+ for case_dict, source in all_cases:
297
+ name = case_dict.get("name")
298
+ if name:
299
+ if name in names:
300
+ # Duplicate names are ambiguous for extends; flag as error
301
+ errors.append(
302
+ f"Duplicate case name '{name}' "
303
+ f"(found in multiple config files; "
304
+ f"extends references must be unambiguous)"
305
+ )
306
+ names[name] = case_dict
307
+
308
+ for case_dict, source in all_cases:
309
+ extends_target = case_dict.get("extends")
310
+ if not extends_target:
311
+ continue
312
+ case_name = case_dict.get("name", "<unnamed>")
313
+ if extends_target not in names:
314
+ errors.append(
315
+ f"[{source}] case '{case_name}': "
316
+ f"extends target '{extends_target}' not found"
317
+ )
318
+
319
+ # Cycle detection: follow extends chain from each case
320
+ for name, case_dict in names.items():
321
+ visited_chain: List[str] = []
322
+ current = name
323
+ while current in names:
324
+ target = names[current].get("extends")
325
+ if not target:
326
+ break
327
+ if target in visited_chain:
328
+ chain = " -> ".join(visited_chain + [target])
329
+ errors.append(f"Circular extends detected: {chain}")
330
+ break
331
+ visited_chain.append(current)
332
+ current = target
333
+
334
+ _validate_inheritance()
335
+
336
+ valid = len(errors) == 0
337
+ return {
338
+ "valid": valid,
339
+ "errors": errors,
340
+ "warnings": warnings,
341
+ "summary": {
342
+ "files": len(files_loaded),
343
+ "cases": total_cases,
344
+ "files_loaded": files_loaded,
345
+ },
346
+ }