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
elspais/commands/trace.py CHANGED
@@ -2,365 +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)
28
- or getattr(args, "embed_content", False)
29
- or getattr(args, "edit_mode", False)
30
- or getattr(args, "review_mode", False)
31
- or 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 FLASK_AVAILABLE, create_app
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(
218
- f"""
219
- ======================================
220
- elspais Review Server
221
- ======================================
392
+ # Build graph using factory
393
+ from elspais.graph.factory import build_graph
222
394
 
223
- Repository: {repo_root}
224
- Server: http://localhost:{port}
395
+ spec_dir = getattr(args, "spec_dir", None)
396
+ config_path = getattr(args, "config", None)
225
397
 
226
- Press Ctrl+C to stop
227
- """
398
+ graph = build_graph(
399
+ spec_dirs=[spec_dir] if spec_dir else None,
400
+ config_path=config_path,
228
401
  )
229
402
 
230
- app = create_app(repo_root, auto_sync=True)
231
- try:
232
- app.run(host="0.0.0.0", port=port, debug=False)
233
- except KeyboardInterrupt:
234
- print("\nServer stopped.")
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
235
412
 
236
- return 0
237
-
238
-
239
- def generate_markdown_matrix(requirements: Dict[str, Requirement]) -> str:
240
- """Generate Markdown traceability matrix."""
241
- lines = ["# Traceability Matrix", "", "## Requirements Hierarchy", ""]
242
-
243
- # Group by type
244
- prd_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["PRD", "PRODUCT"]}
245
- ops_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["OPS", "OPERATIONS"]}
246
- dev_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["DEV", "DEVELOPMENT"]}
247
-
248
- # PRD table
249
- if prd_reqs:
250
- lines.extend(["### Product Requirements", ""])
251
- lines.append("| ID | Title | Status | Implemented By |")
252
- lines.append("|---|---|---|---|")
253
- for req_id, req in sorted(prd_reqs.items()):
254
- impl_by = find_implementers(req_id, requirements)
255
- impl_str = ", ".join(impl_by) if impl_by else "-"
256
- lines.append(f"| {req_id} | {req.title} | {req.status} | {impl_str} |")
257
- lines.append("")
258
-
259
- # OPS table
260
- if ops_reqs:
261
- lines.extend(["### Operations Requirements", ""])
262
- lines.append("| ID | Title | Implements | Status |")
263
- lines.append("|---|---|---|---|")
264
- for req_id, req in sorted(ops_reqs.items()):
265
- impl_str = ", ".join(req.implements) if req.implements else "-"
266
- lines.append(f"| {req_id} | {req.title} | {impl_str} | {req.status} |")
267
- lines.append("")
268
-
269
- # DEV table
270
- if dev_reqs:
271
- lines.extend(["### Development Requirements", ""])
272
- lines.append("| ID | Title | Implements | Status |")
273
- lines.append("|---|---|---|---|")
274
- for req_id, req in sorted(dev_reqs.items()):
275
- impl_str = ", ".join(req.implements) if req.implements else "-"
276
- lines.append(f"| {req_id} | {req.title} | {impl_str} | {req.status} |")
277
- lines.append("")
278
-
279
- lines.extend(["---", "*Generated by elspais*"])
280
- return "\n".join(lines)
281
-
282
-
283
- def generate_html_matrix(requirements: Dict[str, Requirement]) -> str:
284
- """Generate HTML traceability matrix."""
285
- html = """<!DOCTYPE html>
286
- <html lang="en">
287
- <head>
288
- <meta charset="UTF-8">
289
- <title>Traceability Matrix</title>
290
- <style>
291
- body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 2rem; }
292
- h1 { color: #333; }
293
- table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
294
- th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
295
- th { background: #f5f5f5; }
296
- tr:hover { background: #f9f9f9; }
297
- .status-active { color: green; }
298
- .status-draft { color: orange; }
299
- .status-deprecated { color: red; }
300
- </style>
301
- </head>
302
- <body>
303
- <h1>Traceability Matrix</h1>
304
- """
305
-
306
- # Group by type
307
- prd_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["PRD", "PRODUCT"]}
308
- ops_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["OPS", "OPERATIONS"]}
309
- dev_reqs = {k: v for k, v in requirements.items() if v.level.upper() in ["DEV", "DEVELOPMENT"]}
310
-
311
- for title, reqs in [
312
- ("Product Requirements", prd_reqs),
313
- ("Operations Requirements", ops_reqs),
314
- ("Development Requirements", dev_reqs),
315
- ]:
316
- if not reqs:
317
- continue
318
-
319
- html += f" <h2>{title}</h2>\n"
320
- html += " <table>\n"
321
- html += " <tr><th>ID</th><th>Title</th><th>Implements</th><th>Status</th></tr>\n"
322
-
323
- for req_id, req in sorted(reqs.items()):
324
- impl_str = ", ".join(req.implements) if req.implements else "-"
325
- status_class = f"status-{req.status.lower()}"
326
- subdir_attr = f'data-subdir="{req.subdir}"'
327
- html += (
328
- f" <tr {subdir_attr}><td>{req_id}</td><td>{req.title}</td>"
329
- f'<td>{impl_str}</td><td class="{status_class}">{req.status}</td></tr>\n'
330
- )
331
-
332
- html += " </table>\n"
333
-
334
- html += """ <hr>
335
- <p><em>Generated by elspais</em></p>
336
- </body>
337
- </html>"""
338
- return html
413
+ output_path = args.output or Path("traceability_view.html")
414
+ Path(output_path).write_text(content)
339
415
 
416
+ if not getattr(args, "quiet", False):
417
+ print(f"Generated: {output_path}", file=sys.stderr)
418
+ return 0
340
419
 
341
- def generate_csv_matrix(requirements: Dict[str, Requirement]) -> str:
342
- """Generate CSV traceability matrix."""
343
- lines = ["ID,Title,Level,Status,Implements,Subdir"]
344
-
345
- for req_id, req in sorted(requirements.items()):
346
- impl_str = ";".join(req.implements) if req.implements else ""
347
- title = req.title.replace('"', '""')
348
- lines.append(
349
- f'"{req_id}","{title}","{req.level}","{req.status}","{impl_str}","{req.subdir}"'
350
- )
351
-
352
- return "\n".join(lines)
420
+ # Handle --graph-json mode
421
+ if getattr(args, "graph_json", False):
422
+ from elspais.graph.serialize import serialize_graph
353
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
354
470
 
355
- def find_implementers(req_id: str, requirements: Dict[str, Requirement]) -> List[str]:
356
- """Find requirements that implement the given requirement."""
357
- implementers = []
358
- 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
359
473
 
360
- for other_id, other_req in requirements.items():
361
- for impl in other_req.implements:
362
- if impl == req_id or impl == short_id or impl.endswith(short_id):
363
- implementers.append(other_id)
364
- 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)
365
484
 
366
- return implementers
485
+ return 0