elspais 0.11.1__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.
- elspais/__init__.py +2 -11
- elspais/{sponsors/__init__.py → associates.py} +102 -58
- elspais/cli.py +395 -79
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +121 -173
- elspais/commands/changed.py +15 -30
- elspais/commands/config_cmd.py +13 -16
- elspais/commands/edit.py +60 -44
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +167 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -114
- elspais/commands/init.py +103 -26
- elspais/commands/reformat_cmd.py +41 -444
- elspais/commands/rules_cmd.py +7 -3
- elspais/commands/trace.py +444 -321
- elspais/commands/validate.py +195 -415
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -3
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +47 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +2016 -247
- elspais/testing/__init__.py +4 -4
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +58 -57
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -173
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -352
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -640
- elspais/core/rules.py +0 -514
- elspais/mcp/context.py +0 -171
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -119
- elspais/reformat/hierarchy.py +0 -246
- elspais/reformat/line_breaks.py +0 -220
- elspais/reformat/prompts.py +0 -123
- elspais/reformat/transformer.py +0 -264
- elspais/trace_view/__init__.py +0 -54
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -329
- elspais/trace_view/generators/csv.py +0 -122
- elspais/trace_view/generators/markdown.py +0 -175
- elspais/trace_view/html/__init__.py +0 -31
- elspais/trace_view/html/generator.py +0 -1006
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -353
- elspais/trace_view/review/__init__.py +0 -60
- elspais/trace_view/review/branches.py +0 -1149
- elspais/trace_view/review/models.py +0 -1205
- elspais/trace_view/review/position.py +0 -609
- elspais/trace_view/review/server.py +0 -1056
- elspais/trace_view/review/status.py +0 -470
- elspais/trace_view/review/storage.py +0 -1367
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.1.dist-info/RECORD +0 -101
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/config/__init__.py
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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
|
-
"
|
|
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
|
]
|