elspais 0.11.1__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 (148) hide show
  1. elspais/__init__.py +2 -11
  2. elspais/{sponsors/__init__.py → associates.py} +102 -58
  3. elspais/cli.py +395 -79
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +121 -173
  6. elspais/commands/changed.py +15 -30
  7. elspais/commands/config_cmd.py +13 -16
  8. elspais/commands/edit.py +60 -44
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +167 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -114
  13. elspais/commands/init.py +103 -26
  14. elspais/commands/reformat_cmd.py +41 -444
  15. elspais/commands/rules_cmd.py +7 -3
  16. elspais/commands/trace.py +444 -321
  17. elspais/commands/validate.py +195 -415
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -3
  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 +47 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +2016 -247
  61. elspais/testing/__init__.py +4 -4
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/result_parser.py +25 -21
  65. elspais/testing/scanner.py +301 -12
  66. elspais/utilities/__init__.py +1 -0
  67. elspais/utilities/docs_loader.py +115 -0
  68. elspais/utilities/git.py +607 -0
  69. elspais/{core → utilities}/hasher.py +8 -22
  70. elspais/utilities/md_renderer.py +189 -0
  71. elspais/{core → utilities}/patterns.py +58 -57
  72. elspais/utilities/reference_config.py +626 -0
  73. elspais/validation/__init__.py +19 -0
  74. elspais/validation/format.py +264 -0
  75. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  76. elspais-0.43.5.dist-info/RECORD +80 -0
  77. elspais/config/defaults.py +0 -173
  78. elspais/config/loader.py +0 -494
  79. elspais/core/__init__.py +0 -21
  80. elspais/core/git.py +0 -352
  81. elspais/core/models.py +0 -320
  82. elspais/core/parser.py +0 -640
  83. elspais/core/rules.py +0 -514
  84. elspais/mcp/context.py +0 -171
  85. elspais/mcp/serializers.py +0 -112
  86. elspais/reformat/__init__.py +0 -50
  87. elspais/reformat/detector.py +0 -119
  88. elspais/reformat/hierarchy.py +0 -246
  89. elspais/reformat/line_breaks.py +0 -220
  90. elspais/reformat/prompts.py +0 -123
  91. elspais/reformat/transformer.py +0 -264
  92. elspais/trace_view/__init__.py +0 -54
  93. elspais/trace_view/coverage.py +0 -183
  94. elspais/trace_view/generators/__init__.py +0 -12
  95. elspais/trace_view/generators/base.py +0 -329
  96. elspais/trace_view/generators/csv.py +0 -122
  97. elspais/trace_view/generators/markdown.py +0 -175
  98. elspais/trace_view/html/__init__.py +0 -31
  99. elspais/trace_view/html/generator.py +0 -1006
  100. elspais/trace_view/html/templates/base.html +0 -283
  101. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  102. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  103. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  104. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  105. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  106. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  107. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  108. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  109. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  110. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  111. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  112. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  113. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  114. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  115. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  116. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  117. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  118. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  119. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  120. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  121. elspais/trace_view/models.py +0 -353
  122. elspais/trace_view/review/__init__.py +0 -60
  123. elspais/trace_view/review/branches.py +0 -1149
  124. elspais/trace_view/review/models.py +0 -1205
  125. elspais/trace_view/review/position.py +0 -609
  126. elspais/trace_view/review/server.py +0 -1056
  127. elspais/trace_view/review/status.py +0 -470
  128. elspais/trace_view/review/storage.py +0 -1367
  129. elspais/trace_view/scanning.py +0 -213
  130. elspais/trace_view/specs/README.md +0 -84
  131. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  132. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  133. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  134. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  135. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  136. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  137. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  138. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  139. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  140. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  141. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  142. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  143. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  144. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  145. elspais-0.11.1.dist-info/RECORD +0 -101
  146. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  147. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  148. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/commands/trace.py CHANGED
@@ -2,361 +2,484 @@
2
2
  """
3
3
  elspais.commands.trace - Generate traceability matrix command.
4
4
 
5
- Supports both basic matrix generation and enhanced trace-view features.
5
+ Uses the graph-based system to generate traceability reports in various formats.
6
+ Commands only work with graph data (zero file I/O for reading requirements).
7
+
8
+ OUTPUT FORMATS:
9
+ - markdown: Table with columns based on report preset
10
+ - csv: Same columns, comma-separated with proper escaping
11
+ - html: Basic styled HTML table
12
+ - json: Full requirement data including body, assertions, hash, file_path
13
+ - both: Generates both markdown and csv (legacy mode)
14
+
15
+ REPORT PRESETS (--report):
16
+ - minimal: ID, Title, Status only (quick overview)
17
+ - standard: ID, Title, Level, Status, Implements (default)
18
+ - full: All fields including Body, Assertions, Hash, Code/Test refs
19
+
20
+ INTERACTIVE VIEW (--view):
21
+ - Uses elspais.html.HTMLGenerator
22
+ - Generates interactive HTML with collapsible hierarchy
23
+ - Default output: traceability_view.html
6
24
  """
7
25
 
26
+ from __future__ import annotations
27
+
8
28
  import argparse
29
+ import json
9
30
  import sys
31
+ from dataclasses import dataclass
10
32
  from pathlib import Path
11
- from typing import Dict, List
12
-
13
- from elspais.config.defaults import DEFAULT_CONFIG
14
- from elspais.config.loader import find_config_file, get_spec_directories, load_config
15
- from elspais.core.models import Requirement
16
- from elspais.core.parser import RequirementParser
17
- from elspais.core.patterns import PatternConfig
33
+ from typing import TYPE_CHECKING, Iterator
34
+
35
+ if TYPE_CHECKING:
36
+ from elspais.graph.builder import TraceGraph
37
+
38
+ from elspais.graph import NodeKind
39
+
40
+
41
+ @dataclass
42
+ class ReportPreset:
43
+ """Configuration for a report preset."""
44
+
45
+ name: str
46
+ columns: list[str]
47
+ include_body: bool = False
48
+ include_assertions: bool = False
49
+ include_code_refs: bool = False
50
+ include_test_refs: bool = False
51
+
52
+
53
+ # Define report presets
54
+ REPORT_PRESETS = {
55
+ "minimal": ReportPreset(
56
+ name="minimal",
57
+ columns=["id", "title", "status"],
58
+ ),
59
+ "standard": ReportPreset(
60
+ name="standard",
61
+ columns=["id", "title", "level", "status", "implements"],
62
+ ),
63
+ "full": ReportPreset(
64
+ name="full",
65
+ columns=["id", "title", "level", "status", "implements", "hash", "file"],
66
+ include_body=True,
67
+ include_assertions=True,
68
+ include_code_refs=True,
69
+ include_test_refs=True,
70
+ ),
71
+ }
72
+
73
+ DEFAULT_PRESET = "standard"
74
+
75
+
76
+ def _get_node_data(node, graph: TraceGraph) -> dict:
77
+ """Extract data from a node for use in formatters."""
78
+ # Get implements IDs via parent iteration
79
+ impl_ids = []
80
+ for parent in node.iter_parents():
81
+ if parent.kind == NodeKind.REQUIREMENT:
82
+ impl_ids.append(parent.id)
83
+
84
+ # Get code references (CODE nodes that implement this requirement)
85
+ code_refs = []
86
+ for child in node.iter_children():
87
+ if child.kind == NodeKind.CODE:
88
+ code_refs.append(child.id)
89
+
90
+ # Get test references (TEST nodes that validate this requirement)
91
+ test_refs = []
92
+ for child in node.iter_children():
93
+ if child.kind == NodeKind.TEST:
94
+ test_refs.append(child.id)
95
+
96
+ # Get assertions
97
+ assertions = []
98
+ for child in node.iter_children():
99
+ if child.kind == NodeKind.ASSERTION:
100
+ assertions.append(
101
+ {"label": child.get_field("label", ""), "text": child.get_label() or ""}
102
+ )
18
103
 
104
+ return {
105
+ "id": node.id,
106
+ "title": node.get_label() or "",
107
+ "level": node.level or "",
108
+ "status": node.status or "",
109
+ "implements": impl_ids,
110
+ "hash": node.hash or "",
111
+ "file": node.source.path if node.source else "",
112
+ "body": node.get_field("body", "") or "",
113
+ "assertions": assertions,
114
+ "code_refs": code_refs,
115
+ "test_refs": test_refs,
116
+ }
117
+
118
+
119
+ def format_markdown(graph: TraceGraph, preset: ReportPreset | None = None) -> Iterator[str]:
120
+ """Generate markdown table. Streams one node at a time."""
121
+ if preset is None:
122
+ preset = REPORT_PRESETS[DEFAULT_PRESET]
123
+
124
+ yield "# Traceability Matrix"
125
+ yield ""
126
+
127
+ # Build header based on preset columns
128
+ column_headers = {
129
+ "id": "ID",
130
+ "title": "Title",
131
+ "level": "Level",
132
+ "status": "Status",
133
+ "implements": "Implements",
134
+ "hash": "Hash",
135
+ "file": "File",
136
+ }
137
+ headers = [column_headers.get(col, col.title()) for col in preset.columns]
138
+ yield "| " + " | ".join(headers) + " |"
139
+ yield "|" + "|".join(["----"] * len(headers)) + "|"
140
+
141
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
142
+ data = _get_node_data(node, graph)
143
+ row_values = []
144
+ for col in preset.columns:
145
+ if col == "implements":
146
+ row_values.append(", ".join(data["implements"]) or "-")
147
+ else:
148
+ row_values.append(str(data.get(col, "")))
149
+ yield "| " + " | ".join(row_values) + " |"
150
+
151
+ # For full preset, add body and assertions after the row
152
+ if preset.include_body and data["body"]:
153
+ yield ""
154
+ yield "<details><summary>Body</summary>"
155
+ yield ""
156
+ yield data["body"]
157
+ yield ""
158
+ yield "</details>"
159
+
160
+ if preset.include_assertions and data["assertions"]:
161
+ yield ""
162
+ yield f"<details><summary>Assertions ({len(data['assertions'])})</summary>"
163
+ yield ""
164
+ for a in data["assertions"]:
165
+ yield f"- **{a['label']}**: {a['text']}"
166
+ yield ""
167
+ yield "</details>"
168
+
169
+ if preset.include_code_refs and data["code_refs"]:
170
+ yield ""
171
+ yield f"<details><summary>Code Refs ({len(data['code_refs'])})</summary>"
172
+ yield ""
173
+ for ref in data["code_refs"]:
174
+ yield f"- `{ref}`"
175
+ yield ""
176
+ yield "</details>"
177
+
178
+ if preset.include_test_refs and data["test_refs"]:
179
+ yield ""
180
+ yield f"<details><summary>Test Refs ({len(data['test_refs'])})</summary>"
181
+ yield ""
182
+ for ref in data["test_refs"]:
183
+ yield f"- `{ref}`"
184
+ yield ""
185
+ yield "</details>"
186
+
187
+
188
+ def format_csv(graph: TraceGraph, preset: ReportPreset | None = None) -> Iterator[str]:
189
+ """Generate CSV. Streams one node at a time."""
190
+ if preset is None:
191
+ preset = REPORT_PRESETS[DEFAULT_PRESET]
192
+
193
+ def escape(s: str) -> str:
194
+ if "," in s or '"' in s or "\n" in s:
195
+ return '"' + s.replace('"', '""') + '"'
196
+ return s
197
+
198
+ # Build header based on preset columns
199
+ base_columns = list(preset.columns)
200
+ extra_columns = []
201
+ if preset.include_assertions:
202
+ extra_columns.append("assertions")
203
+ if preset.include_code_refs:
204
+ extra_columns.append("code_refs")
205
+ if preset.include_test_refs:
206
+ extra_columns.append("test_refs")
207
+
208
+ yield ",".join(base_columns + extra_columns)
209
+
210
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
211
+ data = _get_node_data(node, graph)
212
+ row_values = []
213
+ for col in base_columns:
214
+ if col == "implements":
215
+ row_values.append(escape(";".join(data["implements"])))
216
+ else:
217
+ row_values.append(escape(str(data.get(col, ""))))
218
+
219
+ # Add extra columns for full preset
220
+ if preset.include_assertions:
221
+ assertions_str = "; ".join(f"{a['label']}: {a['text']}" for a in data["assertions"])
222
+ row_values.append(escape(assertions_str))
223
+ if preset.include_code_refs:
224
+ row_values.append(escape(";".join(data["code_refs"])))
225
+ if preset.include_test_refs:
226
+ row_values.append(escape(";".join(data["test_refs"])))
227
+
228
+ yield ",".join(row_values)
229
+
230
+
231
+ def format_html(graph: TraceGraph, preset: ReportPreset | None = None) -> Iterator[str]:
232
+ """Generate basic HTML table. Streams one node at a time."""
233
+ if preset is None:
234
+ preset = REPORT_PRESETS[DEFAULT_PRESET]
235
+
236
+ def escape_html(s: str) -> str:
237
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
238
+
239
+ yield "<!DOCTYPE html>"
240
+ yield "<html><head><style>"
241
+ yield "table { border-collapse: collapse; width: 100%; }"
242
+ yield "th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }"
243
+ yield "th { background-color: #4CAF50; color: white; }"
244
+ yield "tr:nth-child(even) { background-color: #f2f2f2; }"
245
+ yield ".assertions, .refs { font-size: 0.9em; color: #666; }"
246
+ yield ".assertion-label { font-weight: bold; }"
247
+ yield "details { margin: 5px 0; }"
248
+ yield "summary { cursor: pointer; color: #4CAF50; }"
249
+ yield "</style></head><body>"
250
+ yield "<h1>Traceability Matrix</h1>"
251
+
252
+ # Build header based on preset columns
253
+ column_headers = {
254
+ "id": "ID",
255
+ "title": "Title",
256
+ "level": "Level",
257
+ "status": "Status",
258
+ "implements": "Implements",
259
+ "hash": "Hash",
260
+ "file": "File",
261
+ }
262
+ headers = [column_headers.get(col, col.title()) for col in preset.columns]
263
+
264
+ # Add extra columns for full preset
265
+ if preset.include_assertions:
266
+ headers.append("Assertions")
267
+ if preset.include_code_refs:
268
+ headers.append("Code Refs")
269
+ if preset.include_test_refs:
270
+ headers.append("Test Refs")
271
+
272
+ yield "<table>"
273
+ yield "<tr>" + "".join(f"<th>{h}</th>" for h in headers) + "</tr>"
274
+
275
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
276
+ data = _get_node_data(node, graph)
277
+ cells = []
278
+ for col in preset.columns:
279
+ if col == "implements":
280
+ impl_str = ", ".join(data["implements"]) or "-"
281
+ cells.append(f"<td>{escape_html(impl_str)}</td>")
282
+ elif col == "title":
283
+ cells.append(f"<td>{escape_html(data['title'])}</td>")
284
+ else:
285
+ cells.append(f"<td>{escape_html(str(data.get(col, '')))}</td>")
286
+
287
+ # Add extra columns for full preset
288
+ if preset.include_assertions:
289
+ if data["assertions"]:
290
+ assertion_html = "<br>".join(
291
+ f"<span class='assertion-label'>{a['label']}:</span> {escape_html(a['text'])}"
292
+ for a in data["assertions"]
293
+ )
294
+ cells.append(f"<td class='assertions'>{assertion_html}</td>")
295
+ else:
296
+ cells.append("<td>-</td>")
19
297
 
20
- def run(args: argparse.Namespace) -> int:
21
- """Run the trace command.
298
+ if preset.include_code_refs:
299
+ if data["code_refs"]:
300
+ refs_html = "<br>".join(f"<code>{escape_html(r)}</code>" for r in data["code_refs"])
301
+ cells.append(f"<td class='refs'>{refs_html}</td>")
302
+ else:
303
+ cells.append("<td>-</td>")
22
304
 
23
- REQ-int-d00003-C: Existing elspais trace --format html behavior SHALL be preserved.
24
- """
25
- # Check if enhanced trace-view features are requested
26
- use_trace_view = (
27
- getattr(args, 'view', False) or
28
- getattr(args, 'embed_content', False) or
29
- getattr(args, 'edit_mode', False) or
30
- getattr(args, 'review_mode', False) or
31
- getattr(args, 'server', False)
32
- )
305
+ if preset.include_test_refs:
306
+ if data["test_refs"]:
307
+ refs_html = "<br>".join(f"<code>{escape_html(r)}</code>" for r in data["test_refs"])
308
+ cells.append(f"<td class='refs'>{refs_html}</td>")
309
+ else:
310
+ cells.append("<td>-</td>")
33
311
 
34
- if use_trace_view:
35
- return run_trace_view(args)
312
+ yield f"<tr>{''.join(cells)}</tr>"
36
313
 
37
- # Original basic trace functionality
38
- return run_basic_trace(args)
314
+ yield "</table></body></html>"
39
315
 
40
316
 
41
- def run_basic_trace(args: argparse.Namespace) -> int:
42
- """Run basic trace matrix generation (original behavior)."""
43
- # Load configuration
44
- config_path = args.config or find_config_file(Path.cwd())
45
- if config_path and config_path.exists():
46
- config = load_config(config_path)
47
- else:
48
- config = DEFAULT_CONFIG
317
+ def format_json(graph: TraceGraph, preset: ReportPreset | None = None) -> Iterator[str]:
318
+ """Generate JSON array. Streams one node at a time."""
319
+ if preset is None:
320
+ preset = REPORT_PRESETS[DEFAULT_PRESET]
49
321
 
50
- # Get spec directories
51
- spec_dirs = get_spec_directories(args.spec_dir, config)
52
- if not spec_dirs:
53
- print("Error: No spec directories found", file=sys.stderr)
54
- return 1
322
+ yield "["
323
+ first = True
324
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
325
+ if not first:
326
+ yield ","
327
+ first = False
55
328
 
56
- # Parse requirements
57
- pattern_config = PatternConfig.from_dict(config.get("patterns", {}))
58
- spec_config = config.get("spec", {})
59
- no_reference_values = spec_config.get("no_reference_values")
60
- skip_files = spec_config.get("skip_files", [])
61
- parser = RequirementParser(pattern_config, no_reference_values=no_reference_values)
62
- requirements = parser.parse_directories(spec_dirs, skip_files=skip_files)
329
+ data = _get_node_data(node, graph)
63
330
 
64
- if not requirements:
65
- print("No requirements found.")
66
- return 1
67
-
68
- # Determine output format
69
- output_format = args.format
70
-
71
- # Generate output
72
- if output_format in ["markdown", "both"]:
73
- md_output = generate_markdown_matrix(requirements)
74
- if args.output:
75
- if output_format == "markdown":
76
- output_path = args.output
331
+ # Build node dict based on preset columns
332
+ node_dict: dict = {}
333
+ for col in preset.columns:
334
+ if col == "file":
335
+ node_dict["source"] = {
336
+ "path": node.source.path if node.source else None,
337
+ "line": node.source.line if node.source else None,
338
+ }
77
339
  else:
78
- output_path = args.output.with_suffix(".md")
79
- else:
80
- output_path = Path("traceability.md")
81
- output_path.write_text(md_output)
82
- print(f"Generated: {output_path}")
340
+ node_dict[col] = data.get(col)
83
341
 
84
- if output_format in ["html", "both"]:
85
- html_output = generate_html_matrix(requirements)
86
- if args.output:
87
- if output_format == "html":
88
- output_path = args.output
89
- else:
90
- output_path = args.output.with_suffix(".html")
91
- else:
92
- output_path = Path("traceability.html")
93
- output_path.write_text(html_output)
94
- print(f"Generated: {output_path}")
342
+ # Add extra fields for full preset
343
+ if preset.include_body:
344
+ node_dict["body"] = data["body"]
345
+ if preset.include_assertions:
346
+ node_dict["assertions"] = data["assertions"]
347
+ if preset.include_code_refs:
348
+ node_dict["code_refs"] = data["code_refs"]
349
+ if preset.include_test_refs:
350
+ node_dict["test_refs"] = data["test_refs"]
95
351
 
96
- if output_format == "csv":
97
- csv_output = generate_csv_matrix(requirements)
98
- output_path = args.output or Path("traceability.csv")
99
- output_path.write_text(csv_output)
100
- print(f"Generated: {output_path}")
352
+ yield json.dumps(node_dict, indent=2)
353
+ yield "]"
101
354
 
102
- return 0
103
355
 
104
-
105
- def run_trace_view(args: argparse.Namespace) -> int:
106
- """Run enhanced trace-view features.
107
-
108
- REQ-int-d00003-A: Trace-view features SHALL be accessible via elspais trace command.
109
- REQ-int-d00003-B: New flags SHALL include: --view, --embed-content, --edit-mode,
110
- --review-mode, --server.
111
- """
112
- # Check if starting review server
113
- if args.server:
114
- return run_review_server(args)
115
-
116
- # Import trace_view (requires jinja2)
356
+ def format_view(graph: TraceGraph, embed_content: bool = False, base_path: str = "") -> str:
357
+ """Generate interactive HTML via HTMLGenerator."""
117
358
  try:
118
- from elspais.trace_view import TraceViewGenerator
119
- except ImportError as e:
120
- print("Error: trace-view features require additional dependencies.", file=sys.stderr)
121
- print("Install with: pip install elspais[trace-view]", file=sys.stderr)
122
- if args.verbose if hasattr(args, 'verbose') else False:
123
- print(f"Import error: {e}", file=sys.stderr)
124
- return 1
125
-
126
- # Load configuration
127
- config_path = args.config or find_config_file(Path.cwd())
128
- if config_path and config_path.exists():
129
- config = load_config(config_path)
130
- else:
131
- config = DEFAULT_CONFIG
132
-
133
- # Determine spec directory
134
- spec_dir = args.spec_dir
135
- if not spec_dir:
136
- spec_dirs = get_spec_directories(None, config)
137
- spec_dir = spec_dirs[0] if spec_dirs else Path.cwd() / "spec"
138
-
139
- repo_root = spec_dir.parent if spec_dir.name == "spec" else spec_dir.parent.parent
140
-
141
- # Get implementation directories from config
142
- impl_dirs = []
143
- dirs_config = config.get("directories", {})
144
- code_dirs = dirs_config.get("code", [])
145
- for code_dir in code_dirs:
146
- impl_path = repo_root / code_dir
147
- if impl_path.exists():
148
- impl_dirs.append(impl_path)
149
-
150
- # Create generator
151
- generator = TraceViewGenerator(
152
- spec_dir=spec_dir,
153
- impl_dirs=impl_dirs,
154
- sponsor=getattr(args, 'sponsor', None),
155
- mode=getattr(args, 'mode', 'core'),
156
- repo_root=repo_root,
157
- config=config,
158
- )
159
-
160
- # Determine output format
161
- # --view implies HTML
162
- output_format = "html" if args.view else args.format
163
- if output_format == "both":
164
- output_format = "html"
165
-
166
- # Determine output file
167
- output_file = args.output
168
- if output_file is None:
169
- if output_format == "html":
170
- output_file = Path("traceability_matrix.html")
171
- elif output_format == "csv":
172
- output_file = Path("traceability_matrix.csv")
173
- else:
174
- output_file = Path("traceability_matrix.md")
175
-
176
- # Generate
177
- quiet = getattr(args, 'quiet', False)
178
- generator.generate(
179
- format=output_format,
180
- output_file=output_file,
181
- embed_content=getattr(args, 'embed_content', False),
182
- edit_mode=getattr(args, 'edit_mode', False),
183
- review_mode=getattr(args, 'review_mode', False),
184
- quiet=quiet,
185
- )
186
-
187
- return 0
359
+ from elspais.html import HTMLGenerator
360
+ except ImportError as err:
361
+ raise ImportError(
362
+ "HTMLGenerator requires the trace-view extra. "
363
+ "Install with: pip install elspais[trace-view]"
364
+ ) from err
365
+ generator = HTMLGenerator(graph, base_path=base_path)
366
+ return generator.generate(embed_content=embed_content)
188
367
 
189
368
 
190
- def run_review_server(args: argparse.Namespace) -> int:
191
- """Start the review server.
369
+ def run(args: argparse.Namespace) -> int:
370
+ """Run the trace command.
192
371
 
193
- REQ-int-d00002-C: Review server SHALL require flask, flask-cors via
194
- elspais[trace-review] extra.
372
+ Uses graph factory to build TraceGraph, then streams output in requested format.
195
373
  """
196
- try:
197
- from elspais.trace_view.review import create_app, FLASK_AVAILABLE
198
- except ImportError:
199
- print("Error: Review server requires additional dependencies.", file=sys.stderr)
200
- print("Install with: pip install elspais[trace-review]", file=sys.stderr)
201
- return 1
202
-
203
- if not FLASK_AVAILABLE:
204
- print("Error: Review server requires Flask.", file=sys.stderr)
205
- print("Install with: pip install elspais[trace-review]", file=sys.stderr)
206
- return 1
207
-
208
- # Determine repo root
209
- spec_dir = args.spec_dir
210
- if spec_dir:
211
- repo_root = spec_dir.parent if spec_dir.name == "spec" else spec_dir.parent.parent
374
+ # Handle not-implemented features
375
+ for flag in ("edit_mode", "review_mode", "server"):
376
+ if getattr(args, flag, False):
377
+ print(f"Error: --{flag.replace('_', '-')} not yet implemented", file=sys.stderr)
378
+ return 1
379
+
380
+ # Parse --report preset
381
+ report_name = getattr(args, "report", None)
382
+ if report_name:
383
+ if report_name not in REPORT_PRESETS:
384
+ available = ", ".join(REPORT_PRESETS.keys())
385
+ print(f"Error: Unknown report preset '{report_name}'", file=sys.stderr)
386
+ print(f"Available presets: {available}", file=sys.stderr)
387
+ return 1
388
+ preset = REPORT_PRESETS[report_name]
212
389
  else:
213
- repo_root = Path.cwd()
214
-
215
- port = getattr(args, 'port', 8080)
390
+ preset = REPORT_PRESETS[DEFAULT_PRESET]
216
391
 
217
- print(f"""
218
- ======================================
219
- elspais Review Server
220
- ======================================
392
+ # Build graph using factory
393
+ from elspais.graph.factory import build_graph
221
394
 
222
- Repository: {repo_root}
223
- Server: http://localhost:{port}
395
+ spec_dir = getattr(args, "spec_dir", None)
396
+ config_path = getattr(args, "config", None)
224
397
 
225
- Press Ctrl+C to stop
226
- """)
227
-
228
- app = create_app(repo_root, auto_sync=True)
229
- try:
230
- app.run(host='0.0.0.0', port=port, debug=False)
231
- except KeyboardInterrupt:
232
- print("\nServer stopped.")
233
-
234
- return 0
235
-
236
-
237
- def generate_markdown_matrix(requirements: Dict[str, Requirement]) -> str:
238
- """Generate Markdown traceability matrix."""
239
- lines = ["# Traceability Matrix", "", "## Requirements Hierarchy", ""]
240
-
241
- # Group by type
242
- prd_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["PRD", "PRODUCT"]}
243
- ops_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["OPS", "OPERATIONS"]}
244
- dev_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["DEV", "DEVELOPMENT"]}
245
-
246
- # PRD table
247
- if prd_reqs:
248
- lines.extend(["### Product Requirements", ""])
249
- lines.append("| ID | Title | Status | Implemented By |")
250
- lines.append("|---|---|---|---|")
251
- for req_id, req in sorted(prd_reqs.items()):
252
- impl_by = find_implementers(req_id, requirements)
253
- impl_str = ", ".join(impl_by) if impl_by else "-"
254
- lines.append(f"| {req_id} | {req.title} | {req.status} | {impl_str} |")
255
- lines.append("")
256
-
257
- # OPS table
258
- if ops_reqs:
259
- lines.extend(["### Operations Requirements", ""])
260
- lines.append("| ID | Title | Implements | Status |")
261
- lines.append("|---|---|---|---|")
262
- for req_id, req in sorted(ops_reqs.items()):
263
- impl_str = ", ".join(req.implements) if req.implements else "-"
264
- lines.append(f"| {req_id} | {req.title} | {impl_str} | {req.status} |")
265
- lines.append("")
266
-
267
- # DEV table
268
- if dev_reqs:
269
- lines.extend(["### Development Requirements", ""])
270
- lines.append("| ID | Title | Implements | Status |")
271
- lines.append("|---|---|---|---|")
272
- for req_id, req in sorted(dev_reqs.items()):
273
- impl_str = ", ".join(req.implements) if req.implements else "-"
274
- lines.append(f"| {req_id} | {req.title} | {impl_str} | {req.status} |")
275
- lines.append("")
276
-
277
- lines.extend(["---", "*Generated by elspais*"])
278
- return "\n".join(lines)
279
-
280
-
281
- def generate_html_matrix(requirements: Dict[str, Requirement]) -> str:
282
- """Generate HTML traceability matrix."""
283
- html = """<!DOCTYPE html>
284
- <html lang="en">
285
- <head>
286
- <meta charset="UTF-8">
287
- <title>Traceability Matrix</title>
288
- <style>
289
- body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 2rem; }
290
- h1 { color: #333; }
291
- table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
292
- th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
293
- th { background: #f5f5f5; }
294
- tr:hover { background: #f9f9f9; }
295
- .status-active { color: green; }
296
- .status-draft { color: orange; }
297
- .status-deprecated { color: red; }
298
- </style>
299
- </head>
300
- <body>
301
- <h1>Traceability Matrix</h1>
302
- """
303
-
304
- # Group by type
305
- prd_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["PRD", "PRODUCT"]}
306
- ops_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["OPS", "OPERATIONS"]}
307
- dev_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["DEV", "DEVELOPMENT"]}
308
-
309
- for title, reqs in [
310
- ("Product Requirements", prd_reqs),
311
- ("Operations Requirements", ops_reqs),
312
- ("Development Requirements", dev_reqs),
313
- ]:
314
- if not reqs:
315
- continue
316
-
317
- html += f" <h2>{title}</h2>\n"
318
- html += " <table>\n"
319
- html += " <tr><th>ID</th><th>Title</th><th>Implements</th><th>Status</th></tr>\n"
320
-
321
- for req_id, req in sorted(reqs.items()):
322
- impl_str = ", ".join(req.implements) if req.implements else "-"
323
- status_class = f"status-{req.status.lower()}"
324
- subdir_attr = f'data-subdir="{req.subdir}"'
325
- html += (
326
- f' <tr {subdir_attr}><td>{req_id}</td><td>{req.title}</td>'
327
- f'<td>{impl_str}</td><td class="{status_class}">{req.status}</td></tr>\n'
328
- )
329
-
330
- html += " </table>\n"
331
-
332
- html += """ <hr>
333
- <p><em>Generated by elspais</em></p>
334
- </body>
335
- </html>"""
336
- return html
398
+ graph = build_graph(
399
+ spec_dirs=[spec_dir] if spec_dir else None,
400
+ config_path=config_path,
401
+ )
337
402
 
403
+ # Handle --view mode (interactive HTML)
404
+ if getattr(args, "view", False):
405
+ try:
406
+ # Get absolute base path for VS Code links
407
+ base_path = str(Path.cwd().resolve())
408
+ content = format_view(graph, getattr(args, "embed_content", False), base_path=base_path)
409
+ except ImportError as e:
410
+ print(f"Error: {e}", file=sys.stderr)
411
+ return 1
338
412
 
339
- def generate_csv_matrix(requirements: Dict[str, Requirement]) -> str:
340
- """Generate CSV traceability matrix."""
341
- lines = ["ID,Title,Level,Status,Implements,Subdir"]
413
+ output_path = args.output or Path("traceability_view.html")
414
+ Path(output_path).write_text(content)
342
415
 
343
- for req_id, req in sorted(requirements.items()):
344
- impl_str = ";".join(req.implements) if req.implements else ""
345
- title = req.title.replace('"', '""')
346
- lines.append(f'"{req_id}","{title}","{req.level}","{req.status}","{impl_str}","{req.subdir}"')
416
+ if not getattr(args, "quiet", False):
417
+ print(f"Generated: {output_path}", file=sys.stderr)
418
+ return 0
347
419
 
348
- return "\n".join(lines)
420
+ # Handle --graph-json mode
421
+ if getattr(args, "graph_json", False):
422
+ from elspais.graph.serialize import serialize_graph
349
423
 
424
+ output = json.dumps(serialize_graph(graph), indent=2)
425
+ if args.output:
426
+ Path(args.output).write_text(output)
427
+ if not getattr(args, "quiet", False):
428
+ print(f"Generated: {args.output}", file=sys.stderr)
429
+ else:
430
+ print(output)
431
+ return 0
432
+
433
+ # Select formatter based on format
434
+ fmt = getattr(args, "format", "markdown")
435
+
436
+ # Handle legacy "both" format
437
+ if fmt == "both":
438
+ # Generate both markdown and csv
439
+ output_base = args.output or Path("traceability")
440
+ if isinstance(output_base, str):
441
+ output_base = Path(output_base)
442
+
443
+ md_path = output_base.with_suffix(".md")
444
+ csv_path = output_base.with_suffix(".csv")
445
+
446
+ with open(md_path, "w") as f:
447
+ for line in format_markdown(graph, preset):
448
+ f.write(line + "\n")
449
+
450
+ with open(csv_path, "w") as f:
451
+ for line in format_csv(graph, preset):
452
+ f.write(line + "\n")
453
+
454
+ if not getattr(args, "quiet", False):
455
+ print(f"Generated: {md_path}", file=sys.stderr)
456
+ print(f"Generated: {csv_path}", file=sys.stderr)
457
+ return 0
458
+
459
+ # Single format output
460
+ formatters = {
461
+ "markdown": format_markdown,
462
+ "csv": format_csv,
463
+ "html": format_html,
464
+ "json": format_json,
465
+ }
466
+
467
+ if fmt not in formatters:
468
+ print(f"Error: Unknown format '{fmt}'", file=sys.stderr)
469
+ return 1
350
470
 
351
- def find_implementers(req_id: str, requirements: Dict[str, Requirement]) -> List[str]:
352
- """Find requirements that implement the given requirement."""
353
- implementers = []
354
- short_id = req_id.split("-")[-1] if "-" in req_id else req_id
471
+ line_generator = formatters[fmt](graph, preset)
472
+ output_path = args.output
355
473
 
356
- for other_id, other_req in requirements.items():
357
- for impl in other_req.implements:
358
- if impl == req_id or impl == short_id or impl.endswith(short_id):
359
- implementers.append(other_id)
360
- break
474
+ # Stream output line by line
475
+ if output_path:
476
+ with open(output_path, "w") as f:
477
+ for line in line_generator:
478
+ f.write(line + "\n")
479
+ if not getattr(args, "quiet", False):
480
+ print(f"Generated: {output_path}", file=sys.stderr)
481
+ else:
482
+ for line in line_generator:
483
+ print(line)
361
484
 
362
- return implementers
485
+ return 0