elspais 0.9.1__py3-none-any.whl → 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elspais/cli.py +123 -1
- elspais/commands/changed.py +160 -0
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +81 -18
- elspais/core/git.py +352 -0
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/METADATA +78 -26
- elspais-0.11.0.dist-info/RECORD +101 -0
- elspais-0.9.1.dist-info/RECORD +0 -38
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML Generator for trace-view.
|
|
3
|
+
|
|
4
|
+
This module contains all HTML, CSS, and JavaScript generation for the
|
|
5
|
+
interactive traceability matrix report. It was extracted from the original
|
|
6
|
+
generate_traceability.py as a monolithic module to support the trace_view
|
|
7
|
+
package refactoring.
|
|
8
|
+
|
|
9
|
+
Contains:
|
|
10
|
+
- HTMLGenerator class with all HTML rendering methods
|
|
11
|
+
- CSS styles for the interactive report
|
|
12
|
+
- JavaScript for interactivity (expand/collapse, side panel, code viewer)
|
|
13
|
+
- Modal dialogs (legend, file picker)
|
|
14
|
+
- Edit mode functionality
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, List, Optional, Set
|
|
23
|
+
|
|
24
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
25
|
+
|
|
26
|
+
from elspais.trace_view.models import TraceViewRequirement as Requirement
|
|
27
|
+
from elspais.trace_view.coverage import count_by_level, find_orphaned_requirements, calculate_coverage, get_implementation_status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HTMLGenerator:
|
|
31
|
+
"""Generates interactive HTML traceability matrix.
|
|
32
|
+
|
|
33
|
+
This class contains all the HTML, CSS, and JavaScript generation logic
|
|
34
|
+
for the trace-view interactive report.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
requirements: Dict mapping requirement ID to Requirement object
|
|
38
|
+
base_path: Relative path from output file to repo root (for links)
|
|
39
|
+
mode: Report mode ('core', 'sponsor', 'combined')
|
|
40
|
+
sponsor: Sponsor name if in sponsor mode
|
|
41
|
+
version: Version number for display
|
|
42
|
+
repo_root: Repository root path for absolute links
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
requirements: Dict[str, Requirement],
|
|
48
|
+
base_path: str = '',
|
|
49
|
+
mode: str = 'core',
|
|
50
|
+
sponsor: Optional[str] = None,
|
|
51
|
+
version: int = 16,
|
|
52
|
+
repo_root: Optional[Path] = None
|
|
53
|
+
):
|
|
54
|
+
self.requirements = requirements
|
|
55
|
+
self._base_path = base_path
|
|
56
|
+
self.mode = mode
|
|
57
|
+
self.sponsor = sponsor
|
|
58
|
+
self.VERSION = version
|
|
59
|
+
self.repo_root = repo_root
|
|
60
|
+
# Instance tracking for flat list building
|
|
61
|
+
self._instance_counter = 0
|
|
62
|
+
self._visited_req_ids: Set[str] = set()
|
|
63
|
+
|
|
64
|
+
# Jinja2 template environment
|
|
65
|
+
template_dir = Path(__file__).parent / "templates"
|
|
66
|
+
self.env = Environment(
|
|
67
|
+
loader=FileSystemLoader(template_dir),
|
|
68
|
+
autoescape=select_autoescape(['html', 'xml']),
|
|
69
|
+
trim_blocks=True,
|
|
70
|
+
lstrip_blocks=True
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Register custom filters for templates
|
|
74
|
+
self.env.filters['status_class'] = lambda s: s.lower() if s else ''
|
|
75
|
+
self.env.filters['level_class'] = lambda s: s.lower() if s else ''
|
|
76
|
+
|
|
77
|
+
def generate(
|
|
78
|
+
self,
|
|
79
|
+
embed_content: bool = False,
|
|
80
|
+
edit_mode: bool = False,
|
|
81
|
+
review_mode: bool = False
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Generate the complete HTML report using Jinja2 templates.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
embed_content: If True, embed full requirement content as JSON
|
|
87
|
+
edit_mode: If True, include edit mode UI elements
|
|
88
|
+
review_mode: If True, include review mode UI and scripts
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Complete HTML document as string
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
jinja2.TemplateError: If template rendering fails
|
|
95
|
+
"""
|
|
96
|
+
context = self._build_render_context(embed_content, edit_mode, review_mode)
|
|
97
|
+
template = self.env.get_template('base.html')
|
|
98
|
+
return template.render(**context)
|
|
99
|
+
|
|
100
|
+
def _count_by_level(self) -> Dict[str, Dict[str, int]]:
|
|
101
|
+
"""Count requirements by level, with and without deprecated."""
|
|
102
|
+
return count_by_level(self.requirements)
|
|
103
|
+
|
|
104
|
+
def _count_by_repo(self) -> Dict[str, Dict[str, int]]:
|
|
105
|
+
"""Count requirements by repo prefix (CORE, CAL, TTN, etc.).
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Dict mapping repo prefix to {'active': count, 'all': count}
|
|
109
|
+
CORE is used for core repo requirements (no prefix).
|
|
110
|
+
"""
|
|
111
|
+
repo_counts: Dict[str, Dict[str, int]] = {}
|
|
112
|
+
|
|
113
|
+
for req in self.requirements.values():
|
|
114
|
+
prefix = req.repo_prefix or 'CORE' # Use CORE for core repo
|
|
115
|
+
|
|
116
|
+
if prefix not in repo_counts:
|
|
117
|
+
repo_counts[prefix] = {'active': 0, 'all': 0}
|
|
118
|
+
|
|
119
|
+
repo_counts[prefix]['all'] += 1
|
|
120
|
+
if req.status != 'Deprecated':
|
|
121
|
+
repo_counts[prefix]['active'] += 1
|
|
122
|
+
|
|
123
|
+
return repo_counts
|
|
124
|
+
|
|
125
|
+
def _count_impl_files(self) -> int:
|
|
126
|
+
"""Count total implementation files across all requirements."""
|
|
127
|
+
return sum(len(req.implementation_files) for req in self.requirements.values())
|
|
128
|
+
|
|
129
|
+
def _find_orphaned_requirements(self) -> List[Requirement]:
|
|
130
|
+
"""Find requirements with missing parents."""
|
|
131
|
+
return find_orphaned_requirements(self.requirements)
|
|
132
|
+
|
|
133
|
+
def _calculate_coverage(self, req_id: str) -> dict:
|
|
134
|
+
"""Calculate coverage for a requirement."""
|
|
135
|
+
return calculate_coverage(self.requirements, req_id)
|
|
136
|
+
|
|
137
|
+
def _get_implementation_status(self, req_id: str) -> str:
|
|
138
|
+
"""Get implementation status for a requirement."""
|
|
139
|
+
return get_implementation_status(self.requirements, req_id)
|
|
140
|
+
|
|
141
|
+
def _load_css(self) -> str:
|
|
142
|
+
"""Load CSS content from external stylesheet.
|
|
143
|
+
|
|
144
|
+
Loads styles from templates/partials/styles.css for embedding
|
|
145
|
+
in the HTML output.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
CSS content as string, or empty string if file not found.
|
|
149
|
+
"""
|
|
150
|
+
css_path = Path(__file__).parent / "templates" / "partials" / "styles.css"
|
|
151
|
+
if css_path.exists():
|
|
152
|
+
return css_path.read_text()
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
def _load_js(self) -> str:
|
|
156
|
+
"""Load JavaScript content from external script file.
|
|
157
|
+
|
|
158
|
+
Loads scripts from templates/partials/scripts.js for embedding
|
|
159
|
+
in the HTML output.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
JavaScript content as string, or empty string if file not found.
|
|
163
|
+
"""
|
|
164
|
+
js_path = Path(__file__).parent / "templates" / "partials" / "scripts.js"
|
|
165
|
+
if js_path.exists():
|
|
166
|
+
return js_path.read_text()
|
|
167
|
+
return ""
|
|
168
|
+
|
|
169
|
+
def _load_review_css(self) -> str:
|
|
170
|
+
"""Load review system CSS.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
CSS content as string, or empty string if file not found.
|
|
174
|
+
"""
|
|
175
|
+
css_path = Path(__file__).parent / "templates" / "partials" / "review-styles.css"
|
|
176
|
+
if css_path.exists():
|
|
177
|
+
return css_path.read_text()
|
|
178
|
+
return ""
|
|
179
|
+
|
|
180
|
+
def _load_review_js(self) -> str:
|
|
181
|
+
"""Load review system JavaScript modules.
|
|
182
|
+
|
|
183
|
+
Concatenates all review JS modules in the correct dependency order.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
JavaScript content as string, or empty string if files not found.
|
|
187
|
+
"""
|
|
188
|
+
review_dir = Path(__file__).parent / "templates" / "partials" / "review"
|
|
189
|
+
if not review_dir.exists():
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
# Load modules in dependency order (REQ-d00092)
|
|
193
|
+
module_order = [
|
|
194
|
+
"review-data.js", # Data models and state (no deps)
|
|
195
|
+
"review-position.js", # Position resolution (depends on data)
|
|
196
|
+
"review-line-numbers.js", # Line numbers for click-to-comment (depends on data)
|
|
197
|
+
"review-comments.js", # Comments UI (depends on position, line-numbers)
|
|
198
|
+
"review-status.js", # Status UI (depends on data)
|
|
199
|
+
"review-packages.js", # Package management (depends on data)
|
|
200
|
+
"review-sync.js", # Sync operations (depends on data)
|
|
201
|
+
"review-help.js", # Help system (depends on data)
|
|
202
|
+
"review-resize.js", # Panel resize (depends on DOM)
|
|
203
|
+
"review-init.js", # Init orchestration (must be last)
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
js_parts = []
|
|
207
|
+
for module_name in module_order:
|
|
208
|
+
module_path = review_dir / module_name
|
|
209
|
+
if module_path.exists():
|
|
210
|
+
js_parts.append(f"// === {module_name} ===")
|
|
211
|
+
js_parts.append(module_path.read_text())
|
|
212
|
+
|
|
213
|
+
return "\n".join(js_parts)
|
|
214
|
+
|
|
215
|
+
def _build_render_context(
|
|
216
|
+
self,
|
|
217
|
+
embed_content: bool = False,
|
|
218
|
+
edit_mode: bool = False,
|
|
219
|
+
review_mode: bool = False
|
|
220
|
+
) -> dict:
|
|
221
|
+
"""Build the template render context.
|
|
222
|
+
|
|
223
|
+
Creates a dictionary with all data needed by Jinja2 templates.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
embed_content: If True, embed full requirement content
|
|
227
|
+
edit_mode: If True, include edit mode UI
|
|
228
|
+
review_mode: If True, include review mode UI and scripts
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Dictionary containing template context variables
|
|
232
|
+
"""
|
|
233
|
+
by_level = self._count_by_level()
|
|
234
|
+
by_repo = self._count_by_repo()
|
|
235
|
+
|
|
236
|
+
# Collect topics
|
|
237
|
+
all_topics = set()
|
|
238
|
+
for req in self.requirements.values():
|
|
239
|
+
topic = req.file_path.stem.split('-', 1)[1] if '-' in req.file_path.stem else req.file_path.stem
|
|
240
|
+
all_topics.add(topic)
|
|
241
|
+
sorted_topics = sorted(all_topics)
|
|
242
|
+
|
|
243
|
+
# Build requirements HTML using existing method
|
|
244
|
+
requirements_html = self._generate_requirements_html(embed_content, edit_mode)
|
|
245
|
+
|
|
246
|
+
# Build JSON data for embedded mode
|
|
247
|
+
req_json_data = ""
|
|
248
|
+
if embed_content:
|
|
249
|
+
req_json_data = self._generate_req_json_data()
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
# Configuration flags
|
|
253
|
+
'embed_content': embed_content,
|
|
254
|
+
'edit_mode': edit_mode,
|
|
255
|
+
'review_mode': review_mode,
|
|
256
|
+
'version': self.VERSION,
|
|
257
|
+
|
|
258
|
+
# Statistics
|
|
259
|
+
'stats': {
|
|
260
|
+
'prd': {
|
|
261
|
+
'active': by_level['active']['PRD'],
|
|
262
|
+
'all': by_level['all']['PRD']
|
|
263
|
+
},
|
|
264
|
+
'ops': {
|
|
265
|
+
'active': by_level['active']['OPS'],
|
|
266
|
+
'all': by_level['all']['OPS']
|
|
267
|
+
},
|
|
268
|
+
'dev': {
|
|
269
|
+
'active': by_level['active']['DEV'],
|
|
270
|
+
'all': by_level['all']['DEV']
|
|
271
|
+
},
|
|
272
|
+
'impl_files': self._count_impl_files()
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
# Repo prefix stats (for associated repos like CAL, TTN, etc.)
|
|
276
|
+
'repo_stats': by_repo,
|
|
277
|
+
|
|
278
|
+
# Requirements data
|
|
279
|
+
'topics': sorted_topics,
|
|
280
|
+
'requirements_html': requirements_html,
|
|
281
|
+
'req_json_data': req_json_data,
|
|
282
|
+
|
|
283
|
+
# Asset content (CSS/JS loaded from external files)
|
|
284
|
+
'css': self._load_css(),
|
|
285
|
+
'js': self._load_js(),
|
|
286
|
+
|
|
287
|
+
# Review mode assets (loaded conditionally)
|
|
288
|
+
'review_css': self._load_review_css() if review_mode else '',
|
|
289
|
+
'review_js': self._load_review_js() if review_mode else '',
|
|
290
|
+
'review_json_data': self._generate_review_json_data() if review_mode else '',
|
|
291
|
+
|
|
292
|
+
# Metadata
|
|
293
|
+
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
294
|
+
'repo_root': str(self.repo_root) if self.repo_root else '',
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def _generate_requirements_html(
|
|
298
|
+
self,
|
|
299
|
+
embed_content: bool = False,
|
|
300
|
+
edit_mode: bool = False
|
|
301
|
+
) -> str:
|
|
302
|
+
"""Generate the HTML for all requirements.
|
|
303
|
+
|
|
304
|
+
This extracts the requirement tree generation logic to be used
|
|
305
|
+
by both the legacy _generate_html() method and the template-based
|
|
306
|
+
rendering.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
embed_content: If True, embed full requirement content
|
|
310
|
+
edit_mode: If True, include edit mode UI
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
HTML string with all requirement rows
|
|
314
|
+
"""
|
|
315
|
+
# Build flat list for rendering
|
|
316
|
+
flat_list = self._build_flat_requirement_list()
|
|
317
|
+
|
|
318
|
+
html_parts = []
|
|
319
|
+
for item_data in flat_list:
|
|
320
|
+
html_parts.append(
|
|
321
|
+
self._format_item_flat_html(
|
|
322
|
+
item_data,
|
|
323
|
+
embed_content=embed_content,
|
|
324
|
+
edit_mode=edit_mode
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return '\n'.join(html_parts)
|
|
329
|
+
|
|
330
|
+
def _generate_legend_html(self) -> str:
|
|
331
|
+
"""Generate HTML legend section"""
|
|
332
|
+
return """
|
|
333
|
+
<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
|
334
|
+
<h2 style="margin-top: 0;">Legend</h2>
|
|
335
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px;">
|
|
336
|
+
<div>
|
|
337
|
+
<h3 style="font-size: 13px; margin-bottom: 8px;">Requirement Status:</h3>
|
|
338
|
+
<ul style="list-style: none; padding: 0; font-size: 12px;">
|
|
339
|
+
<li style="margin: 4px 0;">✅ Active requirement</li>
|
|
340
|
+
<li style="margin: 4px 0;">🚧 Draft requirement</li>
|
|
341
|
+
<li style="margin: 4px 0;">⚠️ Deprecated requirement</li>
|
|
342
|
+
<li style="margin: 4px 0;"><span style="color: #28a745; font-weight: bold;">+</span> NEW (in untracked file)</li>
|
|
343
|
+
<li style="margin: 4px 0;"><span style="color: #fd7e14; font-weight: bold;">*</span> MODIFIED (content changed)</li>
|
|
344
|
+
<li style="margin: 4px 0;">🗺️ Roadmap (spec/roadmap/) - hidden by default</li>
|
|
345
|
+
</ul>
|
|
346
|
+
</div>
|
|
347
|
+
<div>
|
|
348
|
+
<h3 style="font-size: 13px; margin-bottom: 8px;">Traceability:</h3>
|
|
349
|
+
<ul style="list-style: none; padding: 0; font-size: 12px;">
|
|
350
|
+
<li style="margin: 4px 0;">🔗 Has implementation file(s)</li>
|
|
351
|
+
<li style="margin: 4px 0;">○ No implementation found</li>
|
|
352
|
+
</ul>
|
|
353
|
+
</div>
|
|
354
|
+
<div>
|
|
355
|
+
<h3 style="font-size: 13px; margin-bottom: 8px;">Implementation Coverage:</h3>
|
|
356
|
+
<ul style="list-style: none; padding: 0; font-size: 12px;">
|
|
357
|
+
<li style="margin: 4px 0;">● Full coverage</li>
|
|
358
|
+
<li style="margin: 4px 0;">◐ Partial coverage</li>
|
|
359
|
+
<li style="margin: 4px 0;">○ Unimplemented</li>
|
|
360
|
+
</ul>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
<div style="margin-top: 10px;">
|
|
364
|
+
<h3 style="font-size: 13px; margin-bottom: 8px;">Interactive Controls:</h3>
|
|
365
|
+
<ul style="list-style: none; padding: 0; font-size: 12px;">
|
|
366
|
+
<li style="margin: 4px 0;">▼ Expandable (has child requirements)</li>
|
|
367
|
+
<li style="margin: 4px 0;">▶ Collapsed (click to expand)</li>
|
|
368
|
+
</ul>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def _generate_req_json_data(self) -> str:
|
|
374
|
+
"""Generate JSON data containing all requirement content for embedded mode"""
|
|
375
|
+
req_data = {}
|
|
376
|
+
for req_id, req in self.requirements.items():
|
|
377
|
+
# Use correct spec path for external vs core repo requirements
|
|
378
|
+
if req.external_spec_path:
|
|
379
|
+
# External repo: use file:// URL
|
|
380
|
+
file_path_url = f"file://{req.external_spec_path}"
|
|
381
|
+
else:
|
|
382
|
+
# Core repo: use relative path
|
|
383
|
+
spec_subpath = 'spec/roadmap' if req.is_roadmap else 'spec'
|
|
384
|
+
file_path_url = f"{self._base_path}{spec_subpath}/{req.file_path.name}"
|
|
385
|
+
|
|
386
|
+
req_data[req_id] = {
|
|
387
|
+
'title': req.title,
|
|
388
|
+
'status': req.status,
|
|
389
|
+
'level': req.level,
|
|
390
|
+
'body': req.body.strip(),
|
|
391
|
+
'rationale': req.rationale.strip(),
|
|
392
|
+
'file': req.display_filename, # Shows CAL/filename.md for external repos
|
|
393
|
+
'filePath': file_path_url,
|
|
394
|
+
'line': req.line_number,
|
|
395
|
+
'implements': list(req.implements) if req.implements else [],
|
|
396
|
+
'isRoadmap': req.is_roadmap,
|
|
397
|
+
'isConflict': req.is_conflict,
|
|
398
|
+
'conflictWith': req.conflict_with if req.is_conflict else None,
|
|
399
|
+
'isCycle': req.is_cycle,
|
|
400
|
+
'cyclePath': req.cycle_path if req.is_cycle else None,
|
|
401
|
+
'isExternal': req.external_spec_path is not None,
|
|
402
|
+
'repoPrefix': req.repo_prefix # e.g., 'CAL' for associated repos
|
|
403
|
+
}
|
|
404
|
+
json_str = json.dumps(req_data, indent=2)
|
|
405
|
+
# Escape </script> to prevent premature closing of the script tag
|
|
406
|
+
# This is safe because JSON strings already escape the backslash
|
|
407
|
+
json_str = json_str.replace('</script>', '<\\/script>')
|
|
408
|
+
return json_str
|
|
409
|
+
|
|
410
|
+
def _generate_review_json_data(self) -> str:
|
|
411
|
+
"""Generate JSON data for review mode initialization.
|
|
412
|
+
|
|
413
|
+
Loads existing review data from .reviews/ directory and embeds it
|
|
414
|
+
in the HTML for immediate display. The API is still used for updates.
|
|
415
|
+
"""
|
|
416
|
+
review_data = {
|
|
417
|
+
'threads': {},
|
|
418
|
+
'flags': {},
|
|
419
|
+
'requests': {},
|
|
420
|
+
'config': {
|
|
421
|
+
'approvalRules': {
|
|
422
|
+
'Draft->Active': ['product_owner', 'tech_lead'],
|
|
423
|
+
'Active->Deprecated': ['product_owner'],
|
|
424
|
+
'Draft->Deprecated': ['product_owner']
|
|
425
|
+
},
|
|
426
|
+
'pushOnComment': True,
|
|
427
|
+
'autoFetchOnOpen': True
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# Load existing review data from .reviews/ directory
|
|
432
|
+
if self.repo_root:
|
|
433
|
+
reviews_dir = self.repo_root / '.reviews' / 'reqs'
|
|
434
|
+
if reviews_dir.exists():
|
|
435
|
+
for req_dir in reviews_dir.iterdir():
|
|
436
|
+
if req_dir.is_dir():
|
|
437
|
+
req_id = req_dir.name
|
|
438
|
+
# Load threads
|
|
439
|
+
threads_file = req_dir / 'threads.json'
|
|
440
|
+
if threads_file.exists():
|
|
441
|
+
try:
|
|
442
|
+
with open(threads_file, 'r') as f:
|
|
443
|
+
threads_data = json.load(f)
|
|
444
|
+
if 'threads' in threads_data:
|
|
445
|
+
review_data['threads'][req_id] = threads_data['threads']
|
|
446
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
447
|
+
print(f"Warning: Could not load {threads_file}: {e}", file=sys.stderr)
|
|
448
|
+
|
|
449
|
+
# Load flags
|
|
450
|
+
flag_file = req_dir / 'flag.json'
|
|
451
|
+
if flag_file.exists():
|
|
452
|
+
try:
|
|
453
|
+
with open(flag_file, 'r') as f:
|
|
454
|
+
flag_data = json.load(f)
|
|
455
|
+
review_data['flags'][req_id] = flag_data
|
|
456
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
457
|
+
print(f"Warning: Could not load {flag_file}: {e}", file=sys.stderr)
|
|
458
|
+
|
|
459
|
+
# Load status requests
|
|
460
|
+
status_file = req_dir / 'status.json'
|
|
461
|
+
if status_file.exists():
|
|
462
|
+
try:
|
|
463
|
+
with open(status_file, 'r') as f:
|
|
464
|
+
status_data = json.load(f)
|
|
465
|
+
if 'requests' in status_data:
|
|
466
|
+
review_data['requests'][req_id] = status_data['requests']
|
|
467
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
468
|
+
print(f"Warning: Could not load {status_file}: {e}", file=sys.stderr)
|
|
469
|
+
|
|
470
|
+
json_str = json.dumps(review_data, indent=2)
|
|
471
|
+
json_str = json_str.replace('</script>', '<\\/script>')
|
|
472
|
+
return json_str
|
|
473
|
+
|
|
474
|
+
def _build_flat_requirement_list(self) -> List[dict]:
|
|
475
|
+
"""Build a flat list of requirements with hierarchy information"""
|
|
476
|
+
flat_list = []
|
|
477
|
+
self._instance_counter = 0 # Track unique instance IDs
|
|
478
|
+
self._visited_req_ids = set() # Track visited requirements to avoid cycles and duplicates
|
|
479
|
+
|
|
480
|
+
# Start with all root requirements (those with no implements/parent)
|
|
481
|
+
# Root requirements can be PRD, OPS, or DEV - any req that doesn't implement another
|
|
482
|
+
root_reqs = [req for req in self.requirements.values() if not req.implements]
|
|
483
|
+
root_reqs.sort(key=lambda r: r.id)
|
|
484
|
+
|
|
485
|
+
for root_req in root_reqs:
|
|
486
|
+
self._add_requirement_and_children(root_req, flat_list, indent=0, parent_instance_id='', ancestor_path=[])
|
|
487
|
+
|
|
488
|
+
# Add any orphaned requirements that weren't included in the tree
|
|
489
|
+
# (requirements that have implements pointing to non-existent parents)
|
|
490
|
+
all_req_ids = set(self.requirements.keys())
|
|
491
|
+
included_req_ids = self._visited_req_ids
|
|
492
|
+
orphaned_ids = all_req_ids - included_req_ids
|
|
493
|
+
|
|
494
|
+
if orphaned_ids:
|
|
495
|
+
orphaned_reqs = [self.requirements[rid] for rid in orphaned_ids]
|
|
496
|
+
orphaned_reqs.sort(key=lambda r: r.id)
|
|
497
|
+
for orphan in orphaned_reqs:
|
|
498
|
+
self._add_requirement_and_children(orphan, flat_list, indent=0, parent_instance_id='', ancestor_path=[], is_orphan=True)
|
|
499
|
+
|
|
500
|
+
return flat_list
|
|
501
|
+
|
|
502
|
+
def _add_requirement_and_children(self, req: Requirement, flat_list: List[dict], indent: int, parent_instance_id: str, ancestor_path: list[str], is_orphan: bool = False):
|
|
503
|
+
"""Recursively add requirement and its children to flat list
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
req: The requirement to add
|
|
507
|
+
flat_list: List to append items to
|
|
508
|
+
indent: Current indentation level
|
|
509
|
+
parent_instance_id: Instance ID of parent item
|
|
510
|
+
ancestor_path: List of requirement IDs in current traversal path (for cycle detection)
|
|
511
|
+
is_orphan: Whether this requirement is an orphan (has missing parent)
|
|
512
|
+
"""
|
|
513
|
+
# Cycle detection: check if this requirement is already in our traversal path
|
|
514
|
+
if req.id in ancestor_path:
|
|
515
|
+
cycle_path = ancestor_path + [req.id]
|
|
516
|
+
cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
|
|
517
|
+
print(f"⚠️ CYCLE DETECTED in flat list build: {cycle_str}", file=sys.stderr)
|
|
518
|
+
return # Don't add cyclic requirement again
|
|
519
|
+
|
|
520
|
+
# Track that we've visited this requirement
|
|
521
|
+
self._visited_req_ids.add(req.id)
|
|
522
|
+
|
|
523
|
+
# Generate unique instance ID for this occurrence
|
|
524
|
+
instance_id = f"inst_{self._instance_counter}"
|
|
525
|
+
self._instance_counter += 1
|
|
526
|
+
|
|
527
|
+
# Find child requirements
|
|
528
|
+
children = [
|
|
529
|
+
r for r in self.requirements.values()
|
|
530
|
+
if req.id in r.implements
|
|
531
|
+
]
|
|
532
|
+
children.sort(key=lambda r: r.id)
|
|
533
|
+
|
|
534
|
+
# Check if this requirement has children (either child reqs or implementation files)
|
|
535
|
+
has_children = len(children) > 0 or len(req.implementation_files) > 0
|
|
536
|
+
|
|
537
|
+
# Add this requirement
|
|
538
|
+
flat_list.append({
|
|
539
|
+
'req': req,
|
|
540
|
+
'indent': indent,
|
|
541
|
+
'instance_id': instance_id,
|
|
542
|
+
'parent_instance_id': parent_instance_id,
|
|
543
|
+
'has_children': has_children,
|
|
544
|
+
'item_type': 'requirement'
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
# Add implementation files as child items
|
|
548
|
+
for file_path, line_num in req.implementation_files:
|
|
549
|
+
impl_instance_id = f"inst_{self._instance_counter}"
|
|
550
|
+
self._instance_counter += 1
|
|
551
|
+
flat_list.append({
|
|
552
|
+
'file_path': file_path,
|
|
553
|
+
'line_num': line_num,
|
|
554
|
+
'indent': indent + 1,
|
|
555
|
+
'instance_id': impl_instance_id,
|
|
556
|
+
'parent_instance_id': instance_id,
|
|
557
|
+
'has_children': False,
|
|
558
|
+
'item_type': 'implementation'
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
# Recursively add child requirements (with updated ancestor path for cycle detection)
|
|
562
|
+
current_path = ancestor_path + [req.id]
|
|
563
|
+
for child in children:
|
|
564
|
+
self._add_requirement_and_children(child, flat_list, indent + 1, instance_id, current_path)
|
|
565
|
+
|
|
566
|
+
def _format_item_flat_html(self, item_data: dict, embed_content: bool = False, edit_mode: bool = False) -> str:
|
|
567
|
+
"""Format a single item (requirement or implementation file) as flat HTML row
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
item_data: Dictionary containing item data
|
|
571
|
+
embed_content: If True, use onclick handlers instead of href links for portability
|
|
572
|
+
edit_mode: If True, include edit mode UI elements
|
|
573
|
+
"""
|
|
574
|
+
item_type = item_data.get('item_type', 'requirement')
|
|
575
|
+
|
|
576
|
+
if item_type == 'implementation':
|
|
577
|
+
return self._format_impl_file_html(item_data, embed_content, edit_mode)
|
|
578
|
+
else:
|
|
579
|
+
return self._format_req_html(item_data, embed_content, edit_mode)
|
|
580
|
+
|
|
581
|
+
def _format_impl_file_html(self, item_data: dict, embed_content: bool = False, edit_mode: bool = False) -> str:
|
|
582
|
+
"""Format an implementation file as a child row"""
|
|
583
|
+
file_path = item_data['file_path']
|
|
584
|
+
line_num = item_data['line_num']
|
|
585
|
+
indent = item_data['indent']
|
|
586
|
+
instance_id = item_data['instance_id']
|
|
587
|
+
parent_instance_id = item_data['parent_instance_id']
|
|
588
|
+
|
|
589
|
+
# Create link or onclick handler
|
|
590
|
+
if embed_content:
|
|
591
|
+
file_url = f"{self._base_path}{file_path}"
|
|
592
|
+
file_link = f'<a href="#" onclick="openCodeViewer(\'{file_url}\', {line_num}); return false;" style="color: #0066cc;">{file_path}:{line_num}</a>'
|
|
593
|
+
else:
|
|
594
|
+
link = f"{self._base_path}{file_path}#L{line_num}"
|
|
595
|
+
file_link = f'<a href="{link}" style="color: #0066cc;">{file_path}:{line_num}</a>'
|
|
596
|
+
|
|
597
|
+
# Add VS Code link for opening in editor (always uses vscode:// protocol)
|
|
598
|
+
# Note: VS Code links only work on the machine where this file was generated
|
|
599
|
+
abs_file_path = self.repo_root / file_path
|
|
600
|
+
vscode_url = f"vscode://file/{abs_file_path}:{line_num}"
|
|
601
|
+
vscode_link = f'<a href="{vscode_url}" title="Open in VS Code" class="vscode-link">🔧</a>'
|
|
602
|
+
file_link = f'{file_link}{vscode_link}'
|
|
603
|
+
|
|
604
|
+
# Edit mode destination column (only if edit mode enabled)
|
|
605
|
+
edit_column = '<div class="req-destination edit-mode-column"></div>' if edit_mode else ''
|
|
606
|
+
|
|
607
|
+
# Build HTML for implementation file row
|
|
608
|
+
html = f"""
|
|
609
|
+
<div class="req-item impl-file" data-instance-id="{instance_id}" data-indent="{indent}" data-parent-instance-id="{parent_instance_id}">
|
|
610
|
+
<div class="req-header-container">
|
|
611
|
+
<span class="collapse-icon"></span>
|
|
612
|
+
<div class="req-content">
|
|
613
|
+
<div class="req-id" style="color: #6c757d;">📄</div>
|
|
614
|
+
<div class="req-header" style="font-family: 'Consolas', 'Monaco', monospace; font-size: 12px;">{file_link}</div>
|
|
615
|
+
<div class="req-level"></div>
|
|
616
|
+
<div class="req-badges"></div>
|
|
617
|
+
<div class="req-coverage"></div>
|
|
618
|
+
<div class="req-status"></div>
|
|
619
|
+
<div class="req-location"></div>
|
|
620
|
+
{edit_column}
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
"""
|
|
625
|
+
return html
|
|
626
|
+
|
|
627
|
+
def _format_req_html(self, req_data: dict, embed_content: bool = False, edit_mode: bool = False) -> str:
|
|
628
|
+
"""Format a single requirement as flat HTML row
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
req_data: Dictionary containing requirement data
|
|
632
|
+
embed_content: If True, use onclick handlers instead of href links for portability
|
|
633
|
+
edit_mode: If True, include edit mode UI elements
|
|
634
|
+
"""
|
|
635
|
+
req = req_data['req']
|
|
636
|
+
indent = req_data['indent']
|
|
637
|
+
instance_id = req_data['instance_id']
|
|
638
|
+
parent_instance_id = req_data['parent_instance_id']
|
|
639
|
+
has_children = req_data['has_children']
|
|
640
|
+
|
|
641
|
+
status_class = req.status.lower()
|
|
642
|
+
level_class = req.level.lower()
|
|
643
|
+
|
|
644
|
+
# Only show collapse icon if there are children
|
|
645
|
+
collapse_icon = '▼' if has_children else ''
|
|
646
|
+
|
|
647
|
+
# Determine implementation coverage status
|
|
648
|
+
impl_status = self._get_implementation_status(req.id)
|
|
649
|
+
if impl_status == 'Full':
|
|
650
|
+
coverage_icon = '●' # Filled circle
|
|
651
|
+
coverage_title = 'Full implementation coverage'
|
|
652
|
+
elif impl_status == 'Partial':
|
|
653
|
+
coverage_icon = '◐' # Half-filled circle
|
|
654
|
+
coverage_title = 'Partial implementation coverage'
|
|
655
|
+
else: # Unimplemented
|
|
656
|
+
coverage_icon = '○' # Empty circle
|
|
657
|
+
coverage_title = 'Unimplemented'
|
|
658
|
+
|
|
659
|
+
# Determine test status
|
|
660
|
+
test_badge = ''
|
|
661
|
+
if req.test_info:
|
|
662
|
+
test_status = req.test_info.test_status
|
|
663
|
+
test_count = req.test_info.test_count + req.test_info.manual_test_count
|
|
664
|
+
|
|
665
|
+
if test_status == 'passed':
|
|
666
|
+
test_badge = f'<span class="test-badge test-passed" title="{test_count} tests passed">✅ {test_count}</span>'
|
|
667
|
+
elif test_status == 'failed':
|
|
668
|
+
test_badge = f'<span class="test-badge test-failed" title="{test_count} tests, some failed">❌ {test_count}</span>'
|
|
669
|
+
elif test_status == 'not_tested':
|
|
670
|
+
test_badge = '<span class="test-badge test-not-tested" title="No tests implemented">⚡</span>'
|
|
671
|
+
else:
|
|
672
|
+
test_badge = '<span class="test-badge test-not-tested" title="No tests implemented">⚡</span>'
|
|
673
|
+
|
|
674
|
+
# Extract topic from filename
|
|
675
|
+
topic = req.file_path.stem.split('-', 1)[1] if '-' in req.file_path.stem else req.file_path.stem
|
|
676
|
+
|
|
677
|
+
# Create link to source file with REQ anchor
|
|
678
|
+
# In embedded mode, use onclick to open side panel instead of navigating away
|
|
679
|
+
# event.stopPropagation() prevents the parent toggle handler from firing
|
|
680
|
+
# Display ID without "REQ-" prefix for cleaner tree view
|
|
681
|
+
# Determine the correct spec path (spec/ or spec/roadmap/, or file:// for external)
|
|
682
|
+
if req.external_spec_path:
|
|
683
|
+
# External repo: use file:// URL
|
|
684
|
+
spec_url = f'file://{req.external_spec_path}'
|
|
685
|
+
else:
|
|
686
|
+
# Core repo: use relative path
|
|
687
|
+
spec_subpath = 'spec/roadmap' if req.is_roadmap else 'spec'
|
|
688
|
+
spec_url = f'{self._base_path}{spec_subpath}/{req.file_path.name}'
|
|
689
|
+
|
|
690
|
+
# Display filename without .md extension but with repo prefix (e.g., CAL/dev-edc-schema)
|
|
691
|
+
file_stem = req.file_path.stem # removes .md extension
|
|
692
|
+
display_filename = f"{req.repo_prefix}/{file_stem}" if req.repo_prefix else file_stem
|
|
693
|
+
|
|
694
|
+
# Display ID: strip __conflict suffix (warning icon shows conflict status separately)
|
|
695
|
+
display_id = req.id
|
|
696
|
+
if '__conflict' in req.id:
|
|
697
|
+
display_id = req.id.replace('__conflict', '')
|
|
698
|
+
|
|
699
|
+
if embed_content:
|
|
700
|
+
req_link = f'<a href="#" onclick="event.stopPropagation(); openReqPanel(\'{req.id}\'); return false;" style="color: inherit; text-decoration: none; cursor: pointer;">{display_id}</a>'
|
|
701
|
+
file_line_link = f'<span style="color: inherit;">{display_filename}</span>'
|
|
702
|
+
else:
|
|
703
|
+
req_link = f'<a href="{spec_url}#REQ-{req.id}" style="color: inherit; text-decoration: none;">{display_id}</a>'
|
|
704
|
+
file_line_link = f'<a href="{spec_url}#L{req.line_number}" style="color: inherit; text-decoration: none;">{display_filename}</a>'
|
|
705
|
+
|
|
706
|
+
# Determine status indicators using distinctive Unicode symbols
|
|
707
|
+
# ★ (star) = NEW, ◆ (diamond) = MODIFIED, ↝ (wave arrow) = MOVED
|
|
708
|
+
status_suffix = ''
|
|
709
|
+
status_suffix_class = ''
|
|
710
|
+
status_title = ''
|
|
711
|
+
|
|
712
|
+
is_moved = req.is_moved
|
|
713
|
+
is_new_not_moved = req.is_new and not is_moved
|
|
714
|
+
is_modified = req.is_modified
|
|
715
|
+
|
|
716
|
+
if is_moved and is_modified:
|
|
717
|
+
# Moved AND modified - show both indicators
|
|
718
|
+
status_suffix = '↝◆'
|
|
719
|
+
status_suffix_class = 'status-moved-modified'
|
|
720
|
+
status_title = 'MOVED and MODIFIED'
|
|
721
|
+
elif is_moved:
|
|
722
|
+
# Just moved (might be in new file)
|
|
723
|
+
status_suffix = '↝'
|
|
724
|
+
status_suffix_class = 'status-moved'
|
|
725
|
+
status_title = 'MOVED from another file'
|
|
726
|
+
elif is_new_not_moved:
|
|
727
|
+
# Truly new (in new file, not moved)
|
|
728
|
+
status_suffix = '★'
|
|
729
|
+
status_suffix_class = 'status-new'
|
|
730
|
+
status_title = 'NEW requirement'
|
|
731
|
+
elif is_modified:
|
|
732
|
+
# Modified in place
|
|
733
|
+
status_suffix = '◆'
|
|
734
|
+
status_suffix_class = 'status-modified'
|
|
735
|
+
status_title = 'MODIFIED content'
|
|
736
|
+
|
|
737
|
+
# VS Code link for use in side panel (not in topic column)
|
|
738
|
+
if req.external_spec_path:
|
|
739
|
+
# External repo: use the absolute path directly
|
|
740
|
+
abs_spec_path = req.external_spec_path
|
|
741
|
+
else:
|
|
742
|
+
# Core repo: construct from repo_root
|
|
743
|
+
spec_subpath_for_vscode = 'spec/roadmap' if req.is_roadmap else 'spec'
|
|
744
|
+
abs_spec_path = self.repo_root / spec_subpath_for_vscode / req.file_path.name
|
|
745
|
+
vscode_url = f"vscode://file/{abs_spec_path}:{req.line_number}"
|
|
746
|
+
|
|
747
|
+
# Check if this is a root requirement (no parents)
|
|
748
|
+
is_root = not req.implements or len(req.implements) == 0
|
|
749
|
+
is_root_attr = 'data-is-root="true"' if is_root else 'data-is-root="false"'
|
|
750
|
+
# Two separate modified attributes: uncommitted (since last commit) and branch (vs main)
|
|
751
|
+
uncommitted_attr = 'data-uncommitted="true"' if req.is_uncommitted else 'data-uncommitted="false"'
|
|
752
|
+
branch_attr = 'data-branch-changed="true"' if req.is_branch_changed else 'data-branch-changed="false"'
|
|
753
|
+
|
|
754
|
+
# Data attribute for has-children (for leaf-only filtering)
|
|
755
|
+
has_children_attr = 'data-has-children="true"' if has_children else 'data-has-children="false"'
|
|
756
|
+
|
|
757
|
+
# Data attribute for test status (for test filter)
|
|
758
|
+
test_status_value = 'not-tested'
|
|
759
|
+
if req.test_info:
|
|
760
|
+
if req.test_info.test_status == 'passed':
|
|
761
|
+
test_status_value = 'tested'
|
|
762
|
+
elif req.test_info.test_status == 'failed':
|
|
763
|
+
test_status_value = 'failed'
|
|
764
|
+
test_status_attr = f'data-test-status="{test_status_value}"'
|
|
765
|
+
|
|
766
|
+
# Data attribute for coverage (for coverage filter)
|
|
767
|
+
coverage_value = 'none'
|
|
768
|
+
if impl_status == 'Full':
|
|
769
|
+
coverage_value = 'full'
|
|
770
|
+
elif impl_status == 'Partial':
|
|
771
|
+
coverage_value = 'partial'
|
|
772
|
+
coverage_attr = f'data-coverage="{coverage_value}"'
|
|
773
|
+
|
|
774
|
+
# Data attribute for roadmap (for roadmap filtering)
|
|
775
|
+
roadmap_attr = 'data-roadmap="true"' if req.is_roadmap else 'data-roadmap="false"'
|
|
776
|
+
|
|
777
|
+
# Edit mode buttons - only generated if edit_mode is enabled
|
|
778
|
+
if edit_mode:
|
|
779
|
+
if req.is_roadmap:
|
|
780
|
+
edit_buttons = f'''<span class="edit-actions" onclick="event.stopPropagation();">
|
|
781
|
+
<button class="edit-btn from-roadmap" onclick="addPendingMove('{req.id}', '{req.file_path.name}', 'from-roadmap')" title="Move out of roadmap">↩ From Roadmap</button>
|
|
782
|
+
<button class="edit-btn move-file" onclick="showMoveToFile('{req.id}', '{req.file_path.name}')" title="Move to different file">📁 Move</button>
|
|
783
|
+
</span>'''
|
|
784
|
+
else:
|
|
785
|
+
edit_buttons = f'''<span class="edit-actions" onclick="event.stopPropagation();">
|
|
786
|
+
<button class="edit-btn to-roadmap" onclick="addPendingMove('{req.id}', '{req.file_path.name}', 'to-roadmap')" title="Move to roadmap">🗺️ To Roadmap</button>
|
|
787
|
+
<button class="edit-btn move-file" onclick="showMoveToFile('{req.id}', '{req.file_path.name}')" title="Move to different file">📁 Move</button>
|
|
788
|
+
</span>'''
|
|
789
|
+
else:
|
|
790
|
+
edit_buttons = ''
|
|
791
|
+
|
|
792
|
+
# Roadmap indicator icon (shown after REQ ID)
|
|
793
|
+
roadmap_icon = '<span class="roadmap-icon" title="In roadmap">🛤️</span>' if req.is_roadmap else ''
|
|
794
|
+
|
|
795
|
+
# Conflict indicator icon (shown for roadmap REQs that conflict with existing REQs)
|
|
796
|
+
conflict_icon = f'<span class="conflict-icon" title="Conflicts with REQ-{req.conflict_with}">⚠️</span>' if req.is_conflict else ''
|
|
797
|
+
conflict_attr = f'data-conflict="true" data-conflict-with="{req.conflict_with}"' if req.is_conflict else 'data-conflict="false"'
|
|
798
|
+
|
|
799
|
+
# Cycle indicator icon (shown for REQs involved in dependency cycles)
|
|
800
|
+
cycle_icon = f'<span class="cycle-icon" title="Cycle: {req.cycle_path}">🔄</span>' if req.is_cycle else ''
|
|
801
|
+
cycle_attr = f'data-cycle="true" data-cycle-path="{req.cycle_path}"' if req.is_cycle else 'data-cycle="false"'
|
|
802
|
+
|
|
803
|
+
# Determine item class based on status
|
|
804
|
+
item_class = 'conflict-item' if req.is_conflict else ('cycle-item' if req.is_cycle else '')
|
|
805
|
+
|
|
806
|
+
# Repo prefix for filtering (CORE for core repo, CAL/TTN/etc. for associated repos)
|
|
807
|
+
repo_prefix = req.repo_prefix or 'CORE'
|
|
808
|
+
|
|
809
|
+
# Build HTML for single flat row with unique instance ID
|
|
810
|
+
html = f"""
|
|
811
|
+
<div class="req-item {level_class} {status_class if req.status == 'Deprecated' else ''} {item_class}" data-req-id="{req.id}" data-instance-id="{instance_id}" data-level="{req.level}" data-indent="{indent}" data-parent-instance-id="{parent_instance_id}" data-topic="{topic}" data-status="{req.status}" data-title="{req.title.lower()}" data-file="{req.file_path.name}" data-repo="{repo_prefix}" {is_root_attr} {uncommitted_attr} {branch_attr} {has_children_attr} {test_status_attr} {coverage_attr} {roadmap_attr} {conflict_attr} {cycle_attr}>
|
|
812
|
+
<div class="req-header-container" onclick="toggleRequirement(this)">
|
|
813
|
+
<span class="collapse-icon">{collapse_icon}</span>
|
|
814
|
+
<div class="req-content">
|
|
815
|
+
<div class="req-id">{conflict_icon}{cycle_icon}{req_link}{roadmap_icon}</div>
|
|
816
|
+
<div class="req-header">{req.title}</div>
|
|
817
|
+
<div class="req-level">{req.level}</div>
|
|
818
|
+
<div class="req-badges">
|
|
819
|
+
<span class="status-badge status-{status_class}">{req.status}</span><span class="status-suffix {status_suffix_class}" title="{status_title}">{status_suffix}</span>
|
|
820
|
+
</div>
|
|
821
|
+
<div class="req-coverage" title="{coverage_title}">{coverage_icon}</div>
|
|
822
|
+
<div class="req-status">{test_badge}</div>
|
|
823
|
+
<div class="req-location">{file_line_link}</div>
|
|
824
|
+
{'<div class="req-destination edit-mode-column" data-req-id="' + req.id + '">' + edit_buttons + '<span class="dest-text"></span></div>' if edit_mode else ''}
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
"""
|
|
829
|
+
return html
|
|
830
|
+
|
|
831
|
+
def _format_req_tree_html(self, req: Requirement, ancestor_path: list[str] | None = None) -> str:
|
|
832
|
+
"""Format requirement and children as HTML tree (legacy non-collapsible).
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
req: The requirement to format
|
|
836
|
+
ancestor_path: List of requirement IDs in the current traversal path (for cycle detection)
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
Formatted HTML string
|
|
840
|
+
"""
|
|
841
|
+
if ancestor_path is None:
|
|
842
|
+
ancestor_path = []
|
|
843
|
+
|
|
844
|
+
# Cycle detection: check if this requirement is already in our traversal path
|
|
845
|
+
if req.id in ancestor_path:
|
|
846
|
+
cycle_path = ancestor_path + [req.id]
|
|
847
|
+
cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
|
|
848
|
+
print(f"⚠️ CYCLE DETECTED: {cycle_str}", file=sys.stderr)
|
|
849
|
+
return f' <div class="req-item cycle-detected"><strong>⚠️ CYCLE DETECTED:</strong> REQ-{req.id} (path: {cycle_str})</div>\n'
|
|
850
|
+
|
|
851
|
+
# Safety depth limit
|
|
852
|
+
MAX_DEPTH = 50
|
|
853
|
+
if len(ancestor_path) > MAX_DEPTH:
|
|
854
|
+
print(f"⚠️ MAX DEPTH ({MAX_DEPTH}) exceeded at REQ-{req.id}", file=sys.stderr)
|
|
855
|
+
return f' <div class="req-item depth-exceeded"><strong>⚠️ MAX DEPTH EXCEEDED:</strong> REQ-{req.id}</div>\n'
|
|
856
|
+
|
|
857
|
+
status_class = req.status.lower()
|
|
858
|
+
level_class = req.level.lower()
|
|
859
|
+
|
|
860
|
+
html = f"""
|
|
861
|
+
<div class="req-item {level_class} {status_class if req.status == 'Deprecated' else ''}">
|
|
862
|
+
<div class="req-header">
|
|
863
|
+
{req.id}: {req.title}
|
|
864
|
+
</div>
|
|
865
|
+
<div class="req-meta">
|
|
866
|
+
<span class="status-badge status-{status_class}">{req.status}</span>
|
|
867
|
+
Level: {req.level} |
|
|
868
|
+
File: {req.display_filename}:{req.line_number}
|
|
869
|
+
</div>
|
|
870
|
+
"""
|
|
871
|
+
|
|
872
|
+
# Find children
|
|
873
|
+
children = [
|
|
874
|
+
r for r in self.requirements.values()
|
|
875
|
+
if req.id in r.implements
|
|
876
|
+
]
|
|
877
|
+
children.sort(key=lambda r: r.id)
|
|
878
|
+
|
|
879
|
+
if children:
|
|
880
|
+
# Add current req to path before recursing into children
|
|
881
|
+
current_path = ancestor_path + [req.id]
|
|
882
|
+
html += ' <div class="child-reqs">\n'
|
|
883
|
+
for child in children:
|
|
884
|
+
html += self._format_req_tree_html(child, current_path)
|
|
885
|
+
html += ' </div>\n'
|
|
886
|
+
|
|
887
|
+
html += ' </div>\n'
|
|
888
|
+
return html
|
|
889
|
+
|
|
890
|
+
def _format_req_tree_html_collapsible(self, req: Requirement, ancestor_path: list[str] | None = None) -> str:
|
|
891
|
+
"""Format requirement and children as collapsible HTML tree.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
req: The requirement to format
|
|
895
|
+
ancestor_path: List of requirement IDs in the current traversal path (for cycle detection)
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
Formatted HTML string
|
|
899
|
+
"""
|
|
900
|
+
if ancestor_path is None:
|
|
901
|
+
ancestor_path = []
|
|
902
|
+
|
|
903
|
+
# Cycle detection: check if this requirement is already in our traversal path
|
|
904
|
+
if req.id in ancestor_path:
|
|
905
|
+
cycle_path = ancestor_path + [req.id]
|
|
906
|
+
cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
|
|
907
|
+
print(f"⚠️ CYCLE DETECTED: {cycle_str}", file=sys.stderr)
|
|
908
|
+
return f'''
|
|
909
|
+
<div class="req-item cycle-detected" data-req-id="{req.id}">
|
|
910
|
+
<div class="req-header-container">
|
|
911
|
+
<span class="collapse-icon"></span>
|
|
912
|
+
<div class="req-content">
|
|
913
|
+
<div class="req-id">⚠️ CYCLE</div>
|
|
914
|
+
<div class="req-header">Circular dependency detected at REQ-{req.id}</div>
|
|
915
|
+
<div class="req-level">ERROR</div>
|
|
916
|
+
<div class="req-badges">
|
|
917
|
+
<span class="status-badge status-deprecated">Cycle</span>
|
|
918
|
+
</div>
|
|
919
|
+
<div class="req-location">Path: {cycle_str}</div>
|
|
920
|
+
</div>
|
|
921
|
+
</div>
|
|
922
|
+
</div>
|
|
923
|
+
'''
|
|
924
|
+
|
|
925
|
+
# Safety depth limit
|
|
926
|
+
MAX_DEPTH = 50
|
|
927
|
+
if len(ancestor_path) > MAX_DEPTH:
|
|
928
|
+
print(f"⚠️ MAX DEPTH ({MAX_DEPTH}) exceeded at REQ-{req.id}", file=sys.stderr)
|
|
929
|
+
return f'''
|
|
930
|
+
<div class="req-item depth-exceeded" data-req-id="{req.id}">
|
|
931
|
+
<div class="req-header-container">
|
|
932
|
+
<span class="collapse-icon"></span>
|
|
933
|
+
<div class="req-content">
|
|
934
|
+
<div class="req-id">⚠️ DEPTH</div>
|
|
935
|
+
<div class="req-header">Maximum depth exceeded at REQ-{req.id}</div>
|
|
936
|
+
<div class="req-level">ERROR</div>
|
|
937
|
+
<div class="req-badges">
|
|
938
|
+
<span class="status-badge status-deprecated">Overflow</span>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
'''
|
|
944
|
+
|
|
945
|
+
status_class = req.status.lower()
|
|
946
|
+
level_class = req.level.lower()
|
|
947
|
+
|
|
948
|
+
# Find children
|
|
949
|
+
children = [
|
|
950
|
+
r for r in self.requirements.values()
|
|
951
|
+
if req.id in r.implements
|
|
952
|
+
]
|
|
953
|
+
children.sort(key=lambda r: r.id)
|
|
954
|
+
|
|
955
|
+
# Only show collapse icon if there are children
|
|
956
|
+
collapse_icon = '▼' if children else ''
|
|
957
|
+
|
|
958
|
+
# Determine test status
|
|
959
|
+
test_badge = ''
|
|
960
|
+
if req.test_info:
|
|
961
|
+
test_status = req.test_info.test_status
|
|
962
|
+
test_count = req.test_info.test_count + req.test_info.manual_test_count
|
|
963
|
+
|
|
964
|
+
if test_status == 'passed':
|
|
965
|
+
test_badge = f'<span class="test-badge test-passed" title="{test_count} tests passed">✅ {test_count}</span>'
|
|
966
|
+
elif test_status == 'failed':
|
|
967
|
+
test_badge = f'<span class="test-badge test-failed" title="{test_count} tests, some failed">❌ {test_count}</span>'
|
|
968
|
+
elif test_status == 'not_tested':
|
|
969
|
+
test_badge = '<span class="test-badge test-not-tested" title="No tests implemented">⚡</span>'
|
|
970
|
+
else:
|
|
971
|
+
test_badge = '<span class="test-badge test-not-tested" title="No tests implemented">⚡</span>'
|
|
972
|
+
|
|
973
|
+
# Extract topic from filename (e.g., prd-security.md -> security)
|
|
974
|
+
topic = req.file_path.stem.split('-', 1)[1] if '-' in req.file_path.stem else req.file_path.stem
|
|
975
|
+
|
|
976
|
+
# Repo prefix for filtering (CORE for core repo, CAL/TTN/etc. for associated repos)
|
|
977
|
+
repo_prefix = req.repo_prefix or 'CORE'
|
|
978
|
+
|
|
979
|
+
html = f"""
|
|
980
|
+
<div class="req-item {level_class} {status_class if req.status == 'Deprecated' else ''}" data-req-id="{req.id}" data-level="{req.level}" data-topic="{topic}" data-status="{req.status}" data-title="{req.title.lower()}" data-repo="{repo_prefix}">
|
|
981
|
+
<div class="req-header-container" onclick="toggleRequirement(this)">
|
|
982
|
+
<span class="collapse-icon">{collapse_icon}</span>
|
|
983
|
+
<div class="req-content">
|
|
984
|
+
<div class="req-id">REQ-{req.id}</div>
|
|
985
|
+
<div class="req-header">{req.title}</div>
|
|
986
|
+
<div class="req-level">{req.level}</div>
|
|
987
|
+
<div class="req-badges">
|
|
988
|
+
<span class="status-badge status-{status_class}">{req.status}</span>
|
|
989
|
+
</div>
|
|
990
|
+
<div class="req-status">{test_badge}</div>
|
|
991
|
+
<div class="req-location">{req.display_filename}:{req.line_number}</div>
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
994
|
+
"""
|
|
995
|
+
|
|
996
|
+
if children:
|
|
997
|
+
# Add current req to path before recursing into children
|
|
998
|
+
current_path = ancestor_path + [req.id]
|
|
999
|
+
html += ' <div class="child-reqs">\n'
|
|
1000
|
+
for child in children:
|
|
1001
|
+
html += self._format_req_tree_html_collapsible(child, current_path)
|
|
1002
|
+
html += ' </div>\n'
|
|
1003
|
+
|
|
1004
|
+
html += ' </div>\n'
|
|
1005
|
+
return html
|
|
1006
|
+
|