elspais 0.11.1__py3-none-any.whl → 0.11.2__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 -1
- elspais/cli.py +29 -10
- elspais/commands/analyze.py +5 -6
- elspais/commands/changed.py +2 -6
- elspais/commands/config_cmd.py +4 -4
- elspais/commands/edit.py +32 -36
- elspais/commands/hash_cmd.py +24 -18
- elspais/commands/index.py +8 -7
- elspais/commands/init.py +4 -4
- elspais/commands/reformat_cmd.py +32 -43
- elspais/commands/rules_cmd.py +6 -2
- elspais/commands/trace.py +23 -19
- elspais/commands/validate.py +8 -10
- elspais/config/defaults.py +7 -1
- elspais/core/content_rules.py +0 -1
- elspais/core/git.py +4 -10
- elspais/core/parser.py +55 -56
- elspais/core/patterns.py +2 -6
- elspais/core/rules.py +10 -15
- elspais/mcp/__init__.py +2 -0
- elspais/mcp/context.py +1 -0
- elspais/mcp/serializers.py +1 -1
- elspais/mcp/server.py +54 -39
- elspais/reformat/__init__.py +13 -13
- elspais/reformat/detector.py +9 -16
- elspais/reformat/hierarchy.py +8 -7
- elspais/reformat/line_breaks.py +36 -38
- elspais/reformat/prompts.py +22 -12
- elspais/reformat/transformer.py +43 -41
- elspais/sponsors/__init__.py +0 -2
- elspais/testing/__init__.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/trace_view/__init__.py +4 -3
- elspais/trace_view/coverage.py +5 -5
- elspais/trace_view/generators/__init__.py +1 -1
- elspais/trace_view/generators/base.py +17 -12
- elspais/trace_view/generators/csv.py +2 -6
- elspais/trace_view/generators/markdown.py +3 -8
- elspais/trace_view/html/__init__.py +4 -2
- elspais/trace_view/html/generator.py +423 -289
- elspais/trace_view/models.py +25 -0
- elspais/trace_view/review/__init__.py +21 -18
- elspais/trace_view/review/branches.py +114 -121
- elspais/trace_view/review/models.py +232 -237
- elspais/trace_view/review/position.py +53 -71
- elspais/trace_view/review/server.py +264 -288
- elspais/trace_view/review/status.py +43 -58
- elspais/trace_view/review/storage.py +48 -72
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/METADATA +1 -1
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.1.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -15,7 +15,6 @@ Contains:
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
|
-
import re
|
|
19
18
|
import sys
|
|
20
19
|
from datetime import datetime
|
|
21
20
|
from pathlib import Path
|
|
@@ -23,8 +22,13 @@ from typing import Dict, List, Optional, Set
|
|
|
23
22
|
|
|
24
23
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
25
24
|
|
|
25
|
+
from elspais.trace_view.coverage import (
|
|
26
|
+
calculate_coverage,
|
|
27
|
+
count_by_level,
|
|
28
|
+
find_orphaned_requirements,
|
|
29
|
+
get_implementation_status,
|
|
30
|
+
)
|
|
26
31
|
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
32
|
|
|
29
33
|
|
|
30
34
|
class HTMLGenerator:
|
|
@@ -45,11 +49,11 @@ class HTMLGenerator:
|
|
|
45
49
|
def __init__(
|
|
46
50
|
self,
|
|
47
51
|
requirements: Dict[str, Requirement],
|
|
48
|
-
base_path: str =
|
|
49
|
-
mode: str =
|
|
52
|
+
base_path: str = "",
|
|
53
|
+
mode: str = "core",
|
|
50
54
|
sponsor: Optional[str] = None,
|
|
51
55
|
version: int = 16,
|
|
52
|
-
repo_root: Optional[Path] = None
|
|
56
|
+
repo_root: Optional[Path] = None,
|
|
53
57
|
):
|
|
54
58
|
self.requirements = requirements
|
|
55
59
|
self._base_path = base_path
|
|
@@ -65,20 +69,17 @@ class HTMLGenerator:
|
|
|
65
69
|
template_dir = Path(__file__).parent / "templates"
|
|
66
70
|
self.env = Environment(
|
|
67
71
|
loader=FileSystemLoader(template_dir),
|
|
68
|
-
autoescape=select_autoescape([
|
|
72
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
69
73
|
trim_blocks=True,
|
|
70
|
-
lstrip_blocks=True
|
|
74
|
+
lstrip_blocks=True,
|
|
71
75
|
)
|
|
72
76
|
|
|
73
77
|
# Register custom filters for templates
|
|
74
|
-
self.env.filters[
|
|
75
|
-
self.env.filters[
|
|
78
|
+
self.env.filters["status_class"] = lambda s: s.lower() if s else ""
|
|
79
|
+
self.env.filters["level_class"] = lambda s: s.lower() if s else ""
|
|
76
80
|
|
|
77
81
|
def generate(
|
|
78
|
-
self,
|
|
79
|
-
embed_content: bool = False,
|
|
80
|
-
edit_mode: bool = False,
|
|
81
|
-
review_mode: bool = False
|
|
82
|
+
self, embed_content: bool = False, edit_mode: bool = False, review_mode: bool = False
|
|
82
83
|
) -> str:
|
|
83
84
|
"""Generate the complete HTML report using Jinja2 templates.
|
|
84
85
|
|
|
@@ -94,7 +95,7 @@ class HTMLGenerator:
|
|
|
94
95
|
jinja2.TemplateError: If template rendering fails
|
|
95
96
|
"""
|
|
96
97
|
context = self._build_render_context(embed_content, edit_mode, review_mode)
|
|
97
|
-
template = self.env.get_template(
|
|
98
|
+
template = self.env.get_template("base.html")
|
|
98
99
|
return template.render(**context)
|
|
99
100
|
|
|
100
101
|
def _count_by_level(self) -> Dict[str, Dict[str, int]]:
|
|
@@ -111,14 +112,14 @@ class HTMLGenerator:
|
|
|
111
112
|
repo_counts: Dict[str, Dict[str, int]] = {}
|
|
112
113
|
|
|
113
114
|
for req in self.requirements.values():
|
|
114
|
-
prefix = req.repo_prefix or
|
|
115
|
+
prefix = req.repo_prefix or "CORE" # Use CORE for core repo
|
|
115
116
|
|
|
116
117
|
if prefix not in repo_counts:
|
|
117
|
-
repo_counts[prefix] = {
|
|
118
|
+
repo_counts[prefix] = {"active": 0, "all": 0}
|
|
118
119
|
|
|
119
|
-
repo_counts[prefix][
|
|
120
|
-
if req.status !=
|
|
121
|
-
repo_counts[prefix][
|
|
120
|
+
repo_counts[prefix]["all"] += 1
|
|
121
|
+
if req.status != "Deprecated":
|
|
122
|
+
repo_counts[prefix]["active"] += 1
|
|
122
123
|
|
|
123
124
|
return repo_counts
|
|
124
125
|
|
|
@@ -191,16 +192,16 @@ class HTMLGenerator:
|
|
|
191
192
|
|
|
192
193
|
# Load modules in dependency order (REQ-d00092)
|
|
193
194
|
module_order = [
|
|
194
|
-
"review-data.js",
|
|
195
|
-
"review-position.js",
|
|
196
|
-
"review-line-numbers.js",
|
|
197
|
-
"review-comments.js",
|
|
198
|
-
"review-status.js",
|
|
199
|
-
"review-packages.js",
|
|
200
|
-
"review-sync.js",
|
|
201
|
-
"review-help.js",
|
|
202
|
-
"review-resize.js",
|
|
203
|
-
"review-init.js",
|
|
195
|
+
"review-data.js", # Data models and state (no deps)
|
|
196
|
+
"review-position.js", # Position resolution (depends on data)
|
|
197
|
+
"review-line-numbers.js", # Line numbers for click-to-comment (depends on data)
|
|
198
|
+
"review-comments.js", # Comments UI (depends on position, line-numbers)
|
|
199
|
+
"review-status.js", # Status UI (depends on data)
|
|
200
|
+
"review-packages.js", # Package management (depends on data)
|
|
201
|
+
"review-sync.js", # Sync operations (depends on data)
|
|
202
|
+
"review-help.js", # Help system (depends on data)
|
|
203
|
+
"review-resize.js", # Panel resize (depends on DOM)
|
|
204
|
+
"review-init.js", # Init orchestration (must be last)
|
|
204
205
|
]
|
|
205
206
|
|
|
206
207
|
js_parts = []
|
|
@@ -213,10 +214,7 @@ class HTMLGenerator:
|
|
|
213
214
|
return "\n".join(js_parts)
|
|
214
215
|
|
|
215
216
|
def _build_render_context(
|
|
216
|
-
self,
|
|
217
|
-
embed_content: bool = False,
|
|
218
|
-
edit_mode: bool = False,
|
|
219
|
-
review_mode: bool = False
|
|
217
|
+
self, embed_content: bool = False, edit_mode: bool = False, review_mode: bool = False
|
|
220
218
|
) -> dict:
|
|
221
219
|
"""Build the template render context.
|
|
222
220
|
|
|
@@ -236,7 +234,11 @@ class HTMLGenerator:
|
|
|
236
234
|
# Collect topics
|
|
237
235
|
all_topics = set()
|
|
238
236
|
for req in self.requirements.values():
|
|
239
|
-
topic =
|
|
237
|
+
topic = (
|
|
238
|
+
req.file_path.stem.split("-", 1)[1]
|
|
239
|
+
if "-" in req.file_path.stem
|
|
240
|
+
else req.file_path.stem
|
|
241
|
+
)
|
|
240
242
|
all_topics.add(topic)
|
|
241
243
|
sorted_topics = sorted(all_topics)
|
|
242
244
|
|
|
@@ -250,54 +252,37 @@ class HTMLGenerator:
|
|
|
250
252
|
|
|
251
253
|
return {
|
|
252
254
|
# Configuration flags
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
"embed_content": embed_content,
|
|
256
|
+
"edit_mode": edit_mode,
|
|
257
|
+
"review_mode": review_mode,
|
|
258
|
+
"version": self.VERSION,
|
|
258
259
|
# Statistics
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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()
|
|
260
|
+
"stats": {
|
|
261
|
+
"prd": {"active": by_level["active"]["PRD"], "all": by_level["all"]["PRD"]},
|
|
262
|
+
"ops": {"active": by_level["active"]["OPS"], "all": by_level["all"]["OPS"]},
|
|
263
|
+
"dev": {"active": by_level["active"]["DEV"], "all": by_level["all"]["DEV"]},
|
|
264
|
+
"impl_files": self._count_impl_files(),
|
|
273
265
|
},
|
|
274
|
-
|
|
275
266
|
# Repo prefix stats (for associated repos like CAL, TTN, etc.)
|
|
276
|
-
|
|
277
|
-
|
|
267
|
+
"repo_stats": by_repo,
|
|
278
268
|
# Requirements data
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
269
|
+
"topics": sorted_topics,
|
|
270
|
+
"requirements_html": requirements_html,
|
|
271
|
+
"req_json_data": req_json_data,
|
|
283
272
|
# Asset content (CSS/JS loaded from external files)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
273
|
+
"css": self._load_css(),
|
|
274
|
+
"js": self._load_js(),
|
|
287
275
|
# Review mode assets (loaded conditionally)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
276
|
+
"review_css": self._load_review_css() if review_mode else "",
|
|
277
|
+
"review_js": self._load_review_js() if review_mode else "",
|
|
278
|
+
"review_json_data": self._generate_review_json_data() if review_mode else "",
|
|
292
279
|
# Metadata
|
|
293
|
-
|
|
294
|
-
|
|
280
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
281
|
+
"repo_root": str(self.repo_root) if self.repo_root else "",
|
|
295
282
|
}
|
|
296
283
|
|
|
297
284
|
def _generate_requirements_html(
|
|
298
|
-
self,
|
|
299
|
-
embed_content: bool = False,
|
|
300
|
-
edit_mode: bool = False
|
|
285
|
+
self, embed_content: bool = False, edit_mode: bool = False
|
|
301
286
|
) -> str:
|
|
302
287
|
"""Generate the HTML for all requirements.
|
|
303
288
|
|
|
@@ -319,13 +304,11 @@ class HTMLGenerator:
|
|
|
319
304
|
for item_data in flat_list:
|
|
320
305
|
html_parts.append(
|
|
321
306
|
self._format_item_flat_html(
|
|
322
|
-
item_data,
|
|
323
|
-
embed_content=embed_content,
|
|
324
|
-
edit_mode=edit_mode
|
|
307
|
+
item_data, embed_content=embed_content, edit_mode=edit_mode
|
|
325
308
|
)
|
|
326
309
|
)
|
|
327
310
|
|
|
328
|
-
return
|
|
311
|
+
return "\n".join(html_parts)
|
|
329
312
|
|
|
330
313
|
def _generate_legend_html(self) -> str:
|
|
331
314
|
"""Generate HTML legend section"""
|
|
@@ -339,9 +322,11 @@ class HTMLGenerator:
|
|
|
339
322
|
<li style="margin: 4px 0;">✅ Active requirement</li>
|
|
340
323
|
<li style="margin: 4px 0;">🚧 Draft requirement</li>
|
|
341
324
|
<li style="margin: 4px 0;">⚠️ Deprecated requirement</li>
|
|
342
|
-
<li style="margin: 4px 0;"><span style="color: #28a745;
|
|
343
|
-
|
|
344
|
-
<li style="margin: 4px 0;"
|
|
325
|
+
<li style="margin: 4px 0;"><span style="color: #28a745; \
|
|
326
|
+
font-weight: bold;">+</span> NEW (in untracked file)</li>
|
|
327
|
+
<li style="margin: 4px 0;"><span style="color: #fd7e14; \
|
|
328
|
+
font-weight: bold;">*</span> MODIFIED (content changed)</li>
|
|
329
|
+
<li style="margin: 4px 0;">🗺️ Roadmap - hidden by default</li>
|
|
345
330
|
</ul>
|
|
346
331
|
</div>
|
|
347
332
|
<div>
|
|
@@ -380,31 +365,31 @@ class HTMLGenerator:
|
|
|
380
365
|
file_path_url = f"file://{req.external_spec_path}"
|
|
381
366
|
else:
|
|
382
367
|
# Core repo: use relative path
|
|
383
|
-
spec_subpath =
|
|
368
|
+
spec_subpath = "spec/roadmap" if req.is_roadmap else "spec"
|
|
384
369
|
file_path_url = f"{self._base_path}{spec_subpath}/{req.file_path.name}"
|
|
385
370
|
|
|
386
371
|
req_data[req_id] = {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
372
|
+
"title": req.title,
|
|
373
|
+
"status": req.status,
|
|
374
|
+
"level": req.level,
|
|
375
|
+
"body": req.body.strip(),
|
|
376
|
+
"rationale": req.rationale.strip(),
|
|
377
|
+
"file": req.display_filename, # Shows CAL/filename.md for external repos
|
|
378
|
+
"filePath": file_path_url,
|
|
379
|
+
"line": req.line_number,
|
|
380
|
+
"implements": list(req.implements) if req.implements else [],
|
|
381
|
+
"isRoadmap": req.is_roadmap,
|
|
382
|
+
"isConflict": req.is_conflict,
|
|
383
|
+
"conflictWith": req.conflict_with if req.is_conflict else None,
|
|
384
|
+
"isCycle": req.is_cycle,
|
|
385
|
+
"cyclePath": req.cycle_path if req.is_cycle else None,
|
|
386
|
+
"isExternal": req.external_spec_path is not None,
|
|
387
|
+
"repoPrefix": req.repo_prefix, # e.g., 'CAL' for associated repos
|
|
403
388
|
}
|
|
404
389
|
json_str = json.dumps(req_data, indent=2)
|
|
405
390
|
# Escape </script> to prevent premature closing of the script tag
|
|
406
391
|
# This is safe because JSON strings already escape the backslash
|
|
407
|
-
json_str = json_str.replace(
|
|
392
|
+
json_str = json_str.replace("</script>", "<\\/script>")
|
|
408
393
|
return json_str
|
|
409
394
|
|
|
410
395
|
def _generate_review_json_data(self) -> str:
|
|
@@ -414,61 +399,65 @@ class HTMLGenerator:
|
|
|
414
399
|
in the HTML for immediate display. The API is still used for updates.
|
|
415
400
|
"""
|
|
416
401
|
review_data = {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
402
|
+
"threads": {},
|
|
403
|
+
"flags": {},
|
|
404
|
+
"requests": {},
|
|
405
|
+
"config": {
|
|
406
|
+
"approvalRules": {
|
|
407
|
+
"Draft->Active": ["product_owner", "tech_lead"],
|
|
408
|
+
"Active->Deprecated": ["product_owner"],
|
|
409
|
+
"Draft->Deprecated": ["product_owner"],
|
|
425
410
|
},
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
411
|
+
"pushOnComment": True,
|
|
412
|
+
"autoFetchOnOpen": True,
|
|
413
|
+
},
|
|
429
414
|
}
|
|
430
415
|
|
|
431
416
|
# Load existing review data from .reviews/ directory
|
|
432
417
|
if self.repo_root:
|
|
433
|
-
reviews_dir = self.repo_root /
|
|
418
|
+
reviews_dir = self.repo_root / ".reviews" / "reqs"
|
|
434
419
|
if reviews_dir.exists():
|
|
435
420
|
for req_dir in reviews_dir.iterdir():
|
|
436
421
|
if req_dir.is_dir():
|
|
437
422
|
req_id = req_dir.name
|
|
438
423
|
# Load threads
|
|
439
|
-
threads_file = req_dir /
|
|
424
|
+
threads_file = req_dir / "threads.json"
|
|
440
425
|
if threads_file.exists():
|
|
441
426
|
try:
|
|
442
|
-
with open(threads_file
|
|
427
|
+
with open(threads_file) as f:
|
|
443
428
|
threads_data = json.load(f)
|
|
444
|
-
if
|
|
445
|
-
review_data[
|
|
446
|
-
except (json.JSONDecodeError
|
|
447
|
-
print(
|
|
429
|
+
if "threads" in threads_data:
|
|
430
|
+
review_data["threads"][req_id] = threads_data["threads"]
|
|
431
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
432
|
+
print(
|
|
433
|
+
f"Warning: Could not load {threads_file}: {e}", file=sys.stderr
|
|
434
|
+
)
|
|
448
435
|
|
|
449
436
|
# Load flags
|
|
450
|
-
flag_file = req_dir /
|
|
437
|
+
flag_file = req_dir / "flag.json"
|
|
451
438
|
if flag_file.exists():
|
|
452
439
|
try:
|
|
453
|
-
with open(flag_file
|
|
440
|
+
with open(flag_file) as f:
|
|
454
441
|
flag_data = json.load(f)
|
|
455
|
-
review_data[
|
|
456
|
-
except (json.JSONDecodeError
|
|
442
|
+
review_data["flags"][req_id] = flag_data
|
|
443
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
457
444
|
print(f"Warning: Could not load {flag_file}: {e}", file=sys.stderr)
|
|
458
445
|
|
|
459
446
|
# Load status requests
|
|
460
|
-
status_file = req_dir /
|
|
447
|
+
status_file = req_dir / "status.json"
|
|
461
448
|
if status_file.exists():
|
|
462
449
|
try:
|
|
463
|
-
with open(status_file
|
|
450
|
+
with open(status_file) as f:
|
|
464
451
|
status_data = json.load(f)
|
|
465
|
-
if
|
|
466
|
-
review_data[
|
|
467
|
-
except (json.JSONDecodeError
|
|
468
|
-
print(
|
|
452
|
+
if "requests" in status_data:
|
|
453
|
+
review_data["requests"][req_id] = status_data["requests"]
|
|
454
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
455
|
+
print(
|
|
456
|
+
f"Warning: Could not load {status_file}: {e}", file=sys.stderr
|
|
457
|
+
)
|
|
469
458
|
|
|
470
459
|
json_str = json.dumps(review_data, indent=2)
|
|
471
|
-
json_str = json_str.replace(
|
|
460
|
+
json_str = json_str.replace("</script>", "<\\/script>")
|
|
472
461
|
return json_str
|
|
473
462
|
|
|
474
463
|
def _build_flat_requirement_list(self) -> List[dict]:
|
|
@@ -483,7 +472,9 @@ class HTMLGenerator:
|
|
|
483
472
|
root_reqs.sort(key=lambda r: r.id)
|
|
484
473
|
|
|
485
474
|
for root_req in root_reqs:
|
|
486
|
-
self._add_requirement_and_children(
|
|
475
|
+
self._add_requirement_and_children(
|
|
476
|
+
root_req, flat_list, indent=0, parent_instance_id="", ancestor_path=[]
|
|
477
|
+
)
|
|
487
478
|
|
|
488
479
|
# Add any orphaned requirements that weren't included in the tree
|
|
489
480
|
# (requirements that have implements pointing to non-existent parents)
|
|
@@ -495,11 +486,26 @@ class HTMLGenerator:
|
|
|
495
486
|
orphaned_reqs = [self.requirements[rid] for rid in orphaned_ids]
|
|
496
487
|
orphaned_reqs.sort(key=lambda r: r.id)
|
|
497
488
|
for orphan in orphaned_reqs:
|
|
498
|
-
self._add_requirement_and_children(
|
|
489
|
+
self._add_requirement_and_children(
|
|
490
|
+
orphan,
|
|
491
|
+
flat_list,
|
|
492
|
+
indent=0,
|
|
493
|
+
parent_instance_id="",
|
|
494
|
+
ancestor_path=[],
|
|
495
|
+
is_orphan=True,
|
|
496
|
+
)
|
|
499
497
|
|
|
500
498
|
return flat_list
|
|
501
499
|
|
|
502
|
-
def _add_requirement_and_children(
|
|
500
|
+
def _add_requirement_and_children(
|
|
501
|
+
self,
|
|
502
|
+
req: Requirement,
|
|
503
|
+
flat_list: List[dict],
|
|
504
|
+
indent: int,
|
|
505
|
+
parent_instance_id: str,
|
|
506
|
+
ancestor_path: list[str],
|
|
507
|
+
is_orphan: bool = False,
|
|
508
|
+
):
|
|
503
509
|
"""Recursively add requirement and its children to flat list
|
|
504
510
|
|
|
505
511
|
Args:
|
|
@@ -525,45 +531,50 @@ class HTMLGenerator:
|
|
|
525
531
|
self._instance_counter += 1
|
|
526
532
|
|
|
527
533
|
# Find child requirements
|
|
528
|
-
children = [
|
|
529
|
-
r for r in self.requirements.values()
|
|
530
|
-
if req.id in r.implements
|
|
531
|
-
]
|
|
534
|
+
children = [r for r in self.requirements.values() if req.id in r.implements]
|
|
532
535
|
children.sort(key=lambda r: r.id)
|
|
533
536
|
|
|
534
537
|
# Check if this requirement has children (either child reqs or implementation files)
|
|
535
538
|
has_children = len(children) > 0 or len(req.implementation_files) > 0
|
|
536
539
|
|
|
537
540
|
# Add this requirement
|
|
538
|
-
flat_list.append(
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
541
|
+
flat_list.append(
|
|
542
|
+
{
|
|
543
|
+
"req": req,
|
|
544
|
+
"indent": indent,
|
|
545
|
+
"instance_id": instance_id,
|
|
546
|
+
"parent_instance_id": parent_instance_id,
|
|
547
|
+
"has_children": has_children,
|
|
548
|
+
"item_type": "requirement",
|
|
549
|
+
}
|
|
550
|
+
)
|
|
546
551
|
|
|
547
552
|
# Add implementation files as child items
|
|
548
553
|
for file_path, line_num in req.implementation_files:
|
|
549
554
|
impl_instance_id = f"inst_{self._instance_counter}"
|
|
550
555
|
self._instance_counter += 1
|
|
551
|
-
flat_list.append(
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
556
|
+
flat_list.append(
|
|
557
|
+
{
|
|
558
|
+
"file_path": file_path,
|
|
559
|
+
"line_num": line_num,
|
|
560
|
+
"indent": indent + 1,
|
|
561
|
+
"instance_id": impl_instance_id,
|
|
562
|
+
"parent_instance_id": instance_id,
|
|
563
|
+
"has_children": False,
|
|
564
|
+
"item_type": "implementation",
|
|
565
|
+
}
|
|
566
|
+
)
|
|
560
567
|
|
|
561
568
|
# Recursively add child requirements (with updated ancestor path for cycle detection)
|
|
562
569
|
current_path = ancestor_path + [req.id]
|
|
563
570
|
for child in children:
|
|
564
|
-
self._add_requirement_and_children(
|
|
571
|
+
self._add_requirement_and_children(
|
|
572
|
+
child, flat_list, indent + 1, instance_id, current_path
|
|
573
|
+
)
|
|
565
574
|
|
|
566
|
-
def _format_item_flat_html(
|
|
575
|
+
def _format_item_flat_html(
|
|
576
|
+
self, item_data: dict, embed_content: bool = False, edit_mode: bool = False
|
|
577
|
+
) -> str:
|
|
567
578
|
"""Format a single item (requirement or implementation file) as flat HTML row
|
|
568
579
|
|
|
569
580
|
Args:
|
|
@@ -571,25 +582,30 @@ class HTMLGenerator:
|
|
|
571
582
|
embed_content: If True, use onclick handlers instead of href links for portability
|
|
572
583
|
edit_mode: If True, include edit mode UI elements
|
|
573
584
|
"""
|
|
574
|
-
item_type = item_data.get(
|
|
585
|
+
item_type = item_data.get("item_type", "requirement")
|
|
575
586
|
|
|
576
|
-
if item_type ==
|
|
587
|
+
if item_type == "implementation":
|
|
577
588
|
return self._format_impl_file_html(item_data, embed_content, edit_mode)
|
|
578
589
|
else:
|
|
579
590
|
return self._format_req_html(item_data, embed_content, edit_mode)
|
|
580
591
|
|
|
581
|
-
def _format_impl_file_html(
|
|
592
|
+
def _format_impl_file_html(
|
|
593
|
+
self, item_data: dict, embed_content: bool = False, edit_mode: bool = False
|
|
594
|
+
) -> str:
|
|
582
595
|
"""Format an implementation file as a child row"""
|
|
583
|
-
file_path = item_data[
|
|
584
|
-
line_num = item_data[
|
|
585
|
-
indent = item_data[
|
|
586
|
-
instance_id = item_data[
|
|
587
|
-
parent_instance_id = item_data[
|
|
596
|
+
file_path = item_data["file_path"]
|
|
597
|
+
line_num = item_data["line_num"]
|
|
598
|
+
indent = item_data["indent"]
|
|
599
|
+
instance_id = item_data["instance_id"]
|
|
600
|
+
parent_instance_id = item_data["parent_instance_id"]
|
|
588
601
|
|
|
589
602
|
# Create link or onclick handler
|
|
590
603
|
if embed_content:
|
|
591
604
|
file_url = f"{self._base_path}{file_path}"
|
|
592
|
-
file_link =
|
|
605
|
+
file_link = (
|
|
606
|
+
f'<a href="#" onclick="openCodeViewer(\'{file_url}\', {line_num}); '
|
|
607
|
+
f'return false;" style="color: #0066cc;">{file_path}:{line_num}</a>'
|
|
608
|
+
)
|
|
593
609
|
else:
|
|
594
610
|
link = f"{self._base_path}{file_path}#L{line_num}"
|
|
595
611
|
file_link = f'<a href="{link}" style="color: #0066cc;">{file_path}:{line_num}</a>'
|
|
@@ -599,19 +615,21 @@ class HTMLGenerator:
|
|
|
599
615
|
abs_file_path = self.repo_root / file_path
|
|
600
616
|
vscode_url = f"vscode://file/{abs_file_path}:{line_num}"
|
|
601
617
|
vscode_link = f'<a href="{vscode_url}" title="Open in VS Code" class="vscode-link">🔧</a>'
|
|
602
|
-
file_link = f
|
|
618
|
+
file_link = f"{file_link}{vscode_link}"
|
|
603
619
|
|
|
604
620
|
# Edit mode destination column (only if edit mode enabled)
|
|
605
|
-
edit_column = '<div class="req-destination edit-mode-column"></div>' if edit_mode else
|
|
621
|
+
edit_column = '<div class="req-destination edit-mode-column"></div>' if edit_mode else ""
|
|
606
622
|
|
|
607
623
|
# Build HTML for implementation file row
|
|
608
624
|
html = f"""
|
|
609
|
-
<div class="req-item impl-file" data-instance-id="{instance_id}"
|
|
625
|
+
<div class="req-item impl-file" data-instance-id="{instance_id}" \
|
|
626
|
+
data-indent="{indent}" data-parent-instance-id="{parent_instance_id}">
|
|
610
627
|
<div class="req-header-container">
|
|
611
628
|
<span class="collapse-icon"></span>
|
|
612
629
|
<div class="req-content">
|
|
613
630
|
<div class="req-id" style="color: #6c757d;">📄</div>
|
|
614
|
-
<div class="req-header" style="font-family: 'Consolas', 'Monaco',
|
|
631
|
+
<div class="req-header" style="font-family: 'Consolas', 'Monaco', \
|
|
632
|
+
monospace; font-size: 12px;">{file_link}</div>
|
|
615
633
|
<div class="req-level"></div>
|
|
616
634
|
<div class="req-badges"></div>
|
|
617
635
|
<div class="req-coverage"></div>
|
|
@@ -624,7 +642,9 @@ class HTMLGenerator:
|
|
|
624
642
|
"""
|
|
625
643
|
return html
|
|
626
644
|
|
|
627
|
-
def _format_req_html(
|
|
645
|
+
def _format_req_html(
|
|
646
|
+
self, req_data: dict, embed_content: bool = False, edit_mode: bool = False
|
|
647
|
+
) -> str:
|
|
628
648
|
"""Format a single requirement as flat HTML row
|
|
629
649
|
|
|
630
650
|
Args:
|
|
@@ -632,47 +652,60 @@ class HTMLGenerator:
|
|
|
632
652
|
embed_content: If True, use onclick handlers instead of href links for portability
|
|
633
653
|
edit_mode: If True, include edit mode UI elements
|
|
634
654
|
"""
|
|
635
|
-
req = req_data[
|
|
636
|
-
indent = req_data[
|
|
637
|
-
instance_id = req_data[
|
|
638
|
-
parent_instance_id = req_data[
|
|
639
|
-
has_children = req_data[
|
|
655
|
+
req = req_data["req"]
|
|
656
|
+
indent = req_data["indent"]
|
|
657
|
+
instance_id = req_data["instance_id"]
|
|
658
|
+
parent_instance_id = req_data["parent_instance_id"]
|
|
659
|
+
has_children = req_data["has_children"]
|
|
640
660
|
|
|
641
661
|
status_class = req.status.lower()
|
|
642
662
|
level_class = req.level.lower()
|
|
643
663
|
|
|
644
664
|
# Only show collapse icon if there are children
|
|
645
|
-
collapse_icon =
|
|
665
|
+
collapse_icon = "▼" if has_children else ""
|
|
646
666
|
|
|
647
667
|
# Determine implementation coverage status
|
|
648
668
|
impl_status = self._get_implementation_status(req.id)
|
|
649
|
-
if impl_status ==
|
|
650
|
-
coverage_icon =
|
|
651
|
-
coverage_title =
|
|
652
|
-
elif impl_status ==
|
|
653
|
-
coverage_icon =
|
|
654
|
-
coverage_title =
|
|
669
|
+
if impl_status == "Full":
|
|
670
|
+
coverage_icon = "●" # Filled circle
|
|
671
|
+
coverage_title = "Full implementation coverage"
|
|
672
|
+
elif impl_status == "Partial":
|
|
673
|
+
coverage_icon = "◐" # Half-filled circle
|
|
674
|
+
coverage_title = "Partial implementation coverage"
|
|
655
675
|
else: # Unimplemented
|
|
656
|
-
coverage_icon =
|
|
657
|
-
coverage_title =
|
|
676
|
+
coverage_icon = "○" # Empty circle
|
|
677
|
+
coverage_title = "Unimplemented"
|
|
658
678
|
|
|
659
679
|
# Determine test status
|
|
660
|
-
test_badge =
|
|
680
|
+
test_badge = ""
|
|
661
681
|
if req.test_info:
|
|
662
682
|
test_status = req.test_info.test_status
|
|
663
683
|
test_count = req.test_info.test_count + req.test_info.manual_test_count
|
|
664
684
|
|
|
665
|
-
if test_status ==
|
|
666
|
-
test_badge =
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
685
|
+
if test_status == "passed":
|
|
686
|
+
test_badge = (
|
|
687
|
+
f'<span class="test-badge test-passed" '
|
|
688
|
+
f'title="{test_count} tests passed">✅ {test_count}</span>'
|
|
689
|
+
)
|
|
690
|
+
elif test_status == "failed":
|
|
691
|
+
test_badge = (
|
|
692
|
+
f'<span class="test-badge test-failed" '
|
|
693
|
+
f'title="{test_count} tests, some failed">❌ {test_count}</span>'
|
|
694
|
+
)
|
|
695
|
+
elif test_status == "not_tested":
|
|
696
|
+
test_badge = (
|
|
697
|
+
'<span class="test-badge test-not-tested" '
|
|
698
|
+
'title="No tests implemented">⚡</span>'
|
|
699
|
+
)
|
|
671
700
|
else:
|
|
672
|
-
test_badge =
|
|
701
|
+
test_badge = (
|
|
702
|
+
'<span class="test-badge test-not-tested" ' 'title="No tests implemented">⚡</span>'
|
|
703
|
+
)
|
|
673
704
|
|
|
674
705
|
# Extract topic from filename
|
|
675
|
-
topic =
|
|
706
|
+
topic = (
|
|
707
|
+
req.file_path.stem.split("-", 1)[1] if "-" in req.file_path.stem else req.file_path.stem
|
|
708
|
+
)
|
|
676
709
|
|
|
677
710
|
# Create link to source file with REQ anchor
|
|
678
711
|
# In embedded mode, use onclick to open side panel instead of navigating away
|
|
@@ -681,11 +714,11 @@ class HTMLGenerator:
|
|
|
681
714
|
# Determine the correct spec path (spec/ or spec/roadmap/, or file:// for external)
|
|
682
715
|
if req.external_spec_path:
|
|
683
716
|
# External repo: use file:// URL
|
|
684
|
-
spec_url = f
|
|
717
|
+
spec_url = f"file://{req.external_spec_path}"
|
|
685
718
|
else:
|
|
686
719
|
# Core repo: use relative path
|
|
687
|
-
spec_subpath =
|
|
688
|
-
spec_url = f
|
|
720
|
+
spec_subpath = "spec/roadmap" if req.is_roadmap else "spec"
|
|
721
|
+
spec_url = f"{self._base_path}{spec_subpath}/{req.file_path.name}"
|
|
689
722
|
|
|
690
723
|
# Display filename without .md extension but with repo prefix (e.g., CAL/dev-edc-schema)
|
|
691
724
|
file_stem = req.file_path.stem # removes .md extension
|
|
@@ -693,21 +726,32 @@ class HTMLGenerator:
|
|
|
693
726
|
|
|
694
727
|
# Display ID: strip __conflict suffix (warning icon shows conflict status separately)
|
|
695
728
|
display_id = req.id
|
|
696
|
-
if
|
|
697
|
-
display_id = req.id.replace(
|
|
729
|
+
if "__conflict" in req.id:
|
|
730
|
+
display_id = req.id.replace("__conflict", "")
|
|
698
731
|
|
|
699
732
|
if embed_content:
|
|
700
|
-
req_link =
|
|
733
|
+
req_link = (
|
|
734
|
+
f'<a href="#" onclick="event.stopPropagation(); '
|
|
735
|
+
f"openReqPanel('{req.id}'); return false;\" "
|
|
736
|
+
f'style="color: inherit; text-decoration: none; cursor: pointer;">'
|
|
737
|
+
f"{display_id}</a>"
|
|
738
|
+
)
|
|
701
739
|
file_line_link = f'<span style="color: inherit;">{display_filename}</span>'
|
|
702
740
|
else:
|
|
703
|
-
req_link =
|
|
704
|
-
|
|
741
|
+
req_link = (
|
|
742
|
+
f'<a href="{spec_url}#REQ-{req.id}" '
|
|
743
|
+
f'style="color: inherit; text-decoration: none;">{display_id}</a>'
|
|
744
|
+
)
|
|
745
|
+
file_line_link = (
|
|
746
|
+
f'<a href="{spec_url}#L{req.line_number}" '
|
|
747
|
+
f'style="color: inherit; text-decoration: none;">{display_filename}</a>'
|
|
748
|
+
)
|
|
705
749
|
|
|
706
750
|
# Determine status indicators using distinctive Unicode symbols
|
|
707
751
|
# ★ (star) = NEW, ◆ (diamond) = MODIFIED, ↝ (wave arrow) = MOVED
|
|
708
|
-
status_suffix =
|
|
709
|
-
status_suffix_class =
|
|
710
|
-
status_title =
|
|
752
|
+
status_suffix = ""
|
|
753
|
+
status_suffix_class = ""
|
|
754
|
+
status_title = ""
|
|
711
755
|
|
|
712
756
|
is_moved = req.is_moved
|
|
713
757
|
is_new_not_moved = req.is_new and not is_moved
|
|
@@ -715,60 +759,56 @@ class HTMLGenerator:
|
|
|
715
759
|
|
|
716
760
|
if is_moved and is_modified:
|
|
717
761
|
# Moved AND modified - show both indicators
|
|
718
|
-
status_suffix =
|
|
719
|
-
status_suffix_class =
|
|
720
|
-
status_title =
|
|
762
|
+
status_suffix = "↝◆"
|
|
763
|
+
status_suffix_class = "status-moved-modified"
|
|
764
|
+
status_title = "MOVED and MODIFIED"
|
|
721
765
|
elif is_moved:
|
|
722
766
|
# Just moved (might be in new file)
|
|
723
|
-
status_suffix =
|
|
724
|
-
status_suffix_class =
|
|
725
|
-
status_title =
|
|
767
|
+
status_suffix = "↝"
|
|
768
|
+
status_suffix_class = "status-moved"
|
|
769
|
+
status_title = "MOVED from another file"
|
|
726
770
|
elif is_new_not_moved:
|
|
727
771
|
# Truly new (in new file, not moved)
|
|
728
|
-
status_suffix =
|
|
729
|
-
status_suffix_class =
|
|
730
|
-
status_title =
|
|
772
|
+
status_suffix = "★"
|
|
773
|
+
status_suffix_class = "status-new"
|
|
774
|
+
status_title = "NEW requirement"
|
|
731
775
|
elif is_modified:
|
|
732
776
|
# Modified in place
|
|
733
|
-
status_suffix =
|
|
734
|
-
status_suffix_class =
|
|
735
|
-
status_title =
|
|
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}"
|
|
777
|
+
status_suffix = "◆"
|
|
778
|
+
status_suffix_class = "status-modified"
|
|
779
|
+
status_title = "MODIFIED content"
|
|
746
780
|
|
|
747
781
|
# Check if this is a root requirement (no parents)
|
|
748
782
|
is_root = not req.implements or len(req.implements) == 0
|
|
749
783
|
is_root_attr = 'data-is-root="true"' if is_root else 'data-is-root="false"'
|
|
750
784
|
# Two separate modified attributes: uncommitted (since last commit) and branch (vs main)
|
|
751
|
-
uncommitted_attr =
|
|
752
|
-
|
|
785
|
+
uncommitted_attr = (
|
|
786
|
+
'data-uncommitted="true"' if req.is_uncommitted else 'data-uncommitted="false"'
|
|
787
|
+
)
|
|
788
|
+
branch_attr = (
|
|
789
|
+
'data-branch-changed="true"' if req.is_branch_changed else 'data-branch-changed="false"'
|
|
790
|
+
)
|
|
753
791
|
|
|
754
792
|
# Data attribute for has-children (for leaf-only filtering)
|
|
755
|
-
has_children_attr =
|
|
793
|
+
has_children_attr = (
|
|
794
|
+
'data-has-children="true"' if has_children else 'data-has-children="false"'
|
|
795
|
+
)
|
|
756
796
|
|
|
757
797
|
# Data attribute for test status (for test filter)
|
|
758
|
-
test_status_value =
|
|
798
|
+
test_status_value = "not-tested"
|
|
759
799
|
if req.test_info:
|
|
760
|
-
if req.test_info.test_status ==
|
|
761
|
-
test_status_value =
|
|
762
|
-
elif req.test_info.test_status ==
|
|
763
|
-
test_status_value =
|
|
800
|
+
if req.test_info.test_status == "passed":
|
|
801
|
+
test_status_value = "tested"
|
|
802
|
+
elif req.test_info.test_status == "failed":
|
|
803
|
+
test_status_value = "failed"
|
|
764
804
|
test_status_attr = f'data-test-status="{test_status_value}"'
|
|
765
805
|
|
|
766
806
|
# Data attribute for coverage (for coverage filter)
|
|
767
|
-
coverage_value =
|
|
768
|
-
if impl_status ==
|
|
769
|
-
coverage_value =
|
|
770
|
-
elif impl_status ==
|
|
771
|
-
coverage_value =
|
|
807
|
+
coverage_value = "none"
|
|
808
|
+
if impl_status == "Full":
|
|
809
|
+
coverage_value = "full"
|
|
810
|
+
elif impl_status == "Partial":
|
|
811
|
+
coverage_value = "partial"
|
|
772
812
|
coverage_attr = f'data-coverage="{coverage_value}"'
|
|
773
813
|
|
|
774
814
|
# Data attribute for roadmap (for roadmap filtering)
|
|
@@ -776,39 +816,106 @@ class HTMLGenerator:
|
|
|
776
816
|
|
|
777
817
|
# Edit mode buttons - only generated if edit_mode is enabled
|
|
778
818
|
if edit_mode:
|
|
819
|
+
req_id = req.id
|
|
820
|
+
file_name = req.file_path.name
|
|
779
821
|
if req.is_roadmap:
|
|
780
|
-
|
|
781
|
-
<button class="edit-btn from-roadmap"
|
|
782
|
-
|
|
783
|
-
|
|
822
|
+
from_roadmap_btn = (
|
|
823
|
+
f'<button class="edit-btn from-roadmap" '
|
|
824
|
+
f"onclick=\"addPendingMove('{req_id}', '{file_name}', 'from-roadmap')\" "
|
|
825
|
+
f'title="Move out of roadmap">↩ From Roadmap</button>'
|
|
826
|
+
)
|
|
827
|
+
move_file_btn = (
|
|
828
|
+
f'<button class="edit-btn move-file" '
|
|
829
|
+
f"onclick=\"showMoveToFile('{req_id}', '{file_name}')\" "
|
|
830
|
+
f'title="Move to different file">📁 Move</button>'
|
|
831
|
+
)
|
|
832
|
+
edit_buttons = (
|
|
833
|
+
f'<span class="edit-actions" onclick="event.stopPropagation();">'
|
|
834
|
+
f"{from_roadmap_btn}{move_file_btn}</span>"
|
|
835
|
+
)
|
|
784
836
|
else:
|
|
785
|
-
|
|
786
|
-
<button class="edit-btn to-roadmap"
|
|
787
|
-
|
|
788
|
-
|
|
837
|
+
to_roadmap_btn = (
|
|
838
|
+
f'<button class="edit-btn to-roadmap" '
|
|
839
|
+
f"onclick=\"addPendingMove('{req_id}', '{file_name}', 'to-roadmap')\" "
|
|
840
|
+
f'title="Move to roadmap">🗺️ To Roadmap</button>'
|
|
841
|
+
)
|
|
842
|
+
move_file_btn = (
|
|
843
|
+
f'<button class="edit-btn move-file" '
|
|
844
|
+
f"onclick=\"showMoveToFile('{req_id}', '{file_name}')\" "
|
|
845
|
+
f'title="Move to different file">📁 Move</button>'
|
|
846
|
+
)
|
|
847
|
+
edit_buttons = (
|
|
848
|
+
f'<span class="edit-actions" onclick="event.stopPropagation();">'
|
|
849
|
+
f"{to_roadmap_btn}{move_file_btn}</span>"
|
|
850
|
+
)
|
|
789
851
|
else:
|
|
790
|
-
edit_buttons =
|
|
852
|
+
edit_buttons = ""
|
|
791
853
|
|
|
792
854
|
# Roadmap indicator icon (shown after REQ ID)
|
|
793
|
-
roadmap_icon =
|
|
855
|
+
roadmap_icon = (
|
|
856
|
+
'<span class="roadmap-icon" title="In roadmap">🛤️</span>' if req.is_roadmap else ""
|
|
857
|
+
)
|
|
794
858
|
|
|
795
859
|
# Conflict indicator icon (shown for roadmap REQs that conflict with existing REQs)
|
|
796
|
-
conflict_icon =
|
|
797
|
-
|
|
860
|
+
conflict_icon = (
|
|
861
|
+
f'<span class="conflict-icon" title="Conflicts with REQ-{req.conflict_with}">⚠️</span>'
|
|
862
|
+
if req.is_conflict
|
|
863
|
+
else ""
|
|
864
|
+
)
|
|
865
|
+
conflict_attr = (
|
|
866
|
+
f'data-conflict="true" data-conflict-with="{req.conflict_with}"'
|
|
867
|
+
if req.is_conflict
|
|
868
|
+
else 'data-conflict="false"'
|
|
869
|
+
)
|
|
798
870
|
|
|
799
871
|
# Cycle indicator icon (shown for REQs involved in dependency cycles)
|
|
800
|
-
cycle_icon =
|
|
801
|
-
|
|
872
|
+
cycle_icon = (
|
|
873
|
+
f'<span class="cycle-icon" title="Cycle: {req.cycle_path}">🔄</span>'
|
|
874
|
+
if req.is_cycle
|
|
875
|
+
else ""
|
|
876
|
+
)
|
|
877
|
+
cycle_attr = (
|
|
878
|
+
f'data-cycle="true" data-cycle-path="{req.cycle_path}"'
|
|
879
|
+
if req.is_cycle
|
|
880
|
+
else 'data-cycle="false"'
|
|
881
|
+
)
|
|
802
882
|
|
|
803
883
|
# Determine item class based on status
|
|
804
|
-
item_class =
|
|
884
|
+
item_class = "conflict-item" if req.is_conflict else ("cycle-item" if req.is_cycle else "")
|
|
805
885
|
|
|
806
886
|
# Repo prefix for filtering (CORE for core repo, CAL/TTN/etc. for associated repos)
|
|
807
|
-
repo_prefix = req.repo_prefix or
|
|
887
|
+
repo_prefix = req.repo_prefix or "CORE"
|
|
888
|
+
|
|
889
|
+
# Build data attributes for the div
|
|
890
|
+
deprecated_class = status_class if req.status == "Deprecated" else ""
|
|
891
|
+
data_attrs = (
|
|
892
|
+
f'data-req-id="{req.id}" data-instance-id="{instance_id}" '
|
|
893
|
+
f'data-level="{req.level}" data-indent="{indent}" '
|
|
894
|
+
f'data-parent-instance-id="{parent_instance_id}" data-topic="{topic}" '
|
|
895
|
+
f'data-status="{req.status}" data-title="{req.title.lower()}" '
|
|
896
|
+
f'data-file="{req.file_path.name}" data-repo="{repo_prefix}" '
|
|
897
|
+
f"{is_root_attr} {uncommitted_attr} {branch_attr} {has_children_attr} "
|
|
898
|
+
f"{test_status_attr} {coverage_attr} {roadmap_attr} {conflict_attr} {cycle_attr}"
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
# Build status badges HTML
|
|
902
|
+
status_badges_html = (
|
|
903
|
+
f'<span class="status-badge status-{status_class}">{req.status}</span>'
|
|
904
|
+
f'<span class="status-suffix {status_suffix_class}" '
|
|
905
|
+
f'title="{status_title}">{status_suffix}</span>'
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
# Build edit mode column if enabled
|
|
909
|
+
edit_column_html = ""
|
|
910
|
+
if edit_mode:
|
|
911
|
+
edit_column_html = (
|
|
912
|
+
f'<div class="req-destination edit-mode-column" data-req-id="{req.id}">'
|
|
913
|
+
f'{edit_buttons}<span class="dest-text"></span></div>'
|
|
914
|
+
)
|
|
808
915
|
|
|
809
916
|
# Build HTML for single flat row with unique instance ID
|
|
810
917
|
html = f"""
|
|
811
|
-
<div class="req-item {level_class} {
|
|
918
|
+
<div class="req-item {level_class} {deprecated_class} {item_class}" {data_attrs}>
|
|
812
919
|
<div class="req-header-container" onclick="toggleRequirement(this)">
|
|
813
920
|
<span class="collapse-icon">{collapse_icon}</span>
|
|
814
921
|
<div class="req-content">
|
|
@@ -816,24 +923,27 @@ class HTMLGenerator:
|
|
|
816
923
|
<div class="req-header">{req.title}</div>
|
|
817
924
|
<div class="req-level">{req.level}</div>
|
|
818
925
|
<div class="req-badges">
|
|
819
|
-
|
|
926
|
+
{status_badges_html}
|
|
820
927
|
</div>
|
|
821
928
|
<div class="req-coverage" title="{coverage_title}">{coverage_icon}</div>
|
|
822
929
|
<div class="req-status">{test_badge}</div>
|
|
823
930
|
<div class="req-location">{file_line_link}</div>
|
|
824
|
-
{
|
|
931
|
+
{edit_column_html}
|
|
825
932
|
</div>
|
|
826
933
|
</div>
|
|
827
934
|
</div>
|
|
828
935
|
"""
|
|
829
936
|
return html
|
|
830
937
|
|
|
831
|
-
def _format_req_tree_html(
|
|
938
|
+
def _format_req_tree_html(
|
|
939
|
+
self, req: Requirement, ancestor_path: list[str] | None = None
|
|
940
|
+
) -> str:
|
|
832
941
|
"""Format requirement and children as HTML tree (legacy non-collapsible).
|
|
833
942
|
|
|
834
943
|
Args:
|
|
835
944
|
req: The requirement to format
|
|
836
|
-
ancestor_path: List of requirement IDs in the current traversal path
|
|
945
|
+
ancestor_path: List of requirement IDs in the current traversal path
|
|
946
|
+
(for cycle detection)
|
|
837
947
|
|
|
838
948
|
Returns:
|
|
839
949
|
Formatted HTML string
|
|
@@ -846,13 +956,20 @@ class HTMLGenerator:
|
|
|
846
956
|
cycle_path = ancestor_path + [req.id]
|
|
847
957
|
cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
|
|
848
958
|
print(f"⚠️ CYCLE DETECTED: {cycle_str}", file=sys.stderr)
|
|
849
|
-
return
|
|
959
|
+
return (
|
|
960
|
+
f' <div class="req-item cycle-detected">'
|
|
961
|
+
f"<strong>⚠️ CYCLE DETECTED:</strong> REQ-{req.id} "
|
|
962
|
+
f"(path: {cycle_str})</div>\n"
|
|
963
|
+
)
|
|
850
964
|
|
|
851
965
|
# Safety depth limit
|
|
852
966
|
MAX_DEPTH = 50
|
|
853
967
|
if len(ancestor_path) > MAX_DEPTH:
|
|
854
968
|
print(f"⚠️ MAX DEPTH ({MAX_DEPTH}) exceeded at REQ-{req.id}", file=sys.stderr)
|
|
855
|
-
return
|
|
969
|
+
return (
|
|
970
|
+
f' <div class="req-item depth-exceeded">'
|
|
971
|
+
f"<strong>⚠️ MAX DEPTH EXCEEDED:</strong> REQ-{req.id}</div>\n"
|
|
972
|
+
)
|
|
856
973
|
|
|
857
974
|
status_class = req.status.lower()
|
|
858
975
|
level_class = req.level.lower()
|
|
@@ -870,10 +987,7 @@ class HTMLGenerator:
|
|
|
870
987
|
"""
|
|
871
988
|
|
|
872
989
|
# Find children
|
|
873
|
-
children = [
|
|
874
|
-
r for r in self.requirements.values()
|
|
875
|
-
if req.id in r.implements
|
|
876
|
-
]
|
|
990
|
+
children = [r for r in self.requirements.values() if req.id in r.implements]
|
|
877
991
|
children.sort(key=lambda r: r.id)
|
|
878
992
|
|
|
879
993
|
if children:
|
|
@@ -882,17 +996,20 @@ class HTMLGenerator:
|
|
|
882
996
|
html += ' <div class="child-reqs">\n'
|
|
883
997
|
for child in children:
|
|
884
998
|
html += self._format_req_tree_html(child, current_path)
|
|
885
|
-
html +=
|
|
999
|
+
html += " </div>\n"
|
|
886
1000
|
|
|
887
|
-
html +=
|
|
1001
|
+
html += " </div>\n"
|
|
888
1002
|
return html
|
|
889
1003
|
|
|
890
|
-
def _format_req_tree_html_collapsible(
|
|
1004
|
+
def _format_req_tree_html_collapsible(
|
|
1005
|
+
self, req: Requirement, ancestor_path: list[str] | None = None
|
|
1006
|
+
) -> str:
|
|
891
1007
|
"""Format requirement and children as collapsible HTML tree.
|
|
892
1008
|
|
|
893
1009
|
Args:
|
|
894
1010
|
req: The requirement to format
|
|
895
|
-
ancestor_path: List of requirement IDs in the current traversal path
|
|
1011
|
+
ancestor_path: List of requirement IDs in the current traversal path
|
|
1012
|
+
(for cycle detection)
|
|
896
1013
|
|
|
897
1014
|
Returns:
|
|
898
1015
|
Formatted HTML string
|
|
@@ -905,7 +1022,7 @@ class HTMLGenerator:
|
|
|
905
1022
|
cycle_path = ancestor_path + [req.id]
|
|
906
1023
|
cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
|
|
907
1024
|
print(f"⚠️ CYCLE DETECTED: {cycle_str}", file=sys.stderr)
|
|
908
|
-
return f
|
|
1025
|
+
return f"""
|
|
909
1026
|
<div class="req-item cycle-detected" data-req-id="{req.id}">
|
|
910
1027
|
<div class="req-header-container">
|
|
911
1028
|
<span class="collapse-icon"></span>
|
|
@@ -920,13 +1037,13 @@ class HTMLGenerator:
|
|
|
920
1037
|
</div>
|
|
921
1038
|
</div>
|
|
922
1039
|
</div>
|
|
923
|
-
|
|
1040
|
+
"""
|
|
924
1041
|
|
|
925
1042
|
# Safety depth limit
|
|
926
1043
|
MAX_DEPTH = 50
|
|
927
1044
|
if len(ancestor_path) > MAX_DEPTH:
|
|
928
1045
|
print(f"⚠️ MAX DEPTH ({MAX_DEPTH}) exceeded at REQ-{req.id}", file=sys.stderr)
|
|
929
|
-
return f
|
|
1046
|
+
return f"""
|
|
930
1047
|
<div class="req-item depth-exceeded" data-req-id="{req.id}">
|
|
931
1048
|
<div class="req-header-container">
|
|
932
1049
|
<span class="collapse-icon"></span>
|
|
@@ -940,44 +1057,62 @@ class HTMLGenerator:
|
|
|
940
1057
|
</div>
|
|
941
1058
|
</div>
|
|
942
1059
|
</div>
|
|
943
|
-
|
|
1060
|
+
"""
|
|
944
1061
|
|
|
945
1062
|
status_class = req.status.lower()
|
|
946
1063
|
level_class = req.level.lower()
|
|
947
1064
|
|
|
948
1065
|
# Find children
|
|
949
|
-
children = [
|
|
950
|
-
r for r in self.requirements.values()
|
|
951
|
-
if req.id in r.implements
|
|
952
|
-
]
|
|
1066
|
+
children = [r for r in self.requirements.values() if req.id in r.implements]
|
|
953
1067
|
children.sort(key=lambda r: r.id)
|
|
954
1068
|
|
|
955
1069
|
# Only show collapse icon if there are children
|
|
956
|
-
collapse_icon =
|
|
1070
|
+
collapse_icon = "▼" if children else ""
|
|
957
1071
|
|
|
958
1072
|
# Determine test status
|
|
959
|
-
test_badge =
|
|
1073
|
+
test_badge = ""
|
|
960
1074
|
if req.test_info:
|
|
961
1075
|
test_status = req.test_info.test_status
|
|
962
1076
|
test_count = req.test_info.test_count + req.test_info.manual_test_count
|
|
963
1077
|
|
|
964
|
-
if test_status ==
|
|
965
|
-
test_badge =
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1078
|
+
if test_status == "passed":
|
|
1079
|
+
test_badge = (
|
|
1080
|
+
f'<span class="test-badge test-passed" '
|
|
1081
|
+
f'title="{test_count} tests passed">✅ {test_count}</span>'
|
|
1082
|
+
)
|
|
1083
|
+
elif test_status == "failed":
|
|
1084
|
+
test_badge = (
|
|
1085
|
+
f'<span class="test-badge test-failed" '
|
|
1086
|
+
f'title="{test_count} tests, some failed">❌ {test_count}</span>'
|
|
1087
|
+
)
|
|
1088
|
+
elif test_status == "not_tested":
|
|
1089
|
+
test_badge = (
|
|
1090
|
+
'<span class="test-badge test-not-tested" '
|
|
1091
|
+
'title="No tests implemented">⚡</span>'
|
|
1092
|
+
)
|
|
970
1093
|
else:
|
|
971
|
-
test_badge =
|
|
1094
|
+
test_badge = (
|
|
1095
|
+
'<span class="test-badge test-not-tested" ' 'title="No tests implemented">⚡</span>'
|
|
1096
|
+
)
|
|
972
1097
|
|
|
973
1098
|
# Extract topic from filename (e.g., prd-security.md -> security)
|
|
974
|
-
topic =
|
|
1099
|
+
topic = (
|
|
1100
|
+
req.file_path.stem.split("-", 1)[1] if "-" in req.file_path.stem else req.file_path.stem
|
|
1101
|
+
)
|
|
975
1102
|
|
|
976
1103
|
# Repo prefix for filtering (CORE for core repo, CAL/TTN/etc. for associated repos)
|
|
977
|
-
repo_prefix = req.repo_prefix or
|
|
1104
|
+
repo_prefix = req.repo_prefix or "CORE"
|
|
1105
|
+
|
|
1106
|
+
# Build data attributes
|
|
1107
|
+
deprecated_class = status_class if req.status == "Deprecated" else ""
|
|
1108
|
+
data_attrs = (
|
|
1109
|
+
f'data-req-id="{req.id}" data-level="{req.level}" data-topic="{topic}" '
|
|
1110
|
+
f'data-status="{req.status}" data-title="{req.title.lower()}" '
|
|
1111
|
+
f'data-repo="{repo_prefix}"'
|
|
1112
|
+
)
|
|
978
1113
|
|
|
979
1114
|
html = f"""
|
|
980
|
-
<div class="req-item {level_class} {
|
|
1115
|
+
<div class="req-item {level_class} {deprecated_class}" {data_attrs}>
|
|
981
1116
|
<div class="req-header-container" onclick="toggleRequirement(this)">
|
|
982
1117
|
<span class="collapse-icon">{collapse_icon}</span>
|
|
983
1118
|
<div class="req-content">
|
|
@@ -999,8 +1134,7 @@ class HTMLGenerator:
|
|
|
999
1134
|
html += ' <div class="child-reqs">\n'
|
|
1000
1135
|
for child in children:
|
|
1001
1136
|
html += self._format_req_tree_html_collapsible(child, current_path)
|
|
1002
|
-
html +=
|
|
1137
|
+
html += " </div>\n"
|
|
1003
1138
|
|
|
1004
|
-
html +=
|
|
1139
|
+
html += " </div>\n"
|
|
1005
1140
|
return html
|
|
1006
|
-
|