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.
Files changed (73) hide show
  1. elspais/cli.py +141 -10
  2. elspais/commands/hash_cmd.py +72 -26
  3. elspais/commands/reformat_cmd.py +458 -0
  4. elspais/commands/trace.py +157 -3
  5. elspais/commands/validate.py +44 -16
  6. elspais/core/models.py +2 -0
  7. elspais/core/parser.py +68 -24
  8. elspais/reformat/__init__.py +50 -0
  9. elspais/reformat/detector.py +119 -0
  10. elspais/reformat/hierarchy.py +246 -0
  11. elspais/reformat/line_breaks.py +220 -0
  12. elspais/reformat/prompts.py +123 -0
  13. elspais/reformat/transformer.py +264 -0
  14. elspais/sponsors/__init__.py +432 -0
  15. elspais/trace_view/__init__.py +54 -0
  16. elspais/trace_view/coverage.py +183 -0
  17. elspais/trace_view/generators/__init__.py +12 -0
  18. elspais/trace_view/generators/base.py +329 -0
  19. elspais/trace_view/generators/csv.py +122 -0
  20. elspais/trace_view/generators/markdown.py +175 -0
  21. elspais/trace_view/html/__init__.py +31 -0
  22. elspais/trace_view/html/generator.py +1006 -0
  23. elspais/trace_view/html/templates/base.html +283 -0
  24. elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
  25. elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
  26. elspais/trace_view/html/templates/components/legend_modal.html +69 -0
  27. elspais/trace_view/html/templates/components/review_panel.html +118 -0
  28. elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
  29. elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
  30. elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
  31. elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
  32. elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
  33. elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
  34. elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
  35. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
  36. elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
  37. elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
  38. elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
  39. elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
  40. elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
  41. elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
  42. elspais/trace_view/html/templates/partials/scripts.js +1741 -0
  43. elspais/trace_view/html/templates/partials/styles.css +1756 -0
  44. elspais/trace_view/models.py +353 -0
  45. elspais/trace_view/review/__init__.py +60 -0
  46. elspais/trace_view/review/branches.py +1149 -0
  47. elspais/trace_view/review/models.py +1205 -0
  48. elspais/trace_view/review/position.py +609 -0
  49. elspais/trace_view/review/server.py +1056 -0
  50. elspais/trace_view/review/status.py +470 -0
  51. elspais/trace_view/review/storage.py +1367 -0
  52. elspais/trace_view/scanning.py +213 -0
  53. elspais/trace_view/specs/README.md +84 -0
  54. elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
  55. elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
  56. elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
  57. elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
  58. elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
  59. elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
  60. elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
  61. elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
  62. elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
  63. elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
  64. elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
  65. elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
  66. elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
  67. elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
  68. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/METADATA +36 -18
  69. elspais-0.11.1.dist-info/RECORD +101 -0
  70. elspais-0.9.3.dist-info/RECORD +0 -40
  71. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/WHEEL +0 -0
  72. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/entry_points.txt +0 -0
  73. {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"]