elspais 0.11.2__py3-none-any.whl → 0.43.5__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 (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,807 @@
1
+ """Config module - Configuration loading and management.
2
+
3
+ Exports:
4
+ - ConfigLoader: Configuration container with dot-notation access
5
+ - load_config: Load config from TOML file
6
+ - find_config_file: Find .elspais.toml in directory hierarchy
1
7
  """
2
- elspais.config - Configuration loading and defaults
3
- """
4
8
 
5
- from elspais.config.defaults import DEFAULT_CONFIG
6
- from elspais.config.loader import find_config_file, load_config, merge_configs
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from elspais.testing.config import TestingConfig
20
+
21
+ # Default configuration values
22
+ DEFAULT_CONFIG: dict[str, Any] = {
23
+ "patterns": {
24
+ "id_template": "{prefix}-{type}{id}",
25
+ "prefix": "REQ",
26
+ "types": {
27
+ "prd": {"id": "p", "name": "PRD", "level": 1},
28
+ "ops": {"id": "o", "name": "OPS", "level": 2},
29
+ "dev": {"id": "d", "name": "DEV", "level": 3},
30
+ },
31
+ "id_format": {"style": "numeric", "digits": 5, "leading_zeros": True},
32
+ },
33
+ "spec": {
34
+ "directories": ["spec"],
35
+ "patterns": ["*.md"],
36
+ "skip_files": [],
37
+ "skip_dirs": [],
38
+ },
39
+ "rules": {
40
+ "hierarchy": {
41
+ "dev": ["ops", "prd"],
42
+ "ops": ["prd"],
43
+ "prd": [],
44
+ },
45
+ },
46
+ "testing": {
47
+ "enabled": False,
48
+ "test_dirs": ["tests"],
49
+ "patterns": ["test_*.py", "*_test.py"],
50
+ "result_files": [],
51
+ "reference_patterns": [],
52
+ "reference_keyword": "Validates",
53
+ },
54
+ "ignore": {
55
+ "global": ["node_modules", ".git", "__pycache__", "*.pyc", ".venv", ".env"],
56
+ "spec": ["README.md", "INDEX.md"],
57
+ "code": ["*_test.py", "conftest.py", "test_*.py"],
58
+ "test": ["fixtures/**", "__snapshots__"],
59
+ },
60
+ "references": {
61
+ "defaults": {
62
+ "separators": ["-", "_"],
63
+ "case_sensitive": False,
64
+ "prefix_optional": False,
65
+ "comment_styles": ["#", "//", "--"],
66
+ "keywords": {
67
+ "implements": ["Implements", "IMPLEMENTS"],
68
+ "validates": ["Validates", "Tests", "VALIDATES", "TESTS"],
69
+ "refines": ["Refines", "REFINES"],
70
+ },
71
+ },
72
+ "overrides": [],
73
+ },
74
+ "keywords": {
75
+ "min_length": 3,
76
+ },
77
+ }
78
+
79
+
80
+ class ConfigLoader:
81
+ """Configuration container with dot-notation access."""
82
+
83
+ def __init__(self, data: dict[str, Any]) -> None:
84
+ """Initialize with configuration data.
85
+
86
+ Args:
87
+ data: Configuration dictionary.
88
+ """
89
+ self._data = data
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: dict[str, Any]) -> ConfigLoader:
93
+ """Create ConfigLoader from dictionary.
94
+
95
+ Args:
96
+ data: Configuration dictionary.
97
+
98
+ Returns:
99
+ ConfigLoader instance.
100
+ """
101
+ merged = _merge_configs(DEFAULT_CONFIG, data)
102
+ return cls(merged)
103
+
104
+ def get(self, key: str, default: Any = None) -> Any:
105
+ """Get configuration value by dot-notation key.
106
+
107
+ Args:
108
+ key: Dot-separated key path (e.g., "patterns.prefix").
109
+ default: Default value if key not found.
110
+
111
+ Returns:
112
+ Configuration value or default.
113
+ """
114
+ parts = key.split(".")
115
+ value = self._data
116
+
117
+ for part in parts:
118
+ if isinstance(value, dict) and part in value:
119
+ value = value[part]
120
+ else:
121
+ return default
122
+
123
+ return value
124
+
125
+ def get_raw(self) -> dict[str, Any]:
126
+ """Get raw configuration dictionary.
127
+
128
+ Returns:
129
+ Complete configuration dictionary.
130
+ """
131
+ return self._data
132
+
133
+
134
+ def load_config(config_path: Path) -> ConfigLoader:
135
+ """Load configuration from a TOML file.
136
+
137
+ Args:
138
+ config_path: Path to the .elspais.toml file.
139
+
140
+ Returns:
141
+ ConfigLoader with merged configuration.
142
+ """
143
+ content = config_path.read_text(encoding="utf-8")
144
+ user_config = _parse_toml(content)
145
+ merged = _merge_configs(DEFAULT_CONFIG, user_config)
146
+ merged = _apply_env_overrides(merged)
147
+ return ConfigLoader(merged)
148
+
149
+
150
+ def find_git_root(start_path: Path | None = None) -> Path | None:
151
+ """Find the root directory of a git repository.
152
+
153
+ Searches upward from start_path for a .git directory or file (worktree).
154
+
155
+ Args:
156
+ start_path: Directory to start searching from (defaults to cwd).
157
+
158
+ Returns:
159
+ Path to git repository root, or None if not in a git repo.
160
+ """
161
+ if start_path is None:
162
+ start_path = Path.cwd()
163
+
164
+ current = start_path.resolve()
165
+
166
+ if current.is_file():
167
+ current = current.parent
168
+
169
+ while current != current.parent:
170
+ git_marker = current / ".git"
171
+ if git_marker.exists():
172
+ # Could be a directory (normal repo) or file (worktree)
173
+ return current
174
+
175
+ current = current.parent
176
+
177
+ return None
178
+
179
+
180
+ def find_config_file(start_path: Path) -> Path | None:
181
+ """Find .elspais.toml configuration file.
182
+
183
+ Searches from start_path up to git root or filesystem root.
184
+
185
+ Args:
186
+ start_path: Directory to start searching from.
187
+
188
+ Returns:
189
+ Path to config file if found, None otherwise.
190
+ """
191
+ current = start_path.resolve()
192
+
193
+ if current.is_file():
194
+ current = current.parent
195
+
196
+ while current != current.parent:
197
+ config_path = current / ".elspais.toml"
198
+ if config_path.exists():
199
+ return config_path
200
+
201
+ # Stop at git root
202
+ if (current / ".git").exists():
203
+ break
204
+
205
+ current = current.parent
206
+
207
+ return None
208
+
209
+
210
+ def _merge_configs(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
211
+ """Recursively merge configuration dictionaries.
212
+
213
+ Args:
214
+ base: Base configuration.
215
+ override: Override configuration.
216
+
217
+ Returns:
218
+ Merged configuration.
219
+ """
220
+ result = dict(base)
221
+
222
+ for key, value in override.items():
223
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
224
+ result[key] = _merge_configs(result[key], value)
225
+ else:
226
+ result[key] = value
227
+
228
+ return result
229
+
230
+
231
+ def _apply_env_overrides(config: dict[str, Any]) -> dict[str, Any]:
232
+ """Apply environment variable overrides.
233
+
234
+ Looks for ELSPAIS_* environment variables.
235
+
236
+ Args:
237
+ config: Configuration dictionary.
238
+
239
+ Returns:
240
+ Configuration with environment overrides applied.
241
+ """
242
+ # Example: ELSPAIS_PATTERNS_PREFIX=MYREQ
243
+ for key, value in os.environ.items():
244
+ if key.startswith("ELSPAIS_"):
245
+ # Convert ELSPAIS_PATTERNS_PREFIX to patterns.prefix
246
+ config_key = key[8:].lower().replace("_", ".")
247
+ _set_nested(config, config_key, value)
248
+
249
+ return config
250
+
251
+
252
+ def _set_nested(data: dict[str, Any], key: str, value: Any) -> None:
253
+ """Set a value at a nested key path."""
254
+ parts = key.split(".")
255
+ current = data
256
+
257
+ for part in parts[:-1]:
258
+ if part not in current:
259
+ current[part] = {}
260
+ current = current[part]
261
+
262
+ current[parts[-1]] = value
263
+
264
+
265
+ def _parse_toml(content: str) -> dict[str, Any]:
266
+ """Parse TOML content into a dictionary.
267
+
268
+ Simple zero-dependency TOML parser.
269
+
270
+ Args:
271
+ content: TOML file content.
272
+
273
+ Returns:
274
+ Parsed dictionary.
275
+ """
276
+ result: dict[str, Any] = {}
277
+ current_section: list[str] = []
278
+ lines = content.split("\n")
279
+
280
+ for line in lines:
281
+ line = line.strip()
282
+
283
+ # Skip empty lines and comments
284
+ if not line or line.startswith("#"):
285
+ continue
286
+
287
+ # Section header
288
+ if line.startswith("[") and not line.startswith("[["):
289
+ section = line.strip("[]").strip()
290
+ current_section = section.split(".")
291
+ _ensure_nested(result, current_section)
292
+ continue
293
+
294
+ # Key-value pair
295
+ if "=" in line:
296
+ key, value = line.split("=", 1)
297
+ key = key.strip()
298
+ value = _parse_value(value.strip())
299
+
300
+ if current_section:
301
+ target = result
302
+ for part in current_section:
303
+ target = target[part]
304
+ target[key] = value
305
+ else:
306
+ result[key] = value
307
+
308
+ return result
309
+
310
+
311
+ def _ensure_nested(data: dict[str, Any], keys: list[str]) -> None:
312
+ """Ensure nested dictionary structure exists."""
313
+ current = data
314
+ for key in keys:
315
+ if key not in current:
316
+ current[key] = {}
317
+ current = current[key]
318
+
319
+
320
+ def _parse_value(value: str) -> Any:
321
+ """Parse a TOML value string."""
322
+ # String (quoted)
323
+ if (value.startswith('"') and value.endswith('"')) or (
324
+ value.startswith("'") and value.endswith("'")
325
+ ):
326
+ return value[1:-1]
327
+
328
+ # Boolean
329
+ if value.lower() == "true":
330
+ return True
331
+ if value.lower() == "false":
332
+ return False
333
+
334
+ # Integer
335
+ if re.match(r"^-?\d+$", value):
336
+ return int(value)
337
+
338
+ # Float
339
+ if re.match(r"^-?\d+\.\d+$", value):
340
+ return float(value)
341
+
342
+ # Array (simple single-line)
343
+ if value.startswith("[") and value.endswith("]"):
344
+ inner = value[1:-1].strip()
345
+ if not inner:
346
+ return []
347
+ items = [_parse_value(item.strip()) for item in inner.split(",")]
348
+ return items
349
+
350
+ # Inline table: { key = value, key2 = value2 }
351
+ if value.startswith("{") and value.endswith("}"):
352
+ inner = value[1:-1].strip()
353
+ if not inner:
354
+ return {}
355
+ result = {}
356
+ # Split on commas, but handle nested structures
357
+ pairs = inner.split(",")
358
+ for pair in pairs:
359
+ pair = pair.strip()
360
+ if "=" in pair:
361
+ k, v = pair.split("=", 1)
362
+ result[k.strip()] = _parse_value(v.strip())
363
+ return result
364
+
365
+ return value
366
+
367
+
368
+ def get_config(
369
+ config_path: Path | None = None,
370
+ start_path: Path | None = None,
371
+ quiet: bool = False,
372
+ ) -> dict[str, Any]:
373
+ """Get configuration with auto-discovery and fallback.
374
+
375
+ This is the standard helper for command modules to load configuration.
376
+ It handles:
377
+ - Explicit config file path (if provided)
378
+ - Config file discovery from start_path
379
+ - Fallback to defaults if no config found
380
+ - Error reporting (unless quiet=True)
381
+
382
+ Args:
383
+ config_path: Explicit config file path (optional)
384
+ start_path: Directory to search for config (defaults to cwd)
385
+ quiet: Suppress error messages
386
+
387
+ Returns:
388
+ Configuration dictionary (defaults if not found)
389
+ """
390
+ import sys
391
+
392
+ if start_path is None:
393
+ start_path = Path.cwd()
394
+
395
+ # Use explicit config path or discover
396
+ resolved_path = config_path if config_path else find_config_file(start_path)
397
+
398
+ if resolved_path and resolved_path.exists():
399
+ try:
400
+ return load_config(resolved_path).get_raw()
401
+ except Exception as e:
402
+ if not quiet:
403
+ print(f"Warning: Error loading config from {resolved_path}: {e}", file=sys.stderr)
404
+
405
+ # Return defaults
406
+ return dict(DEFAULT_CONFIG)
407
+
408
+
409
+ def get_spec_directories(
410
+ spec_dir_override: Path | None,
411
+ config: dict[str, Any],
412
+ base_path: Path | None = None,
413
+ ) -> list[Path]:
414
+ """Get the spec directories from override or config.
415
+
416
+ Args:
417
+ spec_dir_override: Explicit spec directory (e.g., from CLI --spec-dir)
418
+ config: Configuration dictionary
419
+ base_path: Base path to resolve relative directories (defaults to cwd)
420
+
421
+ Returns:
422
+ List of existing spec directory paths
423
+ """
424
+ if spec_dir_override:
425
+ return [spec_dir_override]
426
+
427
+ if base_path is None:
428
+ base_path = Path.cwd()
429
+
430
+ # Get directories from config - check both "directories" and "spec" sections
431
+ dir_config = config.get("directories", {}).get("spec")
432
+ if dir_config is None:
433
+ dir_config = config.get("spec", {}).get("directories", ["spec"])
434
+
435
+ # Handle both string and list
436
+ if isinstance(dir_config, str):
437
+ dir_list = [dir_config]
438
+ else:
439
+ dir_list = list(dir_config)
440
+
441
+ # Resolve paths and filter to existing
442
+ result = []
443
+ for d in dir_list:
444
+ path = Path(d)
445
+ if not path.is_absolute():
446
+ path = base_path / path
447
+ if path.exists() and path.is_dir():
448
+ result.append(path)
449
+
450
+ return result
451
+
452
+
453
+ def get_code_directories(
454
+ config: dict[str, Any],
455
+ base_path: Path | None = None,
456
+ ) -> list[Path]:
457
+ """Get code directories from configuration.
458
+
459
+ Args:
460
+ config: Configuration dictionary
461
+ base_path: Base path to resolve relative directories (defaults to cwd)
462
+
463
+ Returns:
464
+ List of existing code directory paths
465
+ """
466
+ if base_path is None:
467
+ base_path = Path.cwd()
468
+
469
+ dir_config = config.get("directories", {}).get("code", ["src"])
470
+
471
+ # Handle both string and list
472
+ if isinstance(dir_config, str):
473
+ dir_list = [dir_config]
474
+ else:
475
+ dir_list = list(dir_config)
476
+
477
+ # Resolve paths and filter to existing
478
+ result = []
479
+ for d in dir_list:
480
+ path = Path(d)
481
+ if not path.is_absolute():
482
+ path = base_path / path
483
+ if path.exists() and path.is_dir():
484
+ result.append(path)
485
+
486
+ return result
487
+
488
+
489
+ def get_docs_directories(
490
+ config: dict[str, Any],
491
+ base_path: Path | None = None,
492
+ ) -> list[Path]:
493
+ """Get documentation directories from configuration.
494
+
495
+ Uses [directories].docs config for scanning documentation files
496
+ for requirement references and traceability.
497
+
498
+ Args:
499
+ config: Configuration dictionary
500
+ base_path: Base path to resolve relative directories (defaults to cwd)
501
+
502
+ Returns:
503
+ List of existing docs directory paths
504
+ """
505
+ if base_path is None:
506
+ base_path = Path.cwd()
507
+
508
+ dir_config = config.get("directories", {}).get("docs", ["docs"])
509
+
510
+ # Handle both string and list
511
+ if isinstance(dir_config, str):
512
+ dir_list = [dir_config]
513
+ else:
514
+ dir_list = list(dir_config)
515
+
516
+ # Resolve paths and filter to existing
517
+ result = []
518
+ for d in dir_list:
519
+ path = Path(d)
520
+ if not path.is_absolute():
521
+ path = base_path / path
522
+ if path.exists() and path.is_dir():
523
+ result.append(path)
524
+
525
+ return result
526
+
527
+
528
+ # Re-export parse_toml for use by config_cmd
529
+ parse_toml = _parse_toml
530
+
531
+
532
+ @dataclass
533
+ class IgnoreConfig:
534
+ """Unified configuration for ignoring files and directories.
535
+
536
+ Supports glob patterns (fnmatch) for flexible matching.
537
+ Patterns can be scoped to specific contexts (spec, code, test).
538
+
539
+ Attributes:
540
+ global_patterns: Patterns applied everywhere
541
+ spec_patterns: Additional patterns for spec file scanning
542
+ code_patterns: Additional patterns for code scanning
543
+ test_patterns: Additional patterns for test scanning
544
+ """
545
+
546
+ global_patterns: list[str]
547
+ spec_patterns: list[str]
548
+ code_patterns: list[str]
549
+ test_patterns: list[str]
550
+
551
+ @classmethod
552
+ def from_dict(cls, data: dict[str, Any]) -> IgnoreConfig:
553
+ """Create IgnoreConfig from configuration dictionary.
554
+
555
+ Args:
556
+ data: Dictionary from [ignore] config section
557
+
558
+ Returns:
559
+ IgnoreConfig instance
560
+ """
561
+ return cls(
562
+ global_patterns=data.get("global", []),
563
+ spec_patterns=data.get("spec", []),
564
+ code_patterns=data.get("code", []),
565
+ test_patterns=data.get("test", []),
566
+ )
567
+
568
+ def should_ignore(self, path: str | Path, scope: str = "global") -> bool:
569
+ """Check if a path should be ignored based on patterns.
570
+
571
+ Matches against:
572
+ 1. Global patterns (always checked)
573
+ 2. Scope-specific patterns (if scope is provided)
574
+
575
+ Supports glob patterns via fnmatch:
576
+ - "*" matches any characters within a path component
577
+ - "**" matches across directory separators (when using pathlib)
578
+ - "?" matches a single character
579
+
580
+ Args:
581
+ path: Path to check (can be file or directory)
582
+ scope: Context scope ("global", "spec", "code", "test")
583
+
584
+ Returns:
585
+ True if path should be ignored
586
+ """
587
+ if isinstance(path, Path):
588
+ path_str = str(path)
589
+ path_name = path.name
590
+ path_parts = path.parts
591
+ else:
592
+ path_str = path
593
+ path_obj = Path(path)
594
+ path_name = path_obj.name
595
+ path_parts = path_obj.parts
596
+
597
+ # Collect all applicable patterns
598
+ patterns = list(self.global_patterns)
599
+ if scope == "spec":
600
+ patterns.extend(self.spec_patterns)
601
+ elif scope == "code":
602
+ patterns.extend(self.code_patterns)
603
+ elif scope == "test":
604
+ patterns.extend(self.test_patterns)
605
+
606
+ for pattern in patterns:
607
+ # Check if pattern matches the file/dir name directly
608
+ if fnmatch.fnmatch(path_name, pattern):
609
+ return True
610
+
611
+ # Check if pattern matches any path component
612
+ for part in path_parts:
613
+ if fnmatch.fnmatch(part, pattern):
614
+ return True
615
+
616
+ # Check if pattern matches the full relative path
617
+ if fnmatch.fnmatch(path_str, pattern):
618
+ return True
619
+
620
+ return False
621
+
622
+ def get_patterns_for_scope(self, scope: str) -> list[str]:
623
+ """Get all patterns applicable to a scope (global + scope-specific).
624
+
625
+ Args:
626
+ scope: Context scope ("global", "spec", "code", "test")
627
+
628
+ Returns:
629
+ Combined list of patterns
630
+ """
631
+ patterns = list(self.global_patterns)
632
+ if scope == "spec":
633
+ patterns.extend(self.spec_patterns)
634
+ elif scope == "code":
635
+ patterns.extend(self.code_patterns)
636
+ elif scope == "test":
637
+ patterns.extend(self.test_patterns)
638
+ return patterns
639
+
640
+
641
+ class ConfigValidationError(Exception):
642
+ """Raised when configuration validation fails."""
643
+
644
+ pass
645
+
646
+
647
+ def validate_project_config(config: dict[str, Any]) -> list[str]:
648
+ """Validate project type configuration consistency.
649
+
650
+ Checks that project.type matches the presence of [core] and [associated] sections:
651
+ - project.type = "core" → [associated] MAY exist (defines associated repos)
652
+ - project.type = "associated" → [core] MUST exist (specifies core repo path)
653
+ - project.type not set → [core] and [associated] sections are ERRORS
654
+
655
+ Args:
656
+ config: Configuration dictionary
657
+
658
+ Returns:
659
+ List of validation error messages (empty if valid)
660
+ """
661
+ errors = []
662
+
663
+ project_type = config.get("project", {}).get("type")
664
+ has_core_section = "core" in config and isinstance(config["core"], dict)
665
+ has_associated_section = "associated" in config and isinstance(config["associated"], dict)
666
+
667
+ if project_type == "associated":
668
+ # Associated repos MUST have a [core] section
669
+ if not has_core_section:
670
+ errors.append(
671
+ "project.type='associated' requires a [core] section with 'path' "
672
+ "to the core repository"
673
+ )
674
+ elif not config["core"].get("path"):
675
+ errors.append(
676
+ "[core] section must specify 'path' to core repository " "for associated projects"
677
+ )
678
+ elif project_type == "core":
679
+ # Core repos MAY have [associated] section - no validation needed
680
+ pass
681
+ elif project_type is None:
682
+ # No project type set - [core] and [associated] sections are errors
683
+ if has_core_section:
684
+ errors.append(
685
+ "[core] section found but project.type is not set. "
686
+ "Set project.type='associated' to use this section"
687
+ )
688
+ if has_associated_section:
689
+ errors.append(
690
+ "[associated] section found but project.type is not set. "
691
+ "Set project.type='core' or 'associated' to use this section"
692
+ )
693
+ else:
694
+ # Unknown project type
695
+ errors.append(
696
+ f"Unknown project.type='{project_type}'. " "Valid values: 'core', 'associated'"
697
+ )
698
+
699
+ return errors
700
+
701
+
702
+ def get_testing_config(config: dict[str, Any]) -> TestingConfig:
703
+ """Get TestingConfig from configuration dictionary.
704
+
705
+ Args:
706
+ config: Configuration dictionary from get_config() or load_config().get_raw()
707
+
708
+ Returns:
709
+ TestingConfig instance with values from [testing] section or defaults.
710
+ """
711
+ from elspais.testing.config import TestingConfig
712
+
713
+ testing_data = config.get("testing", {})
714
+ return TestingConfig.from_dict(testing_data)
715
+
716
+
717
+ def get_test_directories(
718
+ config: dict[str, Any],
719
+ base_path: Path | None = None,
720
+ ) -> list[Path]:
721
+ """Get test directories from configuration.
722
+
723
+ Uses [testing].test_dirs config, falling back to common defaults.
724
+
725
+ Args:
726
+ config: Configuration dictionary
727
+ base_path: Base path to resolve relative directories (defaults to cwd)
728
+
729
+ Returns:
730
+ List of existing test directory paths
731
+ """
732
+ if base_path is None:
733
+ base_path = Path.cwd()
734
+
735
+ # Get from [testing] section first, then fall back to defaults
736
+ testing_config = config.get("testing", {})
737
+ dir_config = testing_config.get("test_dirs", ["tests"])
738
+
739
+ # Handle both string and list
740
+ if isinstance(dir_config, str):
741
+ dir_list = [dir_config]
742
+ else:
743
+ dir_list = list(dir_config)
744
+
745
+ # Resolve paths and filter to existing
746
+ result = []
747
+ for d in dir_list:
748
+ path = Path(d)
749
+ if not path.is_absolute():
750
+ path = base_path / path
751
+ if path.exists() and path.is_dir():
752
+ result.append(path)
753
+
754
+ return result
755
+
756
+
757
+ def get_ignore_config(config: dict[str, Any]) -> IgnoreConfig:
758
+ """Get IgnoreConfig from configuration dictionary.
759
+
760
+ The IgnoreConfig provides a unified way to check if paths should be ignored
761
+ during file scanning. It supports glob patterns and scope-specific rules.
762
+
763
+ Args:
764
+ config: Configuration dictionary from get_config() or load_config().get_raw()
765
+
766
+ Returns:
767
+ IgnoreConfig instance with patterns from [ignore] section or defaults.
768
+ """
769
+ ignore_data = config.get("ignore", {})
770
+
771
+ # Also check legacy spec.skip_files and spec.skip_dirs and merge them
772
+ spec_config = config.get("spec", {})
773
+ legacy_skip_files = spec_config.get("skip_files", [])
774
+ legacy_skip_dirs = spec_config.get("skip_dirs", [])
775
+
776
+ # Merge legacy patterns into spec scope
777
+ merged_spec = list(ignore_data.get("spec", []))
778
+ merged_spec.extend(legacy_skip_files)
779
+ merged_spec.extend(legacy_skip_dirs)
780
+
781
+ # Create config with merged patterns
782
+ return IgnoreConfig(
783
+ global_patterns=ignore_data.get("global", []),
784
+ spec_patterns=list(set(merged_spec)), # Deduplicate
785
+ code_patterns=ignore_data.get("code", []),
786
+ test_patterns=ignore_data.get("test", []),
787
+ )
788
+
7
789
 
8
790
  __all__ = [
791
+ "ConfigLoader",
792
+ "ConfigValidationError",
793
+ "IgnoreConfig",
9
794
  "load_config",
10
795
  "find_config_file",
11
- "merge_configs",
796
+ "find_git_root",
797
+ "get_config",
798
+ "get_spec_directories",
799
+ "get_code_directories",
800
+ "get_docs_directories",
801
+ "get_testing_config",
802
+ "get_test_directories",
803
+ "get_ignore_config",
804
+ "validate_project_config",
12
805
  "DEFAULT_CONFIG",
806
+ "parse_toml",
13
807
  ]