elspais 0.9.3__py3-none-any.whl → 0.11.1__py3-none-any.whl

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