elspais 0.11.2__py3-none-any.whl → 0.43.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elspais/__init__.py +1 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- 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 +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- 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 +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.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 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- 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 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- 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.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.2.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"]
|