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
@@ -0,0 +1,927 @@
1
+ """Node annotation functions for TraceGraph.
2
+
3
+ These are pure functions that annotate individual GraphNode instances.
4
+ The graph provides iterators (graph.all_nodes(), graph.nodes_by_kind()),
5
+ and the caller applies annotators to nodes as needed.
6
+
7
+ Usage:
8
+ from elspais.graph.annotators import annotate_git_state, annotate_display_info
9
+ from elspais.graph import NodeKind
10
+
11
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
12
+ annotate_git_state(node, git_info)
13
+ annotate_display_info(node)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ if TYPE_CHECKING:
23
+ from elspais.graph import NodeKind
24
+ from elspais.graph.builder import TraceGraph
25
+ from elspais.graph.GraphNode import GraphNode
26
+ from elspais.utilities.git import GitChangeInfo
27
+
28
+
29
+ def annotate_git_state(node: GraphNode, git_info: GitChangeInfo | None) -> None:
30
+ """Annotate a node with git state information.
31
+
32
+ This is a pure function that mutates node.metrics in place.
33
+ Only operates on REQUIREMENT nodes.
34
+
35
+ Git metrics added to node.metrics:
36
+ - is_uncommitted: True if file has uncommitted changes
37
+ - is_untracked: True if file is not tracked by git (new file)
38
+ - is_branch_changed: True if file differs from main branch
39
+ - is_moved: True if requirement moved from a different file
40
+ - is_modified: True if file is modified (but tracked)
41
+ - is_new: True if in an untracked file (convenience alias)
42
+
43
+ Args:
44
+ node: The node to annotate.
45
+ git_info: Git change information, or None if git unavailable.
46
+ """
47
+ from elspais.graph import NodeKind
48
+
49
+ if node.kind != NodeKind.REQUIREMENT:
50
+ return
51
+
52
+ # Get file path relative to repo
53
+ file_path = node.source.path if node.source else ""
54
+
55
+ # Default all git states to False
56
+ is_uncommitted = False
57
+ is_untracked = False
58
+ is_branch_changed = False
59
+ is_moved = False
60
+ is_modified = False
61
+
62
+ if git_info:
63
+ # Check if file has uncommitted changes
64
+ is_untracked = file_path in git_info.untracked_files
65
+ is_modified = file_path in git_info.modified_files
66
+ is_uncommitted = is_untracked or is_modified
67
+
68
+ # Check if file changed vs main branch
69
+ is_branch_changed = file_path in git_info.branch_changed_files
70
+
71
+ # Check if requirement was moved
72
+ # Extract short ID from node ID (e.g., 'p00001' from 'REQ-p00001')
73
+ req_id = node.id
74
+ if "-" in req_id:
75
+ short_id = req_id.rsplit("-", 1)[-1]
76
+ # Handle assertion IDs like REQ-p00001-A
77
+ if len(short_id) == 1 and short_id.isalpha():
78
+ # This is an assertion, get the parent ID
79
+ parts = req_id.split("-")
80
+ if len(parts) >= 2:
81
+ short_id = parts[-2]
82
+ else:
83
+ short_id = req_id
84
+
85
+ committed_path = git_info.committed_req_locations.get(short_id)
86
+ if committed_path and committed_path != file_path:
87
+ is_moved = True
88
+
89
+ # is_new means it's in an untracked file (truly new, not moved)
90
+ is_new = is_untracked
91
+
92
+ # Annotate node metrics
93
+ node.set_metric("is_uncommitted", is_uncommitted)
94
+ node.set_metric("is_untracked", is_untracked)
95
+ node.set_metric("is_branch_changed", is_branch_changed)
96
+ node.set_metric("is_moved", is_moved)
97
+ node.set_metric("is_modified", is_modified)
98
+ node.set_metric("is_new", is_new)
99
+
100
+
101
+ def annotate_display_info(node: GraphNode) -> None:
102
+ """Annotate a node with display-friendly information.
103
+
104
+ This is a pure function that mutates node.metrics in place.
105
+ Only operates on REQUIREMENT nodes.
106
+
107
+ Display metrics added to node.metrics:
108
+ - is_roadmap: True if in spec/roadmap/ directory
109
+ - is_conflict: True if has duplicate ID conflict
110
+ - conflict_with: ID of conflicting requirement (if conflict)
111
+ - display_filename: Filename stem for display
112
+ - file_name: Full filename
113
+ - repo_prefix: Repo prefix for multi-repo setups (e.g., "CORE", "CAL")
114
+ - external_spec_path: Path for associated repo specs
115
+
116
+ Args:
117
+ node: The node to annotate.
118
+ """
119
+ from elspais.graph import NodeKind
120
+
121
+ if node.kind != NodeKind.REQUIREMENT:
122
+ return
123
+
124
+ # Get file path relative to repo
125
+ file_path = node.source.path if node.source else ""
126
+
127
+ # Roadmap detection from path
128
+ is_roadmap = "roadmap" in file_path.lower()
129
+ node.set_metric("is_roadmap", is_roadmap)
130
+
131
+ # Conflict detection from content
132
+ is_conflict = node.get_field("is_conflict", False)
133
+ conflict_with = node.get_field("conflict_with")
134
+ node.set_metric("is_conflict", is_conflict)
135
+ if conflict_with:
136
+ node.set_metric("conflict_with", conflict_with)
137
+
138
+ # Store display-friendly file info
139
+ if file_path:
140
+ path = Path(file_path)
141
+ node.set_metric("display_filename", path.stem)
142
+ node.set_metric("file_name", path.name)
143
+ else:
144
+ node.set_metric("display_filename", "")
145
+ node.set_metric("file_name", "")
146
+
147
+ # Repo prefix for multi-repo setups
148
+ repo_prefix = node.get_field("repo_prefix")
149
+ node.set_metric("repo_prefix", repo_prefix or "CORE")
150
+
151
+ # External spec path for associated repos
152
+ external_spec_path = node.get_field("external_spec_path")
153
+ if external_spec_path:
154
+ node.set_metric("external_spec_path", str(external_spec_path))
155
+
156
+
157
+ def annotate_implementation_files(
158
+ node: GraphNode,
159
+ implementation_files: list[tuple[str, int]],
160
+ ) -> None:
161
+ """Annotate a node with implementation file references.
162
+
163
+ Args:
164
+ node: The node to annotate.
165
+ implementation_files: List of (file_path, line_number) tuples.
166
+ """
167
+ from elspais.graph import NodeKind
168
+
169
+ if node.kind != NodeKind.REQUIREMENT:
170
+ return
171
+
172
+ # Store implementation files in metrics
173
+ existing = node.get_metric("implementation_files", [])
174
+ existing.extend(implementation_files)
175
+ node.set_metric("implementation_files", existing)
176
+
177
+
178
+ # =============================================================================
179
+ # Graph Aggregate Functions
180
+ # =============================================================================
181
+ # These functions compute aggregate statistics from an annotated graph.
182
+ # They follow the composable pattern: take a graph, return computed values.
183
+
184
+
185
+ def count_by_level(graph: TraceGraph) -> dict[str, dict[str, int]]:
186
+ """Count requirements by level, with and without deprecated.
187
+
188
+ Args:
189
+ graph: The TraceGraph to aggregate.
190
+
191
+ Returns:
192
+ Dict with 'active' (excludes Deprecated) and 'all' (includes Deprecated) counts
193
+ by level (PRD, OPS, DEV).
194
+ """
195
+ from elspais.graph import NodeKind
196
+
197
+ counts: dict[str, dict[str, int]] = {
198
+ "active": {"PRD": 0, "OPS": 0, "DEV": 0},
199
+ "all": {"PRD": 0, "OPS": 0, "DEV": 0},
200
+ }
201
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
202
+ level = node.get_field("level", "")
203
+ status = node.get_field("status", "Active")
204
+ if level:
205
+ counts["all"][level] = counts["all"].get(level, 0) + 1
206
+ if status != "Deprecated":
207
+ counts["active"][level] = counts["active"].get(level, 0) + 1
208
+ return counts
209
+
210
+
211
+ def group_by_level(graph: TraceGraph) -> dict[str, list[GraphNode]]:
212
+ """Group requirements by level.
213
+
214
+ Args:
215
+ graph: The TraceGraph to query.
216
+
217
+ Returns:
218
+ Dict mapping level (PRD, OPS, DEV, other) to list of requirement nodes.
219
+ """
220
+ from elspais.graph import NodeKind
221
+
222
+ groups: dict[str, list[GraphNode]] = {"PRD": [], "OPS": [], "DEV": [], "other": []}
223
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
224
+ level = (node.get_field("level") or "").upper()
225
+ if level in groups:
226
+ groups[level].append(node)
227
+ else:
228
+ groups["other"].append(node)
229
+ return groups
230
+
231
+
232
+ def count_by_repo(graph: TraceGraph) -> dict[str, dict[str, int]]:
233
+ """Count requirements by repo prefix (CORE, CAL, TTN, etc.).
234
+
235
+ Args:
236
+ graph: The TraceGraph to aggregate.
237
+
238
+ Returns:
239
+ Dict mapping repo prefix to {'active': count, 'all': count}.
240
+ CORE is used for core repo requirements (no prefix).
241
+ """
242
+ from elspais.graph import NodeKind
243
+
244
+ repo_counts: dict[str, dict[str, int]] = {}
245
+
246
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
247
+
248
+ prefix = node.get_metric("repo_prefix", "CORE")
249
+ status = node.get_field("status", "Active")
250
+
251
+ if prefix not in repo_counts:
252
+ repo_counts[prefix] = {"active": 0, "all": 0}
253
+
254
+ repo_counts[prefix]["all"] += 1
255
+ if status != "Deprecated":
256
+ repo_counts[prefix]["active"] += 1
257
+
258
+ return repo_counts
259
+
260
+
261
+ def count_implementation_files(graph: TraceGraph) -> int:
262
+ """Count total implementation files across all requirements.
263
+
264
+ Args:
265
+ graph: The TraceGraph to aggregate.
266
+
267
+ Returns:
268
+ Total count of implementation file references.
269
+ """
270
+ from elspais.graph import NodeKind
271
+
272
+ total = 0
273
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
274
+ impl_files = node.get_metric("implementation_files", [])
275
+ total += len(impl_files)
276
+ return total
277
+
278
+
279
+ def collect_topics(graph: TraceGraph) -> list[str]:
280
+ """Collect unique topics from requirement file names.
281
+
282
+ Args:
283
+ graph: The TraceGraph to scan.
284
+
285
+ Returns:
286
+ Sorted list of unique topic names extracted from file stems.
287
+ """
288
+ from elspais.graph import NodeKind
289
+
290
+ all_topics: set[str] = set()
291
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
292
+ if node.source and node.source.path:
293
+ stem = Path(node.source.path).stem
294
+ topic = stem.split("-", 1)[1] if "-" in stem else stem
295
+ all_topics.add(topic)
296
+ return sorted(all_topics)
297
+
298
+
299
+ def get_implementation_status(node: GraphNode) -> str:
300
+ """Get implementation status for a requirement node.
301
+
302
+ Args:
303
+ node: The GraphNode to check.
304
+
305
+ Returns:
306
+ 'Full': coverage_pct >= 100
307
+ 'Partial': coverage_pct > 0
308
+ 'Unimplemented': coverage_pct == 0
309
+ """
310
+ coverage_pct = node.get_metric("coverage_pct", 0)
311
+ if coverage_pct >= 100:
312
+ return "Full"
313
+ elif coverage_pct > 0:
314
+ return "Partial"
315
+ else:
316
+ return "Unimplemented"
317
+
318
+
319
+ def count_by_coverage(graph: TraceGraph) -> dict[str, int]:
320
+ """Count requirements by coverage level.
321
+
322
+ Args:
323
+ graph: The TraceGraph to aggregate.
324
+
325
+ Returns:
326
+ Dict with 'total', 'full_coverage', 'partial_coverage', 'no_coverage' counts.
327
+ """
328
+ from elspais.graph import NodeKind
329
+
330
+ counts: dict[str, int] = {
331
+ "total": 0,
332
+ "full_coverage": 0,
333
+ "partial_coverage": 0,
334
+ "no_coverage": 0,
335
+ }
336
+
337
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
338
+
339
+ counts["total"] += 1
340
+ coverage_pct = node.get_metric("coverage_pct", 0)
341
+
342
+ if coverage_pct >= 100:
343
+ counts["full_coverage"] += 1
344
+ elif coverage_pct > 0:
345
+ counts["partial_coverage"] += 1
346
+ else:
347
+ counts["no_coverage"] += 1
348
+
349
+ return counts
350
+
351
+
352
+ def count_with_code_refs(graph: TraceGraph) -> dict[str, int]:
353
+ """Count requirements that have at least one CODE reference.
354
+
355
+ A requirement has CODE coverage if:
356
+ - It has a CODE child directly, OR
357
+ - One of its ASSERTION children has a CODE child
358
+
359
+ Args:
360
+ graph: The TraceGraph to query.
361
+
362
+ Returns:
363
+ Dict with 'total_requirements', 'with_code_refs', 'coverage_percent'.
364
+ """
365
+ from elspais.graph import NodeKind
366
+
367
+ total = 0
368
+ covered_req_ids: set[str] = set()
369
+
370
+ for node in graph.nodes_by_kind(NodeKind.CODE):
371
+ for parent in node.iter_parents():
372
+ if parent.kind == NodeKind.REQUIREMENT:
373
+ covered_req_ids.add(parent.id)
374
+ elif parent.kind == NodeKind.ASSERTION:
375
+ # Get the parent requirement of the assertion
376
+ for grandparent in parent.iter_parents():
377
+ if grandparent.kind == NodeKind.REQUIREMENT:
378
+ covered_req_ids.add(grandparent.id)
379
+
380
+ for _ in graph.nodes_by_kind(NodeKind.REQUIREMENT):
381
+ total += 1
382
+
383
+ pct = (len(covered_req_ids) / total * 100) if total > 0 else 0.0
384
+ return {
385
+ "total_requirements": total,
386
+ "with_code_refs": len(covered_req_ids),
387
+ "coverage_percent": round(pct, 1),
388
+ }
389
+
390
+
391
+ def count_by_git_status(graph: TraceGraph) -> dict[str, int]:
392
+ """Count requirements by git change status.
393
+
394
+ Args:
395
+ graph: The TraceGraph to aggregate.
396
+
397
+ Returns:
398
+ Dict with 'uncommitted' and 'branch_changed' counts.
399
+ """
400
+ from elspais.graph import NodeKind
401
+
402
+ counts: dict[str, int] = {
403
+ "uncommitted": 0,
404
+ "branch_changed": 0,
405
+ }
406
+
407
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
408
+
409
+ if node.get_metric("is_uncommitted", False):
410
+ counts["uncommitted"] += 1
411
+ if node.get_metric("is_branch_changed", False):
412
+ counts["branch_changed"] += 1
413
+
414
+ return counts
415
+
416
+
417
+ def annotate_coverage(graph: TraceGraph) -> None:
418
+ """Compute and store coverage metrics for all requirement nodes.
419
+
420
+ This function traverses the graph once to compute RollupMetrics for
421
+ each REQUIREMENT node. Metrics are stored in node._metrics as:
422
+ - "rollup_metrics": The full RollupMetrics object
423
+ - "coverage_pct": Coverage percentage (for convenience)
424
+
425
+ Coverage is determined by outgoing edges from REQUIREMENT nodes:
426
+ - The builder links TEST/CODE/REQ as children of the parent REQ
427
+ - Edges have assertion_targets when they target specific assertions
428
+ - VALIDATES to TEST with assertion_targets → DIRECT coverage
429
+ - IMPLEMENTS to CODE with assertion_targets → DIRECT coverage
430
+ - IMPLEMENTS to REQ with assertion_targets → EXPLICIT coverage
431
+ - IMPLEMENTS to REQ without assertion_targets → INFERRED coverage
432
+
433
+ REFINES edges do NOT contribute to coverage (EdgeKind.contributes_to_coverage()).
434
+
435
+ Test-specific metrics:
436
+ - direct_tested: Assertions with TEST nodes (not CODE)
437
+ - validated: Assertions with passing TEST_RESULTs
438
+ - has_failures: Any TEST_RESULT is failed/error
439
+
440
+ Args:
441
+ graph: The TraceGraph to annotate.
442
+ """
443
+ from elspais.graph import NodeKind
444
+ from elspais.graph.metrics import (
445
+ CoverageContribution,
446
+ CoverageSource,
447
+ RollupMetrics,
448
+ )
449
+
450
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
451
+
452
+ metrics = RollupMetrics()
453
+
454
+ # Collect assertion children
455
+ assertion_labels: list[str] = []
456
+
457
+ for child in node.iter_children():
458
+ if child.kind == NodeKind.ASSERTION:
459
+ label = child.get_field("label", "")
460
+ if label:
461
+ assertion_labels.append(label)
462
+
463
+ metrics.total_assertions = len(assertion_labels)
464
+
465
+ # Track TEST-specific metrics
466
+ tested_labels: set[str] = set() # Assertions with TEST coverage
467
+ validated_labels: set[str] = set() # Assertions with passing tests
468
+ has_failures = False
469
+ test_nodes_for_result_lookup: list[tuple[GraphNode, list[str] | None]] = []
470
+
471
+ # Check outgoing edges from this requirement
472
+ # The builder links TEST/CODE/REQ as children of parent REQ with assertion_targets
473
+ for edge in node.iter_outgoing_edges():
474
+ if not edge.kind.contributes_to_coverage():
475
+ # REFINES doesn't count
476
+ continue
477
+
478
+ target_node = edge.target
479
+ target_kind = target_node.kind
480
+
481
+ if target_kind == NodeKind.TEST:
482
+ # TEST validates assertion(s) → DIRECT coverage
483
+ if edge.assertion_targets:
484
+ for label in edge.assertion_targets:
485
+ if label in assertion_labels:
486
+ metrics.add_contribution(
487
+ CoverageContribution(
488
+ source_id=target_node.id,
489
+ source_type=CoverageSource.DIRECT,
490
+ assertion_label=label,
491
+ )
492
+ )
493
+ tested_labels.add(label)
494
+
495
+ # Track this TEST node for result lookup later
496
+ test_nodes_for_result_lookup.append((target_node, edge.assertion_targets))
497
+
498
+ elif target_kind == NodeKind.CODE:
499
+ # CODE implements assertion(s) → DIRECT coverage
500
+ if edge.assertion_targets:
501
+ for label in edge.assertion_targets:
502
+ if label in assertion_labels:
503
+ metrics.add_contribution(
504
+ CoverageContribution(
505
+ source_id=target_node.id,
506
+ source_type=CoverageSource.DIRECT,
507
+ assertion_label=label,
508
+ )
509
+ )
510
+
511
+ elif target_kind == NodeKind.REQUIREMENT:
512
+ # Child REQ implements this REQ
513
+ if edge.assertion_targets:
514
+ # Explicit: REQ implements specific assertions
515
+ for label in edge.assertion_targets:
516
+ if label in assertion_labels:
517
+ metrics.add_contribution(
518
+ CoverageContribution(
519
+ source_id=target_node.id,
520
+ source_type=CoverageSource.EXPLICIT,
521
+ assertion_label=label,
522
+ )
523
+ )
524
+ else:
525
+ # Inferred: REQ implements parent REQ (all assertions)
526
+ for label in assertion_labels:
527
+ metrics.add_contribution(
528
+ CoverageContribution(
529
+ source_id=target_node.id,
530
+ source_type=CoverageSource.INFERRED,
531
+ assertion_label=label,
532
+ )
533
+ )
534
+
535
+ # Process TEST children to find TEST_RESULT nodes
536
+ for test_node, assertion_targets in test_nodes_for_result_lookup:
537
+ for result in test_node.iter_children():
538
+ if result.kind == NodeKind.TEST_RESULT:
539
+ status = (result.get_field("status", "") or "").lower()
540
+ if status in ("passed", "pass", "success"):
541
+ # Mark assertions as validated by passing tests
542
+ for label in assertion_targets or []:
543
+ if label in assertion_labels:
544
+ validated_labels.add(label)
545
+ elif status in ("failed", "fail", "failure", "error"):
546
+ has_failures = True
547
+
548
+ # Set test-specific metrics before finalize
549
+ metrics.direct_tested = len(tested_labels)
550
+ metrics.validated = len(validated_labels)
551
+ metrics.has_failures = has_failures
552
+
553
+ # Finalize metrics (computes aggregate coverage counts)
554
+ metrics.finalize()
555
+
556
+ # Store in node metrics
557
+ node.set_metric("rollup_metrics", metrics)
558
+ node.set_metric("coverage_pct", metrics.coverage_pct)
559
+
560
+
561
+ # =============================================================================
562
+ # Keyword Extraction (Phase 4)
563
+ # =============================================================================
564
+ # These functions extract and search keywords from requirement text.
565
+ # Keywords are stored in node._content["keywords"] as a list of strings.
566
+
567
+
568
+ # Default stopwords - common words filtered from keywords.
569
+ # NOTE: Normative keywords (shall, must, should, may, required) are NOT included
570
+ # as they have semantic meaning for requirements (RFC 2119).
571
+ DEFAULT_STOPWORDS = frozenset(
572
+ [
573
+ # Articles and determiners
574
+ "a",
575
+ "an",
576
+ "the",
577
+ "this",
578
+ "that",
579
+ "these",
580
+ "those",
581
+ # Pronouns
582
+ "i",
583
+ "you",
584
+ "he",
585
+ "she",
586
+ "it",
587
+ "we",
588
+ "they",
589
+ "me",
590
+ "him",
591
+ "her",
592
+ "us",
593
+ "them",
594
+ # Prepositions
595
+ "in",
596
+ "on",
597
+ "at",
598
+ "to",
599
+ "for",
600
+ "of",
601
+ "with",
602
+ "by",
603
+ "from",
604
+ "up",
605
+ "about",
606
+ "into",
607
+ "through",
608
+ "during",
609
+ "before",
610
+ "after",
611
+ "above",
612
+ "below",
613
+ "between",
614
+ # Conjunctions
615
+ "and",
616
+ "or",
617
+ "but",
618
+ "nor",
619
+ "so",
620
+ "yet",
621
+ "both",
622
+ "either",
623
+ "neither",
624
+ # Auxiliary verbs (excluding normative: shall, must, should, may)
625
+ "is",
626
+ "am",
627
+ "are",
628
+ "was",
629
+ "were",
630
+ "be",
631
+ "been",
632
+ "being",
633
+ "have",
634
+ "has",
635
+ "had",
636
+ "having",
637
+ "do",
638
+ "does",
639
+ "did",
640
+ "doing",
641
+ "will",
642
+ "would",
643
+ "could",
644
+ "might",
645
+ "can",
646
+ # Common verbs
647
+ "get",
648
+ "got",
649
+ "make",
650
+ "made",
651
+ "let",
652
+ # Other common words
653
+ "not",
654
+ "if",
655
+ "when",
656
+ "where",
657
+ "how",
658
+ "what",
659
+ "which",
660
+ "who",
661
+ "whom",
662
+ "whose",
663
+ "all",
664
+ "each",
665
+ "every",
666
+ "any",
667
+ "some",
668
+ "no",
669
+ "none",
670
+ "other",
671
+ "such",
672
+ "only",
673
+ "own",
674
+ "same",
675
+ "than",
676
+ "too",
677
+ "very",
678
+ ]
679
+ )
680
+
681
+ # Alias for backward compatibility
682
+ STOPWORDS = DEFAULT_STOPWORDS
683
+
684
+
685
+ @dataclass
686
+ class KeywordsConfig:
687
+ """Configuration for keyword extraction."""
688
+
689
+ stopwords: frozenset[str]
690
+ min_length: int = 3
691
+
692
+ @classmethod
693
+ def from_dict(cls, data: dict[str, Any]) -> KeywordsConfig:
694
+ """Create config from dictionary.
695
+
696
+ Args:
697
+ data: Dict with optional 'stopwords' list and 'min_length' int.
698
+
699
+ Returns:
700
+ KeywordsConfig instance.
701
+ """
702
+ stopwords_list = data.get("stopwords")
703
+ if stopwords_list is not None:
704
+ stopwords = frozenset(stopwords_list)
705
+ else:
706
+ stopwords = DEFAULT_STOPWORDS
707
+
708
+ return cls(
709
+ stopwords=stopwords,
710
+ min_length=data.get("min_length", 3),
711
+ )
712
+
713
+
714
+ def extract_keywords(
715
+ text: str,
716
+ config: KeywordsConfig | None = None,
717
+ ) -> list[str]:
718
+ """Extract keywords from text.
719
+
720
+ Extracts meaningful words by:
721
+ - Lowercasing all text
722
+ - Removing punctuation (except hyphens within words)
723
+ - Filtering stopwords
724
+ - Filtering words shorter than min_length
725
+ - Deduplicating results
726
+
727
+ Args:
728
+ text: Input text to extract keywords from.
729
+ config: Optional KeywordsConfig for custom stopwords/min_length.
730
+
731
+ Returns:
732
+ List of unique keywords in lowercase.
733
+ """
734
+ import re
735
+
736
+ if not text:
737
+ return []
738
+
739
+ # Use provided config or defaults
740
+ cfg = config or KeywordsConfig(stopwords=DEFAULT_STOPWORDS, min_length=3)
741
+
742
+ # Lowercase and split into words
743
+ text = text.lower()
744
+
745
+ # Replace punctuation (except hyphens between letters) with spaces
746
+ # Keep alphanumeric and hyphens
747
+ text = re.sub(r"[^\w\s-]", " ", text)
748
+
749
+ # Split on whitespace
750
+ words = text.split()
751
+
752
+ # Filter and deduplicate
753
+ seen: set[str] = set()
754
+ keywords: list[str] = []
755
+
756
+ for word in words:
757
+ # Strip leading/trailing hyphens
758
+ word = word.strip("-")
759
+
760
+ # Skip short words
761
+ if len(word) < cfg.min_length:
762
+ continue
763
+
764
+ # Skip stopwords
765
+ if word in cfg.stopwords:
766
+ continue
767
+
768
+ # Deduplicate
769
+ if word not in seen:
770
+ seen.add(word)
771
+ keywords.append(word)
772
+
773
+ return keywords
774
+
775
+
776
+ def annotate_keywords(
777
+ graph: TraceGraph,
778
+ config: KeywordsConfig | None = None,
779
+ ) -> None:
780
+ """Extract and store keywords for all nodes with text content.
781
+
782
+ Keywords are extracted based on node kind:
783
+ - REQUIREMENT: title + child assertion text
784
+ - ASSERTION: SHALL statement (label)
785
+ - USER_JOURNEY: title + actor + goal + description
786
+ - REMAINDER: label + raw_text
787
+ - Others (CODE, TEST, TEST_RESULT): label only
788
+
789
+ Keywords are stored in node._content["keywords"] as a list.
790
+
791
+ Args:
792
+ graph: The TraceGraph to annotate.
793
+ config: Optional KeywordsConfig for custom stopwords/min_length.
794
+ """
795
+ from elspais.graph import NodeKind
796
+
797
+ for node in graph.all_nodes():
798
+ text_parts: list[str] = []
799
+
800
+ # Get label (all nodes have this)
801
+ label = node.get_label()
802
+ if label:
803
+ text_parts.append(label)
804
+
805
+ # Add kind-specific text
806
+ if node.kind == NodeKind.REQUIREMENT:
807
+ # Include child assertion text
808
+ for child in node.iter_children():
809
+ if child.kind == NodeKind.ASSERTION:
810
+ child_text = child.get_label()
811
+ if child_text:
812
+ text_parts.append(child_text)
813
+
814
+ elif node.kind == NodeKind.USER_JOURNEY:
815
+ # Include actor, goal, description
816
+ for field in ["actor", "goal", "description"]:
817
+ value = node.get_field(field)
818
+ if value:
819
+ text_parts.append(value)
820
+
821
+ elif node.kind == NodeKind.REMAINDER:
822
+ # Include raw text
823
+ raw = node.get_field("raw_text")
824
+ if raw:
825
+ text_parts.append(raw)
826
+
827
+ # Extract and store keywords
828
+ combined_text = " ".join(text_parts)
829
+ keywords = extract_keywords(combined_text, config)
830
+ node.set_field("keywords", keywords)
831
+
832
+
833
+ def find_by_keywords(
834
+ graph: TraceGraph,
835
+ keywords: list[str],
836
+ match_all: bool = True,
837
+ kind: NodeKind | None = None,
838
+ ) -> list[GraphNode]:
839
+ """Find nodes containing specified keywords.
840
+
841
+ Args:
842
+ graph: The TraceGraph to search.
843
+ keywords: List of keywords to search for.
844
+ match_all: If True, node must contain ALL keywords (AND).
845
+ If False, node must contain ANY keyword (OR).
846
+ kind: NodeKind to filter by, or None to search all nodes.
847
+
848
+ Returns:
849
+ List of matching GraphNode objects.
850
+ """
851
+ # Normalize search keywords to lowercase
852
+ search_keywords = {k.lower() for k in keywords}
853
+
854
+ results: list[GraphNode] = []
855
+
856
+ # Choose iterator based on kind parameter
857
+ if kind is not None:
858
+ nodes = graph.nodes_by_kind(kind)
859
+ else:
860
+ nodes = graph.all_nodes()
861
+
862
+ for node in nodes:
863
+ node_keywords = set(node.get_field("keywords", []))
864
+
865
+ if match_all:
866
+ # All keywords must be present
867
+ if search_keywords.issubset(node_keywords):
868
+ results.append(node)
869
+ else:
870
+ # Any keyword must be present
871
+ if search_keywords & node_keywords:
872
+ results.append(node)
873
+
874
+ return results
875
+
876
+
877
+ def collect_all_keywords(
878
+ graph: TraceGraph,
879
+ kind: NodeKind | None = None,
880
+ ) -> list[str]:
881
+ """Collect all unique keywords from annotated nodes.
882
+
883
+ Args:
884
+ graph: The TraceGraph to scan.
885
+ kind: NodeKind to filter by, or None to collect from all nodes.
886
+
887
+ Returns:
888
+ Sorted list of all unique keywords across matching nodes.
889
+ """
890
+ all_keywords: set[str] = set()
891
+
892
+ # Choose iterator based on kind parameter
893
+ if kind is not None:
894
+ nodes = graph.nodes_by_kind(kind)
895
+ else:
896
+ nodes = graph.all_nodes()
897
+
898
+ for node in nodes:
899
+ node_keywords = node.get_field("keywords", [])
900
+ all_keywords.update(node_keywords)
901
+
902
+ return sorted(all_keywords)
903
+
904
+
905
+ __all__ = [
906
+ "annotate_git_state",
907
+ "annotate_display_info",
908
+ "annotate_implementation_files",
909
+ "count_by_level",
910
+ "group_by_level",
911
+ "count_by_repo",
912
+ "count_by_coverage",
913
+ "count_with_code_refs",
914
+ "count_by_git_status",
915
+ "count_implementation_files",
916
+ "collect_topics",
917
+ "get_implementation_status",
918
+ "annotate_coverage",
919
+ # Keyword extraction
920
+ "DEFAULT_STOPWORDS",
921
+ "STOPWORDS",
922
+ "KeywordsConfig",
923
+ "extract_keywords",
924
+ "annotate_keywords",
925
+ "find_by_keywords",
926
+ "collect_all_keywords",
927
+ ]