elspais 0.9.3__py3-none-any.whl → 0.11.1__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/cli.py +141 -10
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +44 -16
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/METADATA +36 -18
- elspais-0.11.1.dist-info/RECORD +101 -0
- elspais-0.9.3.dist-info/RECORD +0 -40
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/WHEEL +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/entry_points.txt +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
elspais.sponsors - Sponsor/associated repository configuration loading.
|
|
3
|
+
|
|
4
|
+
Provides functions for loading sponsor configurations from YAML files
|
|
5
|
+
and resolving sponsor spec directories.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Sponsor:
|
|
16
|
+
"""
|
|
17
|
+
Represents a sponsor/associated repository configuration.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
name: Sponsor name (e.g., "callisto")
|
|
21
|
+
code: Short code used in requirement IDs (e.g., "CAL")
|
|
22
|
+
enabled: Whether this sponsor is enabled for scanning
|
|
23
|
+
path: Default path relative to project root
|
|
24
|
+
spec_path: Spec directory within sponsor path (e.g., "spec")
|
|
25
|
+
local_path: Override path for local development (from .local.yml)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
code: str
|
|
30
|
+
enabled: bool = True
|
|
31
|
+
path: str = ""
|
|
32
|
+
spec_path: str = "spec"
|
|
33
|
+
local_path: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SponsorsConfig:
|
|
38
|
+
"""
|
|
39
|
+
Container for sponsor configuration.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
sponsors: List of Sponsor objects
|
|
43
|
+
config_file: Path to the sponsors config file
|
|
44
|
+
local_dir: Default base directory for sponsor repos
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
sponsors: List[Sponsor] = field(default_factory=list)
|
|
48
|
+
config_file: str = ""
|
|
49
|
+
local_dir: str = "sponsor"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_yaml(content: str) -> Dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Parse simple YAML content into a dictionary.
|
|
55
|
+
|
|
56
|
+
This is a zero-dependency YAML parser that handles basic structures:
|
|
57
|
+
- Key-value pairs
|
|
58
|
+
- Nested dictionaries
|
|
59
|
+
- Lists of dictionaries
|
|
60
|
+
- Strings, booleans, numbers
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
content: YAML file content
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Parsed dictionary
|
|
67
|
+
"""
|
|
68
|
+
result: Dict[str, Any] = {}
|
|
69
|
+
current_key: Optional[str] = None
|
|
70
|
+
current_list: Optional[List[Dict]] = None
|
|
71
|
+
current_dict: Optional[Dict[str, Any]] = None
|
|
72
|
+
list_key: Optional[str] = None
|
|
73
|
+
indent_stack: List[tuple] = [] # (indent_level, container)
|
|
74
|
+
|
|
75
|
+
lines = content.split("\n")
|
|
76
|
+
|
|
77
|
+
for line in lines:
|
|
78
|
+
# Skip empty lines and comments
|
|
79
|
+
stripped = line.strip()
|
|
80
|
+
if not stripped or stripped.startswith("#"):
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Calculate indent level
|
|
84
|
+
indent = len(line) - len(line.lstrip())
|
|
85
|
+
|
|
86
|
+
# Handle list item (starts with -)
|
|
87
|
+
if stripped.startswith("- "):
|
|
88
|
+
item_content = stripped[2:].strip()
|
|
89
|
+
|
|
90
|
+
# List item with inline key-value (e.g., "- name: value")
|
|
91
|
+
if ":" in item_content:
|
|
92
|
+
if current_list is None:
|
|
93
|
+
current_list = []
|
|
94
|
+
if current_key:
|
|
95
|
+
result[current_key] = current_list
|
|
96
|
+
current_dict = {}
|
|
97
|
+
current_list.append(current_dict)
|
|
98
|
+
|
|
99
|
+
# Parse the key-value on the same line
|
|
100
|
+
key, value = item_content.split(":", 1)
|
|
101
|
+
key = key.strip()
|
|
102
|
+
value = value.strip()
|
|
103
|
+
if value:
|
|
104
|
+
current_dict[key] = _parse_yaml_value(value)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Handle key-value pair
|
|
108
|
+
if ":" in stripped:
|
|
109
|
+
key, value = stripped.split(":", 1)
|
|
110
|
+
key = key.strip()
|
|
111
|
+
value = value.strip()
|
|
112
|
+
|
|
113
|
+
if value:
|
|
114
|
+
# Inline value
|
|
115
|
+
parsed_value = _parse_yaml_value(value)
|
|
116
|
+
if current_dict is not None and indent > 0:
|
|
117
|
+
current_dict[key] = parsed_value
|
|
118
|
+
else:
|
|
119
|
+
result[key] = parsed_value
|
|
120
|
+
else:
|
|
121
|
+
# Nested structure starts
|
|
122
|
+
current_key = key
|
|
123
|
+
if current_dict is not None and indent > 0:
|
|
124
|
+
# Nested dict within list item
|
|
125
|
+
pass
|
|
126
|
+
else:
|
|
127
|
+
# Top-level or second-level key
|
|
128
|
+
current_list = None
|
|
129
|
+
current_dict = None
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_yaml_value(value: str) -> Any:
|
|
135
|
+
"""Parse a YAML value string."""
|
|
136
|
+
value = value.strip()
|
|
137
|
+
|
|
138
|
+
# Remove quotes if present
|
|
139
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
140
|
+
value.startswith("'") and value.endswith("'")
|
|
141
|
+
):
|
|
142
|
+
return value[1:-1]
|
|
143
|
+
|
|
144
|
+
# Boolean
|
|
145
|
+
if value.lower() == "true":
|
|
146
|
+
return True
|
|
147
|
+
if value.lower() == "false":
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Integer
|
|
151
|
+
if re.match(r"^-?\d+$", value):
|
|
152
|
+
return int(value)
|
|
153
|
+
|
|
154
|
+
# Float
|
|
155
|
+
if re.match(r"^-?\d+\.\d+$", value):
|
|
156
|
+
return float(value)
|
|
157
|
+
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_sponsors_yaml(yaml_path: Path) -> Dict[str, Any]:
|
|
162
|
+
"""
|
|
163
|
+
Load sponsors configuration from a YAML file.
|
|
164
|
+
|
|
165
|
+
Handles the nested structure:
|
|
166
|
+
```yaml
|
|
167
|
+
sponsors:
|
|
168
|
+
local:
|
|
169
|
+
- name: callisto
|
|
170
|
+
code: CAL
|
|
171
|
+
enabled: true
|
|
172
|
+
path: sponsor/callisto
|
|
173
|
+
spec_path: spec
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
yaml_path: Path to the sponsors YAML file
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Dictionary with sponsor configuration
|
|
181
|
+
"""
|
|
182
|
+
if not yaml_path.exists():
|
|
183
|
+
return {}
|
|
184
|
+
|
|
185
|
+
content = yaml_path.read_text(encoding="utf-8")
|
|
186
|
+
return _parse_sponsors_yaml(content)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parse_sponsors_yaml(content: str) -> Dict[str, Any]:
|
|
190
|
+
"""
|
|
191
|
+
Parse sponsors YAML content with proper handling of nested lists.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
content: YAML file content
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Parsed dictionary with sponsors configuration
|
|
198
|
+
"""
|
|
199
|
+
result: Dict[str, Any] = {"sponsors": {}}
|
|
200
|
+
current_section = None
|
|
201
|
+
current_list_key = None
|
|
202
|
+
current_list: List[Dict] = []
|
|
203
|
+
current_item: Optional[Dict] = None
|
|
204
|
+
current_dict_key = None # For override files: sponsors: callisto: ...
|
|
205
|
+
|
|
206
|
+
lines = content.split("\n")
|
|
207
|
+
|
|
208
|
+
for line in lines:
|
|
209
|
+
stripped = line.strip()
|
|
210
|
+
if not stripped or stripped.startswith("#"):
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
indent = len(line) - len(line.lstrip())
|
|
214
|
+
|
|
215
|
+
# Top-level key (sponsors:)
|
|
216
|
+
if indent == 0 and stripped.endswith(":"):
|
|
217
|
+
current_section = stripped[:-1]
|
|
218
|
+
if current_section not in result:
|
|
219
|
+
result[current_section] = {}
|
|
220
|
+
current_list_key = None
|
|
221
|
+
current_dict_key = None
|
|
222
|
+
current_item = None
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# Second-level key (local:, or sponsor name for overrides)
|
|
226
|
+
if indent == 2 and ":" in stripped:
|
|
227
|
+
key, value = stripped.split(":", 1)
|
|
228
|
+
key = key.strip()
|
|
229
|
+
value = value.strip()
|
|
230
|
+
|
|
231
|
+
if not value:
|
|
232
|
+
# Could be list (local:) or dict (callisto:) - depends on following content
|
|
233
|
+
current_list_key = key
|
|
234
|
+
current_list = []
|
|
235
|
+
current_dict_key = key
|
|
236
|
+
# Initialize as empty dict for now, will be replaced with list if needed
|
|
237
|
+
if current_section:
|
|
238
|
+
result[current_section][key] = {}
|
|
239
|
+
else:
|
|
240
|
+
# Simple key-value at second level
|
|
241
|
+
if current_section:
|
|
242
|
+
if current_section not in result:
|
|
243
|
+
result[current_section] = {}
|
|
244
|
+
result[current_section][key] = _parse_yaml_value(value)
|
|
245
|
+
current_item = None
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# List item start (- name: value)
|
|
249
|
+
if stripped.startswith("- "):
|
|
250
|
+
item_content = stripped[2:].strip()
|
|
251
|
+
current_item = {}
|
|
252
|
+
|
|
253
|
+
# Convert dict to list if this is our first list item
|
|
254
|
+
if current_list_key and current_section:
|
|
255
|
+
if not isinstance(result[current_section].get(current_list_key), list):
|
|
256
|
+
result[current_section][current_list_key] = current_list
|
|
257
|
+
|
|
258
|
+
current_list.append(current_item)
|
|
259
|
+
|
|
260
|
+
if ":" in item_content:
|
|
261
|
+
key, value = item_content.split(":", 1)
|
|
262
|
+
current_item[key.strip()] = _parse_yaml_value(value.strip())
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# Item property (within a list item)
|
|
266
|
+
if indent >= 6 and ":" in stripped and current_item is not None:
|
|
267
|
+
key, value = stripped.split(":", 1)
|
|
268
|
+
current_item[key.strip()] = _parse_yaml_value(value.strip())
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Third-level key-value for override files (sponsors: callisto: local_path: ...)
|
|
272
|
+
if indent == 4 and ":" in stripped and current_section and current_dict_key:
|
|
273
|
+
key, value = stripped.split(":", 1)
|
|
274
|
+
key = key.strip()
|
|
275
|
+
value = value.strip()
|
|
276
|
+
|
|
277
|
+
if value:
|
|
278
|
+
# This is a property of the dict entry
|
|
279
|
+
if isinstance(result[current_section].get(current_dict_key), dict):
|
|
280
|
+
result[current_section][current_dict_key][key] = _parse_yaml_value(value)
|
|
281
|
+
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def load_sponsors_config(
|
|
286
|
+
config: Dict[str, Any],
|
|
287
|
+
base_path: Optional[Path] = None,
|
|
288
|
+
) -> SponsorsConfig:
|
|
289
|
+
"""
|
|
290
|
+
Load sponsor configurations from config files.
|
|
291
|
+
|
|
292
|
+
Reads the main sponsors config file and applies local overrides.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
config: Main elspais configuration dictionary
|
|
296
|
+
base_path: Base path to resolve relative paths (defaults to cwd)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
SponsorsConfig with loaded sponsors
|
|
300
|
+
"""
|
|
301
|
+
if base_path is None:
|
|
302
|
+
base_path = Path.cwd()
|
|
303
|
+
|
|
304
|
+
sponsors_config = SponsorsConfig()
|
|
305
|
+
|
|
306
|
+
# Get sponsors section from config
|
|
307
|
+
sponsors_section = config.get("sponsors", {})
|
|
308
|
+
config_file = sponsors_section.get("config_file", "")
|
|
309
|
+
sponsors_config.config_file = config_file
|
|
310
|
+
sponsors_config.local_dir = sponsors_section.get("local_dir", "sponsor")
|
|
311
|
+
|
|
312
|
+
if not config_file:
|
|
313
|
+
return sponsors_config
|
|
314
|
+
|
|
315
|
+
# Load main sponsors config
|
|
316
|
+
config_path = base_path / config_file
|
|
317
|
+
main_config = load_sponsors_yaml(config_path)
|
|
318
|
+
|
|
319
|
+
# Load local overrides if present
|
|
320
|
+
local_config_path = config_path.with_suffix(".local.yml")
|
|
321
|
+
local_overrides = load_sponsors_yaml(local_config_path)
|
|
322
|
+
|
|
323
|
+
# Parse sponsors from config
|
|
324
|
+
sponsors_data = main_config.get("sponsors", {})
|
|
325
|
+
|
|
326
|
+
# Handle "local" list format (the standard structure)
|
|
327
|
+
sponsor_list = []
|
|
328
|
+
if isinstance(sponsors_data, dict):
|
|
329
|
+
# Check for "local" key containing list
|
|
330
|
+
if "local" in sponsors_data and isinstance(sponsors_data["local"], list):
|
|
331
|
+
sponsor_list = sponsors_data["local"]
|
|
332
|
+
|
|
333
|
+
# Apply local overrides
|
|
334
|
+
local_sponsors = local_overrides.get("sponsors", {})
|
|
335
|
+
|
|
336
|
+
for sponsor_data in sponsor_list:
|
|
337
|
+
name = sponsor_data.get("name", "")
|
|
338
|
+
if not name:
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
# Check for local override
|
|
342
|
+
local_path = None
|
|
343
|
+
if name in local_sponsors:
|
|
344
|
+
local_override = local_sponsors[name]
|
|
345
|
+
if isinstance(local_override, dict):
|
|
346
|
+
local_path = local_override.get("local_path")
|
|
347
|
+
|
|
348
|
+
sponsor = Sponsor(
|
|
349
|
+
name=name,
|
|
350
|
+
code=sponsor_data.get("code", ""),
|
|
351
|
+
enabled=sponsor_data.get("enabled", True),
|
|
352
|
+
path=sponsor_data.get("path", ""),
|
|
353
|
+
spec_path=sponsor_data.get("spec_path", "spec"),
|
|
354
|
+
local_path=local_path,
|
|
355
|
+
)
|
|
356
|
+
sponsors_config.sponsors.append(sponsor)
|
|
357
|
+
|
|
358
|
+
return sponsors_config
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def resolve_sponsor_spec_dir(
|
|
362
|
+
sponsor: Sponsor,
|
|
363
|
+
config: SponsorsConfig,
|
|
364
|
+
base_path: Optional[Path] = None,
|
|
365
|
+
) -> Optional[Path]:
|
|
366
|
+
"""
|
|
367
|
+
Resolve the spec directory path for a sponsor.
|
|
368
|
+
|
|
369
|
+
Checks local_path override first, then default path.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
sponsor: Sponsor configuration
|
|
373
|
+
config: Overall sponsors configuration
|
|
374
|
+
base_path: Base path to resolve relative paths (defaults to cwd)
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Path to sponsor spec directory, or None if not found
|
|
378
|
+
"""
|
|
379
|
+
if base_path is None:
|
|
380
|
+
base_path = Path.cwd()
|
|
381
|
+
|
|
382
|
+
if not sponsor.enabled:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
# Check local_path override first
|
|
386
|
+
if sponsor.local_path:
|
|
387
|
+
spec_dir = Path(sponsor.local_path) / sponsor.spec_path
|
|
388
|
+
if not spec_dir.is_absolute():
|
|
389
|
+
spec_dir = base_path / spec_dir
|
|
390
|
+
if spec_dir.exists() and spec_dir.is_dir():
|
|
391
|
+
return spec_dir
|
|
392
|
+
|
|
393
|
+
# Fall back to default path
|
|
394
|
+
if sponsor.path:
|
|
395
|
+
spec_dir = base_path / sponsor.path / sponsor.spec_path
|
|
396
|
+
if spec_dir.exists() and spec_dir.is_dir():
|
|
397
|
+
return spec_dir
|
|
398
|
+
|
|
399
|
+
# Try local_dir / name / spec_path
|
|
400
|
+
spec_dir = base_path / config.local_dir / sponsor.name / sponsor.spec_path
|
|
401
|
+
if spec_dir.exists() and spec_dir.is_dir():
|
|
402
|
+
return spec_dir
|
|
403
|
+
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def get_sponsor_spec_directories(
|
|
408
|
+
config: Dict[str, Any],
|
|
409
|
+
base_path: Optional[Path] = None,
|
|
410
|
+
) -> List[Path]:
|
|
411
|
+
"""
|
|
412
|
+
Get all sponsor spec directories from configuration.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
config: Main elspais configuration dictionary
|
|
416
|
+
base_path: Base path to resolve relative paths (defaults to cwd)
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
List of existing sponsor spec directory paths
|
|
420
|
+
"""
|
|
421
|
+
if base_path is None:
|
|
422
|
+
base_path = Path.cwd()
|
|
423
|
+
|
|
424
|
+
sponsors_config = load_sponsors_config(config, base_path)
|
|
425
|
+
spec_dirs = []
|
|
426
|
+
|
|
427
|
+
for sponsor in sponsors_config.sponsors:
|
|
428
|
+
spec_dir = resolve_sponsor_spec_dir(sponsor, sponsors_config, base_path)
|
|
429
|
+
if spec_dir:
|
|
430
|
+
spec_dirs.append(spec_dir)
|
|
431
|
+
|
|
432
|
+
return spec_dirs
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Implements: REQ-int-d00001-A (trace_view package at src/elspais/trace_view/)
|
|
2
|
+
"""
|
|
3
|
+
elspais.trace_view - Interactive traceability matrix generation.
|
|
4
|
+
|
|
5
|
+
This package provides enhanced traceability features including:
|
|
6
|
+
- Interactive HTML generation with collapsible hierarchies
|
|
7
|
+
- Implementation file scanning
|
|
8
|
+
- Git state tracking (uncommitted, modified, moved files)
|
|
9
|
+
- Review system with comment threads and approval workflows
|
|
10
|
+
|
|
11
|
+
Optional dependencies:
|
|
12
|
+
- pip install elspais[trace-view] for HTML generation (requires jinja2)
|
|
13
|
+
- pip install elspais[trace-review] for review server (requires flask)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from elspais.trace_view.models import TraceViewRequirement, TestInfo, GitChangeInfo
|
|
17
|
+
from elspais.trace_view.generators.base import TraceViewGenerator
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"TraceViewRequirement",
|
|
21
|
+
"TestInfo",
|
|
22
|
+
"GitChangeInfo",
|
|
23
|
+
"TraceViewGenerator",
|
|
24
|
+
"generate_markdown",
|
|
25
|
+
"generate_csv",
|
|
26
|
+
"generate_html",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_markdown(requirements, **kwargs):
|
|
31
|
+
"""Generate Markdown traceability matrix."""
|
|
32
|
+
from elspais.trace_view.generators.markdown import generate_markdown as _gen
|
|
33
|
+
return _gen(requirements, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_csv(requirements, **kwargs):
|
|
37
|
+
"""Generate CSV traceability matrix."""
|
|
38
|
+
from elspais.trace_view.generators.csv import generate_csv as _gen
|
|
39
|
+
return _gen(requirements, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_html(requirements, **kwargs):
|
|
43
|
+
"""Generate interactive HTML traceability matrix.
|
|
44
|
+
|
|
45
|
+
Requires jinja2: pip install elspais[trace-view]
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
from elspais.trace_view.html import HTMLGenerator
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"HTML generation requires Jinja2. "
|
|
52
|
+
"Install with: pip install elspais[trace-view]"
|
|
53
|
+
) from e
|
|
54
|
+
return HTMLGenerator(requirements, **kwargs).generate()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
elspais.trace_view.coverage - Coverage calculation for trace-view.
|
|
3
|
+
|
|
4
|
+
Provides functions to calculate implementation coverage and status
|
|
5
|
+
for requirements.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, List, Union
|
|
9
|
+
|
|
10
|
+
from elspais.trace_view.models import TraceViewRequirement
|
|
11
|
+
|
|
12
|
+
# Type alias for requirement dict (supports both ID forms)
|
|
13
|
+
ReqDict = Dict[str, TraceViewRequirement]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def count_by_level(requirements: ReqDict) -> Dict[str, Dict[str, int]]:
|
|
17
|
+
"""Count requirements by level, both including and excluding Deprecated.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
requirements: Dict mapping requirement ID to TraceViewRequirement
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dict with 'active' (excludes Deprecated) and 'all' (includes Deprecated) counts
|
|
24
|
+
Each contains counts for 'PRD', 'OPS', 'DEV'
|
|
25
|
+
"""
|
|
26
|
+
counts = {
|
|
27
|
+
"active": {"PRD": 0, "OPS": 0, "DEV": 0},
|
|
28
|
+
"all": {"PRD": 0, "OPS": 0, "DEV": 0},
|
|
29
|
+
}
|
|
30
|
+
for req in requirements.values():
|
|
31
|
+
level = req.level
|
|
32
|
+
counts["all"][level] = counts["all"].get(level, 0) + 1
|
|
33
|
+
if req.status != "Deprecated":
|
|
34
|
+
counts["active"][level] = counts["active"].get(level, 0) + 1
|
|
35
|
+
return counts
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_orphaned_requirements(requirements: ReqDict) -> List[TraceViewRequirement]:
|
|
39
|
+
"""Find requirements not linked from any parent.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
requirements: Dict mapping requirement ID to TraceViewRequirement
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of orphaned requirements (non-PRD requirements with no implements)
|
|
46
|
+
"""
|
|
47
|
+
implemented = set()
|
|
48
|
+
for req in requirements.values():
|
|
49
|
+
implemented.update(req.implements)
|
|
50
|
+
|
|
51
|
+
orphaned = []
|
|
52
|
+
for req in requirements.values():
|
|
53
|
+
# Skip PRD requirements (they're top-level)
|
|
54
|
+
if req.level == "PRD":
|
|
55
|
+
continue
|
|
56
|
+
# Skip if this requirement is implemented by someone
|
|
57
|
+
if req.id in implemented:
|
|
58
|
+
continue
|
|
59
|
+
# Skip if it has no parent (should have one)
|
|
60
|
+
if not req.implements:
|
|
61
|
+
orphaned.append(req)
|
|
62
|
+
|
|
63
|
+
return sorted(orphaned, key=lambda r: r.id)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def calculate_coverage(requirements: ReqDict, req_id: str) -> dict:
|
|
67
|
+
"""Calculate coverage for a requirement.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
requirements: Dict mapping requirement ID to TraceViewRequirement
|
|
71
|
+
req_id: ID of requirement to calculate coverage for
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict with 'children' (total child count) and 'traced' (children with implementation)
|
|
75
|
+
"""
|
|
76
|
+
# Find all requirements that implement this requirement (children)
|
|
77
|
+
children = [r for r in requirements.values() if req_id in r.implements]
|
|
78
|
+
|
|
79
|
+
# Count how many children have implementation files or their own children with implementation
|
|
80
|
+
traced = 0
|
|
81
|
+
for child in children:
|
|
82
|
+
child_status = get_implementation_status(requirements, child.id)
|
|
83
|
+
if child_status in ["Full", "Partial"]:
|
|
84
|
+
traced += 1
|
|
85
|
+
|
|
86
|
+
return {"children": len(children), "traced": traced}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_implementation_status(requirements: ReqDict, req_id: str) -> str:
|
|
90
|
+
"""Get implementation status for a requirement.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
requirements: Dict mapping requirement ID to TraceViewRequirement
|
|
94
|
+
req_id: ID of requirement to check
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
'Unimplemented': No children AND no implementation_files
|
|
98
|
+
'Partial': Some but not all children traced
|
|
99
|
+
'Full': Has implementation_files OR all children traced
|
|
100
|
+
"""
|
|
101
|
+
req = requirements.get(req_id)
|
|
102
|
+
if not req:
|
|
103
|
+
return "Unimplemented"
|
|
104
|
+
|
|
105
|
+
# If requirement has implementation files, it's fully implemented
|
|
106
|
+
if req.implementation_files:
|
|
107
|
+
return "Full"
|
|
108
|
+
|
|
109
|
+
# Find children
|
|
110
|
+
children = [r for r in requirements.values() if req_id in r.implements]
|
|
111
|
+
|
|
112
|
+
# No children and no implementation files = Unimplemented
|
|
113
|
+
if not children:
|
|
114
|
+
return "Unimplemented"
|
|
115
|
+
|
|
116
|
+
# Check how many children are traced
|
|
117
|
+
coverage = calculate_coverage(requirements, req_id)
|
|
118
|
+
|
|
119
|
+
if coverage["traced"] == 0:
|
|
120
|
+
return "Unimplemented"
|
|
121
|
+
elif coverage["traced"] == coverage["children"]:
|
|
122
|
+
return "Full"
|
|
123
|
+
else:
|
|
124
|
+
return "Partial"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def generate_coverage_report(
|
|
128
|
+
requirements: ReqDict, get_status_fn=None
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Generate text-based coverage report with summary statistics.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
requirements: Dict mapping requirement ID to TraceViewRequirement
|
|
134
|
+
get_status_fn: Optional function to get implementation status.
|
|
135
|
+
If None, uses get_implementation_status.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Formatted text report showing:
|
|
139
|
+
- Total requirements count
|
|
140
|
+
- Breakdown by level (PRD, OPS, DEV) with percentages
|
|
141
|
+
- Breakdown by implementation status (Full/Partial/Unimplemented)
|
|
142
|
+
"""
|
|
143
|
+
if get_status_fn is None:
|
|
144
|
+
get_status_fn = lambda req_id: get_implementation_status(requirements, req_id)
|
|
145
|
+
|
|
146
|
+
lines = []
|
|
147
|
+
lines.append("=== Coverage Report ===")
|
|
148
|
+
lines.append(f"Total Requirements: {len(requirements)}")
|
|
149
|
+
lines.append("")
|
|
150
|
+
|
|
151
|
+
# Count by level
|
|
152
|
+
by_level = {"PRD": 0, "OPS": 0, "DEV": 0}
|
|
153
|
+
implemented_by_level = {"PRD": 0, "OPS": 0, "DEV": 0}
|
|
154
|
+
|
|
155
|
+
for req in requirements.values():
|
|
156
|
+
level = req.level
|
|
157
|
+
by_level[level] = by_level.get(level, 0) + 1
|
|
158
|
+
|
|
159
|
+
impl_status = get_status_fn(req.id)
|
|
160
|
+
if impl_status in ["Full", "Partial"]:
|
|
161
|
+
implemented_by_level[level] = implemented_by_level.get(level, 0) + 1
|
|
162
|
+
|
|
163
|
+
lines.append("By Level:")
|
|
164
|
+
for level in ["PRD", "OPS", "DEV"]:
|
|
165
|
+
total = by_level[level]
|
|
166
|
+
implemented = implemented_by_level[level]
|
|
167
|
+
percentage = (implemented / total * 100) if total > 0 else 0
|
|
168
|
+
lines.append(f" {level}: {total} ({percentage:.0f}% implemented)")
|
|
169
|
+
|
|
170
|
+
lines.append("")
|
|
171
|
+
|
|
172
|
+
# Count by implementation status
|
|
173
|
+
status_counts = {"Full": 0, "Partial": 0, "Unimplemented": 0}
|
|
174
|
+
for req in requirements.values():
|
|
175
|
+
impl_status = get_status_fn(req.id)
|
|
176
|
+
status_counts[impl_status] = status_counts.get(impl_status, 0) + 1
|
|
177
|
+
|
|
178
|
+
lines.append("By Status:")
|
|
179
|
+
lines.append(f" Full: {status_counts['Full']}")
|
|
180
|
+
lines.append(f" Partial: {status_counts['Partial']}")
|
|
181
|
+
lines.append(f" Unimplemented: {status_counts['Unimplemented']}")
|
|
182
|
+
|
|
183
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Implements: REQ-int-d00001-A (trace_view package structure)
|
|
2
|
+
"""
|
|
3
|
+
elspais.trace_view.generators - Output format generators.
|
|
4
|
+
|
|
5
|
+
Provides Markdown and CSV generators (no dependencies).
|
|
6
|
+
HTML generator is in the html/ subpackage (requires jinja2).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from elspais.trace_view.generators.markdown import generate_markdown
|
|
10
|
+
from elspais.trace_view.generators.csv import generate_csv
|
|
11
|
+
|
|
12
|
+
__all__ = ["generate_markdown", "generate_csv"]
|