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.
- elspais/__init__.py +2 -11
- elspais/{sponsors/__init__.py → associates.py} +102 -58
- elspais/cli.py +395 -79
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +121 -173
- elspais/commands/changed.py +15 -30
- elspais/commands/config_cmd.py +13 -16
- elspais/commands/edit.py +60 -44
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +167 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -114
- elspais/commands/init.py +103 -26
- elspais/commands/reformat_cmd.py +41 -444
- elspais/commands/rules_cmd.py +7 -3
- elspais/commands/trace.py +444 -321
- elspais/commands/validate.py +195 -415
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -3
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +47 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +2016 -247
- elspais/testing/__init__.py +4 -4
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +58 -57
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -173
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -352
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -640
- elspais/core/rules.py +0 -514
- elspais/mcp/context.py +0 -171
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -119
- elspais/reformat/hierarchy.py +0 -246
- elspais/reformat/line_breaks.py +0 -220
- elspais/reformat/prompts.py +0 -123
- elspais/reformat/transformer.py +0 -264
- elspais/trace_view/__init__.py +0 -54
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -329
- elspais/trace_view/generators/csv.py +0 -122
- elspais/trace_view/generators/markdown.py +0 -175
- elspais/trace_view/html/__init__.py +0 -31
- elspais/trace_view/html/generator.py +0 -1006
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -353
- elspais/trace_view/review/__init__.py +0 -60
- elspais/trace_view/review/branches.py +0 -1149
- elspais/trace_view/review/models.py +0 -1205
- elspais/trace_view/review/position.py +0 -609
- elspais/trace_view/review/server.py +0 -1056
- elspais/trace_view/review/status.py +0 -470
- elspais/trace_view/review/storage.py +0 -1367
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.1.dist-info/RECORD +0 -101
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {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
|
+
]
|