elspais 0.11.2__py3-none-any.whl → 0.43.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -1,1140 +0,0 @@
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 sys
19
- from datetime import datetime
20
- from pathlib import Path
21
- from typing import Dict, List, Optional, Set
22
-
23
- from jinja2 import Environment, FileSystemLoader, select_autoescape
24
-
25
- from elspais.trace_view.coverage import (
26
- calculate_coverage,
27
- count_by_level,
28
- find_orphaned_requirements,
29
- get_implementation_status,
30
- )
31
- from elspais.trace_view.models import TraceViewRequirement as Requirement
32
-
33
-
34
- class HTMLGenerator:
35
- """Generates interactive HTML traceability matrix.
36
-
37
- This class contains all the HTML, CSS, and JavaScript generation logic
38
- for the trace-view interactive report.
39
-
40
- Args:
41
- requirements: Dict mapping requirement ID to Requirement object
42
- base_path: Relative path from output file to repo root (for links)
43
- mode: Report mode ('core', 'sponsor', 'combined')
44
- sponsor: Sponsor name if in sponsor mode
45
- version: Version number for display
46
- repo_root: Repository root path for absolute links
47
- """
48
-
49
- def __init__(
50
- self,
51
- requirements: Dict[str, Requirement],
52
- base_path: str = "",
53
- mode: str = "core",
54
- sponsor: Optional[str] = None,
55
- version: int = 16,
56
- repo_root: Optional[Path] = None,
57
- ):
58
- self.requirements = requirements
59
- self._base_path = base_path
60
- self.mode = mode
61
- self.sponsor = sponsor
62
- self.VERSION = version
63
- self.repo_root = repo_root
64
- # Instance tracking for flat list building
65
- self._instance_counter = 0
66
- self._visited_req_ids: Set[str] = set()
67
-
68
- # Jinja2 template environment
69
- template_dir = Path(__file__).parent / "templates"
70
- self.env = Environment(
71
- loader=FileSystemLoader(template_dir),
72
- autoescape=select_autoescape(["html", "xml"]),
73
- trim_blocks=True,
74
- lstrip_blocks=True,
75
- )
76
-
77
- # Register custom filters for templates
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 ""
80
-
81
- def generate(
82
- self, embed_content: bool = False, edit_mode: bool = False, review_mode: bool = False
83
- ) -> str:
84
- """Generate the complete HTML report using Jinja2 templates.
85
-
86
- Args:
87
- embed_content: If True, embed full requirement content as JSON
88
- edit_mode: If True, include edit mode UI elements
89
- review_mode: If True, include review mode UI and scripts
90
-
91
- Returns:
92
- Complete HTML document as string
93
-
94
- Raises:
95
- jinja2.TemplateError: If template rendering fails
96
- """
97
- context = self._build_render_context(embed_content, edit_mode, review_mode)
98
- template = self.env.get_template("base.html")
99
- return template.render(**context)
100
-
101
- def _count_by_level(self) -> Dict[str, Dict[str, int]]:
102
- """Count requirements by level, with and without deprecated."""
103
- return count_by_level(self.requirements)
104
-
105
- def _count_by_repo(self) -> Dict[str, Dict[str, int]]:
106
- """Count requirements by repo prefix (CORE, CAL, TTN, etc.).
107
-
108
- Returns:
109
- Dict mapping repo prefix to {'active': count, 'all': count}
110
- CORE is used for core repo requirements (no prefix).
111
- """
112
- repo_counts: Dict[str, Dict[str, int]] = {}
113
-
114
- for req in self.requirements.values():
115
- prefix = req.repo_prefix or "CORE" # Use CORE for core repo
116
-
117
- if prefix not in repo_counts:
118
- repo_counts[prefix] = {"active": 0, "all": 0}
119
-
120
- repo_counts[prefix]["all"] += 1
121
- if req.status != "Deprecated":
122
- repo_counts[prefix]["active"] += 1
123
-
124
- return repo_counts
125
-
126
- def _count_impl_files(self) -> int:
127
- """Count total implementation files across all requirements."""
128
- return sum(len(req.implementation_files) for req in self.requirements.values())
129
-
130
- def _find_orphaned_requirements(self) -> List[Requirement]:
131
- """Find requirements with missing parents."""
132
- return find_orphaned_requirements(self.requirements)
133
-
134
- def _calculate_coverage(self, req_id: str) -> dict:
135
- """Calculate coverage for a requirement."""
136
- return calculate_coverage(self.requirements, req_id)
137
-
138
- def _get_implementation_status(self, req_id: str) -> str:
139
- """Get implementation status for a requirement."""
140
- return get_implementation_status(self.requirements, req_id)
141
-
142
- def _load_css(self) -> str:
143
- """Load CSS content from external stylesheet.
144
-
145
- Loads styles from templates/partials/styles.css for embedding
146
- in the HTML output.
147
-
148
- Returns:
149
- CSS content as string, or empty string if file not found.
150
- """
151
- css_path = Path(__file__).parent / "templates" / "partials" / "styles.css"
152
- if css_path.exists():
153
- return css_path.read_text()
154
- return ""
155
-
156
- def _load_js(self) -> str:
157
- """Load JavaScript content from external script file.
158
-
159
- Loads scripts from templates/partials/scripts.js for embedding
160
- in the HTML output.
161
-
162
- Returns:
163
- JavaScript content as string, or empty string if file not found.
164
- """
165
- js_path = Path(__file__).parent / "templates" / "partials" / "scripts.js"
166
- if js_path.exists():
167
- return js_path.read_text()
168
- return ""
169
-
170
- def _load_review_css(self) -> str:
171
- """Load review system CSS.
172
-
173
- Returns:
174
- CSS content as string, or empty string if file not found.
175
- """
176
- css_path = Path(__file__).parent / "templates" / "partials" / "review-styles.css"
177
- if css_path.exists():
178
- return css_path.read_text()
179
- return ""
180
-
181
- def _load_review_js(self) -> str:
182
- """Load review system JavaScript modules.
183
-
184
- Concatenates all review JS modules in the correct dependency order.
185
-
186
- Returns:
187
- JavaScript content as string, or empty string if files not found.
188
- """
189
- review_dir = Path(__file__).parent / "templates" / "partials" / "review"
190
- if not review_dir.exists():
191
- return ""
192
-
193
- # Load modules in dependency order (REQ-d00092)
194
- module_order = [
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)
205
- ]
206
-
207
- js_parts = []
208
- for module_name in module_order:
209
- module_path = review_dir / module_name
210
- if module_path.exists():
211
- js_parts.append(f"// === {module_name} ===")
212
- js_parts.append(module_path.read_text())
213
-
214
- return "\n".join(js_parts)
215
-
216
- def _build_render_context(
217
- self, embed_content: bool = False, edit_mode: bool = False, review_mode: bool = False
218
- ) -> dict:
219
- """Build the template render context.
220
-
221
- Creates a dictionary with all data needed by Jinja2 templates.
222
-
223
- Args:
224
- embed_content: If True, embed full requirement content
225
- edit_mode: If True, include edit mode UI
226
- review_mode: If True, include review mode UI and scripts
227
-
228
- Returns:
229
- Dictionary containing template context variables
230
- """
231
- by_level = self._count_by_level()
232
- by_repo = self._count_by_repo()
233
-
234
- # Collect topics
235
- all_topics = set()
236
- for req in self.requirements.values():
237
- topic = (
238
- req.file_path.stem.split("-", 1)[1]
239
- if "-" in req.file_path.stem
240
- else req.file_path.stem
241
- )
242
- all_topics.add(topic)
243
- sorted_topics = sorted(all_topics)
244
-
245
- # Build requirements HTML using existing method
246
- requirements_html = self._generate_requirements_html(embed_content, edit_mode)
247
-
248
- # Build JSON data for embedded mode
249
- req_json_data = ""
250
- if embed_content:
251
- req_json_data = self._generate_req_json_data()
252
-
253
- return {
254
- # Configuration flags
255
- "embed_content": embed_content,
256
- "edit_mode": edit_mode,
257
- "review_mode": review_mode,
258
- "version": self.VERSION,
259
- # Statistics
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(),
265
- },
266
- # Repo prefix stats (for associated repos like CAL, TTN, etc.)
267
- "repo_stats": by_repo,
268
- # Requirements data
269
- "topics": sorted_topics,
270
- "requirements_html": requirements_html,
271
- "req_json_data": req_json_data,
272
- # Asset content (CSS/JS loaded from external files)
273
- "css": self._load_css(),
274
- "js": self._load_js(),
275
- # Review mode assets (loaded conditionally)
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 "",
279
- # Metadata
280
- "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
281
- "repo_root": str(self.repo_root) if self.repo_root else "",
282
- }
283
-
284
- def _generate_requirements_html(
285
- self, embed_content: bool = False, edit_mode: bool = False
286
- ) -> str:
287
- """Generate the HTML for all requirements.
288
-
289
- This extracts the requirement tree generation logic to be used
290
- by both the legacy _generate_html() method and the template-based
291
- rendering.
292
-
293
- Args:
294
- embed_content: If True, embed full requirement content
295
- edit_mode: If True, include edit mode UI
296
-
297
- Returns:
298
- HTML string with all requirement rows
299
- """
300
- # Build flat list for rendering
301
- flat_list = self._build_flat_requirement_list()
302
-
303
- html_parts = []
304
- for item_data in flat_list:
305
- html_parts.append(
306
- self._format_item_flat_html(
307
- item_data, embed_content=embed_content, edit_mode=edit_mode
308
- )
309
- )
310
-
311
- return "\n".join(html_parts)
312
-
313
- def _generate_legend_html(self) -> str:
314
- """Generate HTML legend section"""
315
- return """
316
- <div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
317
- <h2 style="margin-top: 0;">Legend</h2>
318
- <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px;">
319
- <div>
320
- <h3 style="font-size: 13px; margin-bottom: 8px;">Requirement Status:</h3>
321
- <ul style="list-style: none; padding: 0; font-size: 12px;">
322
- <li style="margin: 4px 0;">✅ Active requirement</li>
323
- <li style="margin: 4px 0;">🚧 Draft requirement</li>
324
- <li style="margin: 4px 0;">⚠️ Deprecated requirement</li>
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>
330
- </ul>
331
- </div>
332
- <div>
333
- <h3 style="font-size: 13px; margin-bottom: 8px;">Traceability:</h3>
334
- <ul style="list-style: none; padding: 0; font-size: 12px;">
335
- <li style="margin: 4px 0;">🔗 Has implementation file(s)</li>
336
- <li style="margin: 4px 0;">○ No implementation found</li>
337
- </ul>
338
- </div>
339
- <div>
340
- <h3 style="font-size: 13px; margin-bottom: 8px;">Implementation Coverage:</h3>
341
- <ul style="list-style: none; padding: 0; font-size: 12px;">
342
- <li style="margin: 4px 0;">● Full coverage</li>
343
- <li style="margin: 4px 0;">◐ Partial coverage</li>
344
- <li style="margin: 4px 0;">○ Unimplemented</li>
345
- </ul>
346
- </div>
347
- </div>
348
- <div style="margin-top: 10px;">
349
- <h3 style="font-size: 13px; margin-bottom: 8px;">Interactive Controls:</h3>
350
- <ul style="list-style: none; padding: 0; font-size: 12px;">
351
- <li style="margin: 4px 0;">▼ Expandable (has child requirements)</li>
352
- <li style="margin: 4px 0;">▶ Collapsed (click to expand)</li>
353
- </ul>
354
- </div>
355
- </div>
356
- """
357
-
358
- def _generate_req_json_data(self) -> str:
359
- """Generate JSON data containing all requirement content for embedded mode"""
360
- req_data = {}
361
- for req_id, req in self.requirements.items():
362
- # Use correct spec path for external vs core repo requirements
363
- if req.external_spec_path:
364
- # External repo: use file:// URL
365
- file_path_url = f"file://{req.external_spec_path}"
366
- else:
367
- # Core repo: use relative path
368
- spec_subpath = "spec/roadmap" if req.is_roadmap else "spec"
369
- file_path_url = f"{self._base_path}{spec_subpath}/{req.file_path.name}"
370
-
371
- req_data[req_id] = {
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
388
- }
389
- json_str = json.dumps(req_data, indent=2)
390
- # Escape </script> to prevent premature closing of the script tag
391
- # This is safe because JSON strings already escape the backslash
392
- json_str = json_str.replace("</script>", "<\\/script>")
393
- return json_str
394
-
395
- def _generate_review_json_data(self) -> str:
396
- """Generate JSON data for review mode initialization.
397
-
398
- Loads existing review data from .reviews/ directory and embeds it
399
- in the HTML for immediate display. The API is still used for updates.
400
- """
401
- review_data = {
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"],
410
- },
411
- "pushOnComment": True,
412
- "autoFetchOnOpen": True,
413
- },
414
- }
415
-
416
- # Load existing review data from .reviews/ directory
417
- if self.repo_root:
418
- reviews_dir = self.repo_root / ".reviews" / "reqs"
419
- if reviews_dir.exists():
420
- for req_dir in reviews_dir.iterdir():
421
- if req_dir.is_dir():
422
- req_id = req_dir.name
423
- # Load threads
424
- threads_file = req_dir / "threads.json"
425
- if threads_file.exists():
426
- try:
427
- with open(threads_file) as f:
428
- threads_data = json.load(f)
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
- )
435
-
436
- # Load flags
437
- flag_file = req_dir / "flag.json"
438
- if flag_file.exists():
439
- try:
440
- with open(flag_file) as f:
441
- flag_data = json.load(f)
442
- review_data["flags"][req_id] = flag_data
443
- except (OSError, json.JSONDecodeError) as e:
444
- print(f"Warning: Could not load {flag_file}: {e}", file=sys.stderr)
445
-
446
- # Load status requests
447
- status_file = req_dir / "status.json"
448
- if status_file.exists():
449
- try:
450
- with open(status_file) as f:
451
- status_data = json.load(f)
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
- )
458
-
459
- json_str = json.dumps(review_data, indent=2)
460
- json_str = json_str.replace("</script>", "<\\/script>")
461
- return json_str
462
-
463
- def _build_flat_requirement_list(self) -> List[dict]:
464
- """Build a flat list of requirements with hierarchy information"""
465
- flat_list = []
466
- self._instance_counter = 0 # Track unique instance IDs
467
- self._visited_req_ids = set() # Track visited requirements to avoid cycles and duplicates
468
-
469
- # Start with all root requirements (those with no implements/parent)
470
- # Root requirements can be PRD, OPS, or DEV - any req that doesn't implement another
471
- root_reqs = [req for req in self.requirements.values() if not req.implements]
472
- root_reqs.sort(key=lambda r: r.id)
473
-
474
- for root_req in root_reqs:
475
- self._add_requirement_and_children(
476
- root_req, flat_list, indent=0, parent_instance_id="", ancestor_path=[]
477
- )
478
-
479
- # Add any orphaned requirements that weren't included in the tree
480
- # (requirements that have implements pointing to non-existent parents)
481
- all_req_ids = set(self.requirements.keys())
482
- included_req_ids = self._visited_req_ids
483
- orphaned_ids = all_req_ids - included_req_ids
484
-
485
- if orphaned_ids:
486
- orphaned_reqs = [self.requirements[rid] for rid in orphaned_ids]
487
- orphaned_reqs.sort(key=lambda r: r.id)
488
- for orphan in orphaned_reqs:
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
- )
497
-
498
- return flat_list
499
-
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
- ):
509
- """Recursively add requirement and its children to flat list
510
-
511
- Args:
512
- req: The requirement to add
513
- flat_list: List to append items to
514
- indent: Current indentation level
515
- parent_instance_id: Instance ID of parent item
516
- ancestor_path: List of requirement IDs in current traversal path (for cycle detection)
517
- is_orphan: Whether this requirement is an orphan (has missing parent)
518
- """
519
- # Cycle detection: check if this requirement is already in our traversal path
520
- if req.id in ancestor_path:
521
- cycle_path = ancestor_path + [req.id]
522
- cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
523
- print(f"⚠️ CYCLE DETECTED in flat list build: {cycle_str}", file=sys.stderr)
524
- return # Don't add cyclic requirement again
525
-
526
- # Track that we've visited this requirement
527
- self._visited_req_ids.add(req.id)
528
-
529
- # Generate unique instance ID for this occurrence
530
- instance_id = f"inst_{self._instance_counter}"
531
- self._instance_counter += 1
532
-
533
- # Find child requirements
534
- children = [r for r in self.requirements.values() if req.id in r.implements]
535
- children.sort(key=lambda r: r.id)
536
-
537
- # Check if this requirement has children (either child reqs or implementation files)
538
- has_children = len(children) > 0 or len(req.implementation_files) > 0
539
-
540
- # Add this requirement
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
- )
551
-
552
- # Add implementation files as child items
553
- for file_path, line_num in req.implementation_files:
554
- impl_instance_id = f"inst_{self._instance_counter}"
555
- self._instance_counter += 1
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
- )
567
-
568
- # Recursively add child requirements (with updated ancestor path for cycle detection)
569
- current_path = ancestor_path + [req.id]
570
- for child in children:
571
- self._add_requirement_and_children(
572
- child, flat_list, indent + 1, instance_id, current_path
573
- )
574
-
575
- def _format_item_flat_html(
576
- self, item_data: dict, embed_content: bool = False, edit_mode: bool = False
577
- ) -> str:
578
- """Format a single item (requirement or implementation file) as flat HTML row
579
-
580
- Args:
581
- item_data: Dictionary containing item data
582
- embed_content: If True, use onclick handlers instead of href links for portability
583
- edit_mode: If True, include edit mode UI elements
584
- """
585
- item_type = item_data.get("item_type", "requirement")
586
-
587
- if item_type == "implementation":
588
- return self._format_impl_file_html(item_data, embed_content, edit_mode)
589
- else:
590
- return self._format_req_html(item_data, embed_content, edit_mode)
591
-
592
- def _format_impl_file_html(
593
- self, item_data: dict, embed_content: bool = False, edit_mode: bool = False
594
- ) -> str:
595
- """Format an implementation file as a child row"""
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"]
601
-
602
- # Create link or onclick handler
603
- if embed_content:
604
- file_url = f"{self._base_path}{file_path}"
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
- )
609
- else:
610
- link = f"{self._base_path}{file_path}#L{line_num}"
611
- file_link = f'<a href="{link}" style="color: #0066cc;">{file_path}:{line_num}</a>'
612
-
613
- # Add VS Code link for opening in editor (always uses vscode:// protocol)
614
- # Note: VS Code links only work on the machine where this file was generated
615
- abs_file_path = self.repo_root / file_path
616
- vscode_url = f"vscode://file/{abs_file_path}:{line_num}"
617
- vscode_link = f'<a href="{vscode_url}" title="Open in VS Code" class="vscode-link">🔧</a>'
618
- file_link = f"{file_link}{vscode_link}"
619
-
620
- # Edit mode destination column (only if edit mode enabled)
621
- edit_column = '<div class="req-destination edit-mode-column"></div>' if edit_mode else ""
622
-
623
- # Build HTML for implementation file row
624
- html = f"""
625
- <div class="req-item impl-file" data-instance-id="{instance_id}" \
626
- data-indent="{indent}" data-parent-instance-id="{parent_instance_id}">
627
- <div class="req-header-container">
628
- <span class="collapse-icon"></span>
629
- <div class="req-content">
630
- <div class="req-id" style="color: #6c757d;">📄</div>
631
- <div class="req-header" style="font-family: 'Consolas', 'Monaco', \
632
- monospace; font-size: 12px;">{file_link}</div>
633
- <div class="req-level"></div>
634
- <div class="req-badges"></div>
635
- <div class="req-coverage"></div>
636
- <div class="req-status"></div>
637
- <div class="req-location"></div>
638
- {edit_column}
639
- </div>
640
- </div>
641
- </div>
642
- """
643
- return html
644
-
645
- def _format_req_html(
646
- self, req_data: dict, embed_content: bool = False, edit_mode: bool = False
647
- ) -> str:
648
- """Format a single requirement as flat HTML row
649
-
650
- Args:
651
- req_data: Dictionary containing requirement data
652
- embed_content: If True, use onclick handlers instead of href links for portability
653
- edit_mode: If True, include edit mode UI elements
654
- """
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"]
660
-
661
- status_class = req.status.lower()
662
- level_class = req.level.lower()
663
-
664
- # Only show collapse icon if there are children
665
- collapse_icon = "▼" if has_children else ""
666
-
667
- # Determine implementation coverage status
668
- impl_status = self._get_implementation_status(req.id)
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"
675
- else: # Unimplemented
676
- coverage_icon = "○" # Empty circle
677
- coverage_title = "Unimplemented"
678
-
679
- # Determine test status
680
- test_badge = ""
681
- if req.test_info:
682
- test_status = req.test_info.test_status
683
- test_count = req.test_info.test_count + req.test_info.manual_test_count
684
-
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
- )
700
- else:
701
- test_badge = (
702
- '<span class="test-badge test-not-tested" ' 'title="No tests implemented">⚡</span>'
703
- )
704
-
705
- # Extract topic from filename
706
- topic = (
707
- req.file_path.stem.split("-", 1)[1] if "-" in req.file_path.stem else req.file_path.stem
708
- )
709
-
710
- # Create link to source file with REQ anchor
711
- # In embedded mode, use onclick to open side panel instead of navigating away
712
- # event.stopPropagation() prevents the parent toggle handler from firing
713
- # Display ID without "REQ-" prefix for cleaner tree view
714
- # Determine the correct spec path (spec/ or spec/roadmap/, or file:// for external)
715
- if req.external_spec_path:
716
- # External repo: use file:// URL
717
- spec_url = f"file://{req.external_spec_path}"
718
- else:
719
- # Core repo: use relative path
720
- spec_subpath = "spec/roadmap" if req.is_roadmap else "spec"
721
- spec_url = f"{self._base_path}{spec_subpath}/{req.file_path.name}"
722
-
723
- # Display filename without .md extension but with repo prefix (e.g., CAL/dev-edc-schema)
724
- file_stem = req.file_path.stem # removes .md extension
725
- display_filename = f"{req.repo_prefix}/{file_stem}" if req.repo_prefix else file_stem
726
-
727
- # Display ID: strip __conflict suffix (warning icon shows conflict status separately)
728
- display_id = req.id
729
- if "__conflict" in req.id:
730
- display_id = req.id.replace("__conflict", "")
731
-
732
- if embed_content:
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
- )
739
- file_line_link = f'<span style="color: inherit;">{display_filename}</span>'
740
- else:
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
- )
749
-
750
- # Determine status indicators using distinctive Unicode symbols
751
- # ★ (star) = NEW, ◆ (diamond) = MODIFIED, ↝ (wave arrow) = MOVED
752
- status_suffix = ""
753
- status_suffix_class = ""
754
- status_title = ""
755
-
756
- is_moved = req.is_moved
757
- is_new_not_moved = req.is_new and not is_moved
758
- is_modified = req.is_modified
759
-
760
- if is_moved and is_modified:
761
- # Moved AND modified - show both indicators
762
- status_suffix = "↝◆"
763
- status_suffix_class = "status-moved-modified"
764
- status_title = "MOVED and MODIFIED"
765
- elif is_moved:
766
- # Just moved (might be in new file)
767
- status_suffix = "↝"
768
- status_suffix_class = "status-moved"
769
- status_title = "MOVED from another file"
770
- elif is_new_not_moved:
771
- # Truly new (in new file, not moved)
772
- status_suffix = "★"
773
- status_suffix_class = "status-new"
774
- status_title = "NEW requirement"
775
- elif is_modified:
776
- # Modified in place
777
- status_suffix = "◆"
778
- status_suffix_class = "status-modified"
779
- status_title = "MODIFIED content"
780
-
781
- # Check if this is a root requirement (no parents)
782
- is_root = not req.implements or len(req.implements) == 0
783
- is_root_attr = 'data-is-root="true"' if is_root else 'data-is-root="false"'
784
- # Two separate modified attributes: uncommitted (since last commit) and branch (vs main)
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
- )
791
-
792
- # Data attribute for has-children (for leaf-only filtering)
793
- has_children_attr = (
794
- 'data-has-children="true"' if has_children else 'data-has-children="false"'
795
- )
796
-
797
- # Data attribute for test status (for test filter)
798
- test_status_value = "not-tested"
799
- if req.test_info:
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"
804
- test_status_attr = f'data-test-status="{test_status_value}"'
805
-
806
- # Data attribute for coverage (for coverage filter)
807
- coverage_value = "none"
808
- if impl_status == "Full":
809
- coverage_value = "full"
810
- elif impl_status == "Partial":
811
- coverage_value = "partial"
812
- coverage_attr = f'data-coverage="{coverage_value}"'
813
-
814
- # Data attribute for roadmap (for roadmap filtering)
815
- roadmap_attr = 'data-roadmap="true"' if req.is_roadmap else 'data-roadmap="false"'
816
-
817
- # Edit mode buttons - only generated if edit_mode is enabled
818
- if edit_mode:
819
- req_id = req.id
820
- file_name = req.file_path.name
821
- if req.is_roadmap:
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
- )
836
- else:
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
- )
851
- else:
852
- edit_buttons = ""
853
-
854
- # Roadmap indicator icon (shown after REQ ID)
855
- roadmap_icon = (
856
- '<span class="roadmap-icon" title="In roadmap">🛤️</span>' if req.is_roadmap else ""
857
- )
858
-
859
- # Conflict indicator icon (shown for roadmap REQs that conflict with existing REQs)
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
- )
870
-
871
- # Cycle indicator icon (shown for REQs involved in dependency cycles)
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
- )
882
-
883
- # Determine item class based on status
884
- item_class = "conflict-item" if req.is_conflict else ("cycle-item" if req.is_cycle else "")
885
-
886
- # Repo prefix for filtering (CORE for core repo, CAL/TTN/etc. for associated repos)
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
- )
915
-
916
- # Build HTML for single flat row with unique instance ID
917
- html = f"""
918
- <div class="req-item {level_class} {deprecated_class} {item_class}" {data_attrs}>
919
- <div class="req-header-container" onclick="toggleRequirement(this)">
920
- <span class="collapse-icon">{collapse_icon}</span>
921
- <div class="req-content">
922
- <div class="req-id">{conflict_icon}{cycle_icon}{req_link}{roadmap_icon}</div>
923
- <div class="req-header">{req.title}</div>
924
- <div class="req-level">{req.level}</div>
925
- <div class="req-badges">
926
- {status_badges_html}
927
- </div>
928
- <div class="req-coverage" title="{coverage_title}">{coverage_icon}</div>
929
- <div class="req-status">{test_badge}</div>
930
- <div class="req-location">{file_line_link}</div>
931
- {edit_column_html}
932
- </div>
933
- </div>
934
- </div>
935
- """
936
- return html
937
-
938
- def _format_req_tree_html(
939
- self, req: Requirement, ancestor_path: list[str] | None = None
940
- ) -> str:
941
- """Format requirement and children as HTML tree (legacy non-collapsible).
942
-
943
- Args:
944
- req: The requirement to format
945
- ancestor_path: List of requirement IDs in the current traversal path
946
- (for cycle detection)
947
-
948
- Returns:
949
- Formatted HTML string
950
- """
951
- if ancestor_path is None:
952
- ancestor_path = []
953
-
954
- # Cycle detection: check if this requirement is already in our traversal path
955
- if req.id in ancestor_path:
956
- cycle_path = ancestor_path + [req.id]
957
- cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
958
- print(f"⚠️ CYCLE DETECTED: {cycle_str}", file=sys.stderr)
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
- )
964
-
965
- # Safety depth limit
966
- MAX_DEPTH = 50
967
- if len(ancestor_path) > MAX_DEPTH:
968
- print(f"⚠️ MAX DEPTH ({MAX_DEPTH}) exceeded at REQ-{req.id}", file=sys.stderr)
969
- return (
970
- f' <div class="req-item depth-exceeded">'
971
- f"<strong>⚠️ MAX DEPTH EXCEEDED:</strong> REQ-{req.id}</div>\n"
972
- )
973
-
974
- status_class = req.status.lower()
975
- level_class = req.level.lower()
976
-
977
- html = f"""
978
- <div class="req-item {level_class} {status_class if req.status == 'Deprecated' else ''}">
979
- <div class="req-header">
980
- {req.id}: {req.title}
981
- </div>
982
- <div class="req-meta">
983
- <span class="status-badge status-{status_class}">{req.status}</span>
984
- Level: {req.level} |
985
- File: {req.display_filename}:{req.line_number}
986
- </div>
987
- """
988
-
989
- # Find children
990
- children = [r for r in self.requirements.values() if req.id in r.implements]
991
- children.sort(key=lambda r: r.id)
992
-
993
- if children:
994
- # Add current req to path before recursing into children
995
- current_path = ancestor_path + [req.id]
996
- html += ' <div class="child-reqs">\n'
997
- for child in children:
998
- html += self._format_req_tree_html(child, current_path)
999
- html += " </div>\n"
1000
-
1001
- html += " </div>\n"
1002
- return html
1003
-
1004
- def _format_req_tree_html_collapsible(
1005
- self, req: Requirement, ancestor_path: list[str] | None = None
1006
- ) -> str:
1007
- """Format requirement and children as collapsible HTML tree.
1008
-
1009
- Args:
1010
- req: The requirement to format
1011
- ancestor_path: List of requirement IDs in the current traversal path
1012
- (for cycle detection)
1013
-
1014
- Returns:
1015
- Formatted HTML string
1016
- """
1017
- if ancestor_path is None:
1018
- ancestor_path = []
1019
-
1020
- # Cycle detection: check if this requirement is already in our traversal path
1021
- if req.id in ancestor_path:
1022
- cycle_path = ancestor_path + [req.id]
1023
- cycle_str = " -> ".join([f"REQ-{rid}" for rid in cycle_path])
1024
- print(f"⚠️ CYCLE DETECTED: {cycle_str}", file=sys.stderr)
1025
- return f"""
1026
- <div class="req-item cycle-detected" data-req-id="{req.id}">
1027
- <div class="req-header-container">
1028
- <span class="collapse-icon"></span>
1029
- <div class="req-content">
1030
- <div class="req-id">⚠️ CYCLE</div>
1031
- <div class="req-header">Circular dependency detected at REQ-{req.id}</div>
1032
- <div class="req-level">ERROR</div>
1033
- <div class="req-badges">
1034
- <span class="status-badge status-deprecated">Cycle</span>
1035
- </div>
1036
- <div class="req-location">Path: {cycle_str}</div>
1037
- </div>
1038
- </div>
1039
- </div>
1040
- """
1041
-
1042
- # Safety depth limit
1043
- MAX_DEPTH = 50
1044
- if len(ancestor_path) > MAX_DEPTH:
1045
- print(f"⚠️ MAX DEPTH ({MAX_DEPTH}) exceeded at REQ-{req.id}", file=sys.stderr)
1046
- return f"""
1047
- <div class="req-item depth-exceeded" data-req-id="{req.id}">
1048
- <div class="req-header-container">
1049
- <span class="collapse-icon"></span>
1050
- <div class="req-content">
1051
- <div class="req-id">⚠️ DEPTH</div>
1052
- <div class="req-header">Maximum depth exceeded at REQ-{req.id}</div>
1053
- <div class="req-level">ERROR</div>
1054
- <div class="req-badges">
1055
- <span class="status-badge status-deprecated">Overflow</span>
1056
- </div>
1057
- </div>
1058
- </div>
1059
- </div>
1060
- """
1061
-
1062
- status_class = req.status.lower()
1063
- level_class = req.level.lower()
1064
-
1065
- # Find children
1066
- children = [r for r in self.requirements.values() if req.id in r.implements]
1067
- children.sort(key=lambda r: r.id)
1068
-
1069
- # Only show collapse icon if there are children
1070
- collapse_icon = "▼" if children else ""
1071
-
1072
- # Determine test status
1073
- test_badge = ""
1074
- if req.test_info:
1075
- test_status = req.test_info.test_status
1076
- test_count = req.test_info.test_count + req.test_info.manual_test_count
1077
-
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
- )
1093
- else:
1094
- test_badge = (
1095
- '<span class="test-badge test-not-tested" ' 'title="No tests implemented">⚡</span>'
1096
- )
1097
-
1098
- # Extract topic from filename (e.g., prd-security.md -> security)
1099
- topic = (
1100
- req.file_path.stem.split("-", 1)[1] if "-" in req.file_path.stem else req.file_path.stem
1101
- )
1102
-
1103
- # Repo prefix for filtering (CORE for core repo, CAL/TTN/etc. for associated repos)
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
- )
1113
-
1114
- html = f"""
1115
- <div class="req-item {level_class} {deprecated_class}" {data_attrs}>
1116
- <div class="req-header-container" onclick="toggleRequirement(this)">
1117
- <span class="collapse-icon">{collapse_icon}</span>
1118
- <div class="req-content">
1119
- <div class="req-id">REQ-{req.id}</div>
1120
- <div class="req-header">{req.title}</div>
1121
- <div class="req-level">{req.level}</div>
1122
- <div class="req-badges">
1123
- <span class="status-badge status-{status_class}">{req.status}</span>
1124
- </div>
1125
- <div class="req-status">{test_badge}</div>
1126
- <div class="req-location">{req.display_filename}:{req.line_number}</div>
1127
- </div>
1128
- </div>
1129
- """
1130
-
1131
- if children:
1132
- # Add current req to path before recursing into children
1133
- current_path = ancestor_path + [req.id]
1134
- html += ' <div class="child-reqs">\n'
1135
- for child in children:
1136
- html += self._format_req_tree_html_collapsible(child, current_path)
1137
- html += " </div>\n"
1138
-
1139
- html += " </div>\n"
1140
- return html