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.
Files changed (148) hide show
  1. elspais/__init__.py +2 -11
  2. elspais/{sponsors/__init__.py → associates.py} +102 -58
  3. elspais/cli.py +395 -79
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +121 -173
  6. elspais/commands/changed.py +15 -30
  7. elspais/commands/config_cmd.py +13 -16
  8. elspais/commands/edit.py +60 -44
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +167 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -114
  13. elspais/commands/init.py +103 -26
  14. elspais/commands/reformat_cmd.py +41 -444
  15. elspais/commands/rules_cmd.py +7 -3
  16. elspais/commands/trace.py +444 -321
  17. elspais/commands/validate.py +195 -415
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -3
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +47 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +2016 -247
  61. elspais/testing/__init__.py +4 -4
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/result_parser.py +25 -21
  65. elspais/testing/scanner.py +301 -12
  66. elspais/utilities/__init__.py +1 -0
  67. elspais/utilities/docs_loader.py +115 -0
  68. elspais/utilities/git.py +607 -0
  69. elspais/{core → utilities}/hasher.py +8 -22
  70. elspais/utilities/md_renderer.py +189 -0
  71. elspais/{core → utilities}/patterns.py +58 -57
  72. elspais/utilities/reference_config.py +626 -0
  73. elspais/validation/__init__.py +19 -0
  74. elspais/validation/format.py +264 -0
  75. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  76. elspais-0.43.5.dist-info/RECORD +80 -0
  77. elspais/config/defaults.py +0 -173
  78. elspais/config/loader.py +0 -494
  79. elspais/core/__init__.py +0 -21
  80. elspais/core/git.py +0 -352
  81. elspais/core/models.py +0 -320
  82. elspais/core/parser.py +0 -640
  83. elspais/core/rules.py +0 -514
  84. elspais/mcp/context.py +0 -171
  85. elspais/mcp/serializers.py +0 -112
  86. elspais/reformat/__init__.py +0 -50
  87. elspais/reformat/detector.py +0 -119
  88. elspais/reformat/hierarchy.py +0 -246
  89. elspais/reformat/line_breaks.py +0 -220
  90. elspais/reformat/prompts.py +0 -123
  91. elspais/reformat/transformer.py +0 -264
  92. elspais/trace_view/__init__.py +0 -54
  93. elspais/trace_view/coverage.py +0 -183
  94. elspais/trace_view/generators/__init__.py +0 -12
  95. elspais/trace_view/generators/base.py +0 -329
  96. elspais/trace_view/generators/csv.py +0 -122
  97. elspais/trace_view/generators/markdown.py +0 -175
  98. elspais/trace_view/html/__init__.py +0 -31
  99. elspais/trace_view/html/generator.py +0 -1006
  100. elspais/trace_view/html/templates/base.html +0 -283
  101. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  102. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  103. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  104. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  105. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  106. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  107. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  108. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  109. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  110. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  111. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  112. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  113. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  114. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  115. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  116. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  117. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  118. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  119. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  120. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  121. elspais/trace_view/models.py +0 -353
  122. elspais/trace_view/review/__init__.py +0 -60
  123. elspais/trace_view/review/branches.py +0 -1149
  124. elspais/trace_view/review/models.py +0 -1205
  125. elspais/trace_view/review/position.py +0 -609
  126. elspais/trace_view/review/server.py +0 -1056
  127. elspais/trace_view/review/status.py +0 -470
  128. elspais/trace_view/review/storage.py +0 -1367
  129. elspais/trace_view/scanning.py +0 -213
  130. elspais/trace_view/specs/README.md +0 -84
  131. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  132. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  133. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  134. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  135. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  136. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  137. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  138. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  139. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  140. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  141. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  142. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  143. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  144. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  145. elspais-0.11.1.dist-info/RECORD +0 -101
  146. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  147. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  148. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,731 @@
1
+ """HTML Generator for traceability reports.
2
+
3
+ This module generates interactive HTML traceability views from TraceGraph.
4
+ Uses Jinja2 templates for rich interactive output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from elspais import __version__
14
+
15
+ if TYPE_CHECKING:
16
+ from elspais.graph.builder import TraceGraph
17
+ from elspais.graph.GraphNode import GraphNode
18
+
19
+
20
+ @dataclass
21
+ class TreeRow:
22
+ """Represents a single row in the tree view."""
23
+
24
+ id: str
25
+ display_id: str
26
+ title: str
27
+ level: str
28
+ status: str
29
+ coverage: str # "none", "partial", "full"
30
+ topic: str
31
+ depth: int
32
+ parent_id: str | None
33
+ assertions: list[str] # Assertion letters like ["A", "B"]
34
+ is_leaf: bool
35
+ is_changed: bool
36
+ is_uncommitted: bool
37
+ is_roadmap: bool
38
+ is_code: bool
39
+ is_test: bool # TEST node for traceability
40
+ is_test_result: bool # TEST_RESULT node (test execution result)
41
+ has_children: bool
42
+ has_failures: bool
43
+ is_associated: bool # From sponsor/associated repository
44
+ source_file: str = "" # Relative path to source file
45
+ source_line: int = 0 # Line number in source file
46
+ result_status: str = "" # For TEST_RESULT: passed/failed/error/skipped
47
+
48
+
49
+ @dataclass
50
+ class JourneyItem:
51
+ """Represents a user journey for display."""
52
+
53
+ id: str
54
+ title: str
55
+ description: str
56
+ actor: str | None = None
57
+ goal: str | None = None
58
+ descriptor: str = "" # Extracted from ID: JNY-{descriptor}-{number}
59
+ file: str = "" # Source file path
60
+
61
+
62
+ @dataclass
63
+ class ViewStats:
64
+ """Statistics for the header display."""
65
+
66
+ prd_count: int = 0
67
+ ops_count: int = 0
68
+ dev_count: int = 0
69
+ total_count: int = 0
70
+ code_count: int = 0 # Number of unique CODE nodes in the graph
71
+ test_count: int = 0 # Number of unique TEST nodes in the graph
72
+ test_result_count: int = 0 # Number of TEST_RESULT nodes
73
+ test_passed_count: int = 0 # Number of passed TEST_RESULT nodes
74
+ test_failed_count: int = 0 # Number of failed TEST_RESULT nodes
75
+ associated_count: int = 0
76
+ journey_count: int = 0
77
+ # Assertion-level metrics
78
+ assertion_count: int = 0 # Total unique assertions
79
+ assertions_implemented: int = 0 # Assertions with CODE coverage
80
+ assertions_tested: int = 0 # Assertions with TEST coverage
81
+ assertions_validated: int = 0 # Assertions with passing TEST_RESULTs
82
+
83
+
84
+ class HTMLGenerator:
85
+ """Generates interactive HTML traceability view from TraceGraph.
86
+
87
+ Uses Jinja2 templates to render a rich, interactive tree view with:
88
+ - Hierarchical expand/collapse
89
+ - Multiple view modes (flat/hierarchical)
90
+ - Git change detection
91
+ - Coverage indicators
92
+ - Filtering and search
93
+
94
+ Args:
95
+ graph: The TraceGraph containing all requirement data.
96
+ version: Version string for display (defaults to elspais package version).
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ graph: TraceGraph,
102
+ base_path: str = "",
103
+ version: str | None = None,
104
+ ) -> None:
105
+ self.graph = graph
106
+ self.base_path = base_path
107
+ self.version = version if version is not None else __version__
108
+
109
+ def generate(self, embed_content: bool = False) -> str:
110
+ """Generate the complete HTML report.
111
+
112
+ Args:
113
+ embed_content: If True, embed full requirement content as JSON.
114
+
115
+ Returns:
116
+ Complete HTML document as string.
117
+ """
118
+ try:
119
+ from jinja2 import Environment, PackageLoader, select_autoescape
120
+
121
+ env = Environment(
122
+ loader=PackageLoader("elspais.html", "templates"),
123
+ autoescape=select_autoescape(["html", "xml"]),
124
+ )
125
+ template = env.get_template("trace_view.html.j2")
126
+ except ImportError as err:
127
+ raise ImportError(
128
+ "HTMLGenerator requires the trace-view extra. "
129
+ "Install with: pip install elspais[trace-view]"
130
+ ) from err
131
+
132
+ # Apply git annotations to all nodes
133
+ self._annotate_git_state()
134
+
135
+ # Compute coverage metrics for all requirements
136
+ self._annotate_coverage()
137
+
138
+ # Build data structures
139
+ stats = self._compute_stats()
140
+ rows = self._build_tree_rows()
141
+ journeys = self._collect_journeys()
142
+ statuses = self._collect_unique_values("status")
143
+ topics = self._collect_unique_values("topic")
144
+ tree_data = self._build_tree_data() if embed_content else {}
145
+
146
+ # Update journey count in stats
147
+ stats.journey_count = len(journeys)
148
+
149
+ # Render template
150
+ html_content = template.render(
151
+ stats=stats,
152
+ rows=rows,
153
+ journeys=journeys,
154
+ statuses=sorted(statuses),
155
+ topics=sorted(topics),
156
+ tree_data=tree_data,
157
+ version=self.version,
158
+ base_path=self.base_path,
159
+ )
160
+
161
+ return html_content
162
+
163
+ def _annotate_git_state(self) -> None:
164
+ """Apply git state annotations to all requirement nodes.
165
+
166
+ Detects uncommitted changes and changes vs main branch for filtering.
167
+ """
168
+ from elspais.graph import NodeKind
169
+ from elspais.graph.annotators import annotate_display_info, annotate_git_state
170
+
171
+ # Try to get git info
172
+ git_info = None
173
+ try:
174
+ from elspais.utilities.git import (
175
+ GitChangeInfo,
176
+ _extract_req_locations_from_graph,
177
+ get_changed_vs_branch,
178
+ get_modified_files,
179
+ temporary_worktree,
180
+ )
181
+
182
+ repo_root = self.graph.repo_root
183
+ if repo_root:
184
+ modified, untracked = get_modified_files(repo_root)
185
+ branch_changed = get_changed_vs_branch(repo_root)
186
+
187
+ # Get committed locations using graph-based approach via git worktree
188
+ committed_locs: dict[str, str] = {}
189
+ try:
190
+ with temporary_worktree(repo_root, "HEAD") as worktree_path:
191
+ from elspais.graph.factory import build_graph
192
+
193
+ committed_graph = build_graph(
194
+ repo_root=worktree_path,
195
+ scan_code=False,
196
+ scan_tests=False,
197
+ scan_sponsors=False,
198
+ )
199
+ committed_locs = _extract_req_locations_from_graph(
200
+ committed_graph, worktree_path
201
+ )
202
+ except Exception:
203
+ # Worktree creation failed, continue with empty committed_locs
204
+ pass
205
+
206
+ git_info = GitChangeInfo(
207
+ modified_files=set(modified),
208
+ untracked_files=set(untracked),
209
+ branch_changed_files=set(branch_changed),
210
+ committed_req_locations=committed_locs,
211
+ )
212
+ except Exception:
213
+ # Git not available or error - continue without git info
214
+ pass
215
+
216
+ # Annotate all requirement nodes
217
+ for node in self.graph.all_nodes():
218
+ if node.kind == NodeKind.REQUIREMENT:
219
+ annotate_git_state(node, git_info)
220
+ annotate_display_info(node)
221
+
222
+ def _annotate_coverage(self) -> None:
223
+ """Compute coverage metrics for all requirement nodes.
224
+
225
+ Uses the centralized annotate_coverage() function to compute
226
+ RollupMetrics for each requirement, which are then stored in
227
+ node._metrics for use by stats computation and row building.
228
+ """
229
+ from elspais.graph.annotators import annotate_coverage
230
+
231
+ annotate_coverage(self.graph)
232
+
233
+ def _is_associated(self, node: GraphNode) -> bool:
234
+ """Check if a node is from an associated/sponsor repository.
235
+
236
+ Associated requirements come from sponsor repos, identified by:
237
+ - ID containing associated prefix (e.g., REQ-CAL-p00001)
238
+ - Path containing 'sponsor' or 'associated'
239
+ - Path outside the base_path (different repo)
240
+ - Or marked with an associated field
241
+ """
242
+ # Check if ID has associated prefix pattern (e.g., REQ-CAL-p00001)
243
+ # Associated IDs have format: PREFIX-ASSOC-type where ASSOC is 2-4 uppercase letters
244
+ import re
245
+
246
+ if re.match(r"^REQ-[A-Z]{2,4}-[a-z]", node.id):
247
+ return True
248
+
249
+ if not node.source:
250
+ return False
251
+
252
+ path = node.source.path.lower()
253
+ # Check for common associated repo patterns
254
+ if "sponsor" in path or "associated" in path:
255
+ return True
256
+
257
+ # Check if path is outside base_path (different repo)
258
+ if self.base_path:
259
+ try:
260
+ # If the source path doesn't start with base_path, it's from a different repo
261
+ source_path = Path(node.source.path).resolve()
262
+ base = Path(self.base_path).resolve()
263
+ if not str(source_path).startswith(str(base)):
264
+ return True
265
+ except (ValueError, OSError):
266
+ pass
267
+
268
+ # Check if node has associated field set
269
+ if node.get_field("associated", False):
270
+ return True
271
+
272
+ return False
273
+
274
+ def _compute_stats(self) -> ViewStats:
275
+ """Compute statistics for the header.
276
+
277
+ Uses pre-computed RollupMetrics from annotate_coverage() for all
278
+ assertion-level coverage stats. No ad-hoc calculation.
279
+ """
280
+ from elspais.graph import NodeKind
281
+ from elspais.graph.metrics import RollupMetrics
282
+
283
+ stats = ViewStats()
284
+
285
+ for node in self.graph.nodes_by_kind(NodeKind.REQUIREMENT):
286
+ stats.total_count += 1
287
+
288
+ level = (node.level or "").upper()
289
+ if level == "PRD":
290
+ stats.prd_count += 1
291
+ elif level == "OPS":
292
+ stats.ops_count += 1
293
+ elif level == "DEV":
294
+ stats.dev_count += 1
295
+
296
+ # Count associated requirements
297
+ if self._is_associated(node):
298
+ stats.associated_count += 1
299
+
300
+ # Aggregate all assertion metrics from pre-computed RollupMetrics
301
+ rollup: RollupMetrics | None = node.get_metric("rollup_metrics")
302
+ if rollup:
303
+ stats.assertion_count += rollup.total_assertions
304
+ stats.assertions_implemented += rollup.covered_assertions
305
+ stats.assertions_tested += rollup.direct_tested
306
+ stats.assertions_validated += rollup.validated
307
+
308
+ # Count CODE nodes
309
+ for _ in self.graph.nodes_by_kind(NodeKind.CODE):
310
+ stats.code_count += 1
311
+
312
+ # Count TEST nodes
313
+ for _ in self.graph.nodes_by_kind(NodeKind.TEST):
314
+ stats.test_count += 1
315
+
316
+ # Count TEST_RESULT nodes
317
+ for node in self.graph.nodes_by_kind(NodeKind.TEST_RESULT):
318
+ stats.test_result_count += 1
319
+ status = (node.get_field("status", "") or "").lower()
320
+ if status in ("passed", "pass", "success"):
321
+ stats.test_passed_count += 1
322
+ elif status in ("failed", "fail", "failure", "error"):
323
+ stats.test_failed_count += 1
324
+
325
+ return stats
326
+
327
+ def _build_tree_rows(self) -> list[TreeRow]:
328
+ """Build flat list of rows representing the hierarchical tree.
329
+
330
+ Nodes can appear multiple times if they have multiple parents.
331
+ Uses DFS traversal to maintain parent-child ordering.
332
+ """
333
+ from elspais.graph import NodeKind
334
+
335
+ rows: list[TreeRow] = []
336
+ visited_at_depth: dict[tuple[str, int, str | None], bool] = {}
337
+
338
+ def get_topic(node: GraphNode) -> str:
339
+ """Extract topic from file path."""
340
+ if not node.source:
341
+ return ""
342
+ path = node.source.path
343
+ # Extract filename without extension
344
+ # e.g., "spec/prd-system.md" -> "prd-system" -> "system"
345
+ filename = Path(path).stem
346
+ # Remove level prefix if present
347
+ for prefix in ("prd-", "ops-", "dev-"):
348
+ if filename.lower().startswith(prefix):
349
+ return filename[len(prefix) :]
350
+ return filename
351
+
352
+ def is_roadmap(node: GraphNode) -> bool:
353
+ """Check if node is from a roadmap file."""
354
+ if not node.source:
355
+ return False
356
+ return "roadmap" in node.source.path.lower()
357
+
358
+ def compute_coverage(node: GraphNode) -> tuple[str, bool]:
359
+ """Get coverage status and failure flag from pre-computed metrics.
360
+
361
+ Uses RollupMetrics computed by annotate_coverage().
362
+
363
+ Returns:
364
+ Tuple of (coverage_status, has_failures)
365
+ coverage_status: "none", "partial", or "full"
366
+ """
367
+ from elspais.graph.metrics import RollupMetrics
368
+
369
+ rollup: RollupMetrics | None = node.get_metric("rollup_metrics")
370
+
371
+ if not rollup or rollup.total_assertions == 0:
372
+ # No assertions - check if any code references the req directly
373
+ has_code = False
374
+ for child in node.iter_children():
375
+ if child.kind == NodeKind.CODE:
376
+ has_code = True
377
+ break
378
+ return ("full" if has_code else "none", False)
379
+
380
+ # Use pre-computed coverage percentage
381
+ if rollup.coverage_pct == 0:
382
+ return ("none", rollup.has_failures)
383
+ elif rollup.coverage_pct < 100:
384
+ return ("partial", rollup.has_failures)
385
+ else:
386
+ return ("full", rollup.has_failures)
387
+
388
+ def get_assertion_letters(node: GraphNode, parent_id: str | None) -> list[str]:
389
+ """Get assertion letters that this node implements from a specific parent."""
390
+ if not parent_id:
391
+ return []
392
+
393
+ letters: list[str] = []
394
+ for edge in node.iter_incoming_edges():
395
+ if edge.source.id == parent_id and edge.assertion_targets:
396
+ letters.extend(edge.assertion_targets)
397
+ return sorted(set(letters))
398
+
399
+ def has_req_children(node: GraphNode) -> bool:
400
+ """Check if node has requirement children (for tree expand/collapse)."""
401
+ for child in node.iter_children():
402
+ if child.kind == NodeKind.REQUIREMENT:
403
+ return True
404
+ return False
405
+
406
+ def has_code_children(node: GraphNode) -> bool:
407
+ """Check if node has code children."""
408
+ for child in node.iter_children():
409
+ if child.kind == NodeKind.CODE:
410
+ return True
411
+ return False
412
+
413
+ def has_test_children(node: GraphNode) -> bool:
414
+ """Check if node has test children."""
415
+ for child in node.iter_children():
416
+ if child.kind == NodeKind.TEST:
417
+ return True
418
+ return False
419
+
420
+ def has_test_result_children(node: GraphNode) -> bool:
421
+ """Check if node has test result children."""
422
+ for child in node.iter_children():
423
+ if child.kind == NodeKind.TEST_RESULT:
424
+ return True
425
+ return False
426
+
427
+ def traverse(
428
+ node: GraphNode,
429
+ depth: int,
430
+ parent_id: str | None,
431
+ parent_assertions: list[str] | None = None,
432
+ ) -> None:
433
+ """DFS traversal to build rows."""
434
+ # Avoid infinite loops - track by (id, depth, parent)
435
+ key = (node.id, depth, parent_id)
436
+ if key in visited_at_depth:
437
+ return
438
+ visited_at_depth[key] = True
439
+
440
+ # Process requirements, code, test, and test_result nodes
441
+ if node.kind not in (
442
+ NodeKind.REQUIREMENT,
443
+ NodeKind.CODE,
444
+ NodeKind.TEST,
445
+ NodeKind.TEST_RESULT,
446
+ ):
447
+ return
448
+
449
+ is_code = node.kind == NodeKind.CODE
450
+ is_test = node.kind == NodeKind.TEST
451
+ is_test_result = node.kind == NodeKind.TEST_RESULT
452
+ is_impl_node = is_code or is_test or is_test_result # Implementation/evidence nodes
453
+ coverage, has_failures = ("none", False) if is_impl_node else compute_coverage(node)
454
+ assertion_letters = (
455
+ get_assertion_letters(node, parent_id)
456
+ if parent_assertions is None
457
+ else parent_assertions
458
+ )
459
+
460
+ # Get source location
461
+ source_file = node.source.path if node.source else ""
462
+ source_line = node.source.line if node.source else 0
463
+
464
+ # Get result status for TEST_RESULT nodes
465
+ result_status = ""
466
+ if is_test_result:
467
+ result_status = (node.get_field("status", "") or "").lower()
468
+
469
+ # Determine has_children based on node kind
470
+ if is_test:
471
+ # TEST nodes can have TEST_RESULT children
472
+ node_has_children = has_test_result_children(node)
473
+ elif is_test_result:
474
+ # TEST_RESULT nodes don't have children
475
+ node_has_children = False
476
+ else:
477
+ # REQ and CODE nodes
478
+ node_has_children = (
479
+ has_req_children(node) or has_code_children(node) or has_test_children(node)
480
+ )
481
+
482
+ # Create row
483
+ row = TreeRow(
484
+ id=f"{node.id}_{depth}_{parent_id or 'root'}", # Unique key for multi-parent
485
+ display_id=node.id,
486
+ title=node.get_label() or "",
487
+ level=(node.level or "").upper() if not is_impl_node else "",
488
+ status=(node.status or "").upper() if not is_impl_node else "",
489
+ coverage=coverage,
490
+ topic=get_topic(node) if not is_impl_node else "",
491
+ depth=depth,
492
+ parent_id=(
493
+ f"{parent_id}_{depth - 1}_"
494
+ f"{rows[-1].parent_id if rows and depth > 0 else 'root'}"
495
+ if parent_id and depth > 0
496
+ else None
497
+ ),
498
+ assertions=assertion_letters,
499
+ is_leaf=not has_req_children(node) and not is_impl_node,
500
+ is_changed=node.get_metric("is_branch_changed", False),
501
+ is_uncommitted=node.get_metric("is_uncommitted", False)
502
+ or node.get_metric("is_untracked", False),
503
+ is_roadmap=is_roadmap(node),
504
+ is_code=is_code,
505
+ is_test=is_test,
506
+ is_test_result=is_test_result,
507
+ has_children=node_has_children,
508
+ has_failures=has_failures,
509
+ is_associated=self._is_associated(node) if not is_impl_node else False,
510
+ source_file=source_file,
511
+ source_line=source_line,
512
+ result_status=result_status,
513
+ )
514
+
515
+ # Fix parent_id to reference actual row id
516
+ if parent_id and depth > 0:
517
+ # Find the parent row we just added
518
+ for prev_row in reversed(rows):
519
+ if prev_row.display_id == parent_id and prev_row.depth == depth - 1:
520
+ row.parent_id = prev_row.id
521
+ break
522
+
523
+ rows.append(row)
524
+
525
+ # Traverse children - requirements first, then code/tests
526
+ # First, aggregate all assertion targets per child to avoid duplicates
527
+ child_assertions: dict[str, tuple[GraphNode, set[str]]] = {}
528
+ children_without_assertions: list[GraphNode] = []
529
+
530
+ for edge in node.iter_outgoing_edges():
531
+ child = edge.target
532
+ if child.kind == NodeKind.REQUIREMENT:
533
+ if edge.assertion_targets:
534
+ # Aggregate assertion targets for this child
535
+ if child.id not in child_assertions:
536
+ child_assertions[child.id] = (child, set())
537
+ child_assertions[child.id][1].update(edge.assertion_targets)
538
+ else:
539
+ # Track children without assertion-specific edges
540
+ if child.id not in child_assertions:
541
+ children_without_assertions.append(child)
542
+
543
+ # Build children_to_visit list: assertion-specific children first
544
+ children_to_visit: list[tuple[GraphNode, list[str] | None]] = []
545
+ for _child_id, (child, assertions) in child_assertions.items():
546
+ # Convert set to sorted list
547
+ children_to_visit.append((child, sorted(assertions)))
548
+
549
+ # Add children without assertion targets
550
+ # (only if they don't have assertion-specific edges)
551
+ for child in children_without_assertions:
552
+ if child.id not in child_assertions:
553
+ children_to_visit.append((child, None))
554
+
555
+ # Add code, test, and test_result children
556
+ for child in node.iter_children():
557
+ if child.kind == NodeKind.CODE:
558
+ children_to_visit.append((child, None))
559
+ elif child.kind == NodeKind.TEST:
560
+ children_to_visit.append((child, None))
561
+ elif child.kind == NodeKind.TEST_RESULT:
562
+ # TEST_RESULT children of TEST nodes
563
+ children_to_visit.append((child, None))
564
+
565
+ # Sort children: assertion-specific first (by letter), then general (by ID)
566
+ # Key: (has_assertions=False sorts before True, assertion_letters, node_id)
567
+ def sort_key(item: tuple[GraphNode, list[str] | None]) -> tuple:
568
+ child, assertions = item
569
+ if assertions:
570
+ # Has assertion targets: sort by letters first (A, B, C...)
571
+ return (0, sorted(assertions), child.id)
572
+ else:
573
+ # No assertion targets: sort after assertion-specific children
574
+ return (1, [], child.id)
575
+
576
+ children_to_visit.sort(key=sort_key)
577
+
578
+ for child, assertions in children_to_visit:
579
+ traverse(child, depth + 1, node.id, assertions)
580
+
581
+ # Start traversal from roots
582
+ for root in self.graph.iter_roots():
583
+ if root.kind == NodeKind.REQUIREMENT:
584
+ traverse(root, 0, None)
585
+
586
+ # Add orphan TEST_RESULT nodes (those without TEST parents)
587
+ # These appear as root-level items in the tree
588
+ for node in self.graph.nodes_by_kind(NodeKind.TEST_RESULT):
589
+ # Skip if already visited (has a TEST parent)
590
+ if node.parent_count() > 0:
591
+ continue
592
+
593
+ source_file = node.source.path if node.source else ""
594
+ source_line = node.source.line if node.source else 0
595
+ result_status = (node.get_field("status", "") or "").lower()
596
+
597
+ # Create a short display ID from test name
598
+ test_name = node.get_field("name", "") or ""
599
+ classname = node.get_field("classname", "") or ""
600
+ # Use just test name as display ID, or extract from classname
601
+ if test_name:
602
+ display_id = test_name
603
+ elif classname:
604
+ display_id = classname.split(".")[-1]
605
+ else:
606
+ display_id = node.id.split("::")[-1] if "::" in node.id else node.id[-30:]
607
+
608
+ row = TreeRow(
609
+ id=f"{node.id}_0_root",
610
+ display_id=display_id,
611
+ title=node.get_label() or "",
612
+ level="",
613
+ status="",
614
+ coverage="none",
615
+ topic="",
616
+ depth=0,
617
+ parent_id=None,
618
+ assertions=[],
619
+ is_leaf=True,
620
+ is_changed=False,
621
+ is_uncommitted=False,
622
+ is_roadmap=False,
623
+ is_code=False,
624
+ is_test=False,
625
+ is_test_result=True,
626
+ has_children=False,
627
+ has_failures=result_status in ("failed", "fail", "failure", "error"),
628
+ is_associated=False,
629
+ source_file=source_file,
630
+ source_line=source_line,
631
+ result_status=result_status,
632
+ )
633
+ rows.append(row)
634
+
635
+ return rows
636
+
637
+ def _collect_unique_values(self, field_name: str) -> set[str]:
638
+ """Collect unique values for a field across all requirements."""
639
+ from elspais.graph import NodeKind
640
+
641
+ values: set[str] = set()
642
+ for node in self.graph.nodes_by_kind(NodeKind.REQUIREMENT):
643
+ if field_name == "status":
644
+ val = (node.status or "").upper()
645
+ elif field_name == "topic":
646
+ val = self._get_topic_for_node(node)
647
+ else:
648
+ val = node.get_field(field_name, "")
649
+ if val:
650
+ values.add(val)
651
+ return values
652
+
653
+ def _get_topic_for_node(self, node: GraphNode) -> str:
654
+ """Extract topic from file path."""
655
+ if not node.source:
656
+ return ""
657
+ path = node.source.path
658
+ filename = Path(path).stem
659
+ for prefix in ("prd-", "ops-", "dev-"):
660
+ if filename.lower().startswith(prefix):
661
+ return filename[len(prefix) :]
662
+ return filename
663
+
664
+ def _build_tree_data(self) -> dict[str, Any]:
665
+ """Build tree data structure for embedded JSON."""
666
+ from elspais.graph import NodeKind
667
+
668
+ data: dict[str, Any] = {}
669
+ for node in self.graph.nodes_by_kind(NodeKind.REQUIREMENT):
670
+ data[node.id] = {
671
+ "id": node.id,
672
+ "label": node.get_label(),
673
+ "uuid": node.uuid,
674
+ "level": node.level,
675
+ "status": node.status,
676
+ "hash": node.hash,
677
+ "source": {
678
+ "path": node.source.path if node.source else None,
679
+ "line": node.source.line if node.source else None,
680
+ },
681
+ }
682
+ return data
683
+
684
+ def _collect_journeys(self) -> list[JourneyItem]:
685
+ """Collect all user journey nodes for the journeys tab."""
686
+ import re
687
+
688
+ from elspais.graph import NodeKind
689
+
690
+ journeys: list[JourneyItem] = []
691
+
692
+ for node in self.graph.nodes_by_kind(NodeKind.USER_JOURNEY):
693
+ # Extract description from body or other fields
694
+ description = node.get_field("body", "") or node.get_field("description", "")
695
+ if not description and node.get_label():
696
+ # Use label as title, look for body content
697
+ description = ""
698
+
699
+ # Extract actor and goal fields from parsed journey data
700
+ actor = node.get_field("actor")
701
+ goal = node.get_field("goal")
702
+
703
+ # Extract descriptor from journey ID: JNY-{descriptor}-{number}
704
+ descriptor = ""
705
+ match = re.match(r"JNY-(.+)-\d+$", node.id)
706
+ if match:
707
+ descriptor = match.group(1)
708
+
709
+ # Extract file from source path
710
+ file = ""
711
+ if node.source:
712
+ file = Path(node.source.path).name
713
+
714
+ journeys.append(
715
+ JourneyItem(
716
+ id=node.id,
717
+ title=node.get_label() or node.id,
718
+ description=description,
719
+ actor=actor,
720
+ goal=goal,
721
+ descriptor=descriptor,
722
+ file=file,
723
+ )
724
+ )
725
+
726
+ # Sort by ID for consistent ordering
727
+ journeys.sort(key=lambda j: j.id)
728
+ return journeys
729
+
730
+
731
+ __all__ = ["HTMLGenerator"]