julee 0.1.1__py3-none-any.whl → 0.1.3__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.
- julee/api/app.py +9 -8
- julee/api/dependencies.py +15 -15
- julee/api/requests.py +10 -9
- julee/api/responses.py +2 -1
- julee/api/routers/__init__.py +5 -5
- julee/api/routers/assembly_specifications.py +5 -4
- julee/api/routers/documents.py +1 -1
- julee/api/routers/knowledge_service_configs.py +4 -3
- julee/api/routers/knowledge_service_queries.py +7 -6
- julee/api/routers/system.py +4 -3
- julee/api/routers/workflows.py +4 -5
- julee/api/services/system_initialization.py +6 -6
- julee/api/tests/routers/test_assembly_specifications.py +4 -3
- julee/api/tests/routers/test_documents.py +5 -4
- julee/api/tests/routers/test_knowledge_service_configs.py +7 -6
- julee/api/tests/routers/test_knowledge_service_queries.py +4 -3
- julee/api/tests/routers/test_system.py +5 -4
- julee/api/tests/routers/test_workflows.py +5 -4
- julee/api/tests/test_app.py +5 -4
- julee/api/tests/test_dependencies.py +3 -2
- julee/api/tests/test_requests.py +2 -1
- julee/contrib/__init__.py +15 -0
- julee/contrib/polling/__init__.py +47 -0
- julee/contrib/polling/domain/__init__.py +17 -0
- julee/contrib/polling/domain/models/__init__.py +13 -0
- julee/contrib/polling/domain/models/polling_config.py +39 -0
- julee/contrib/polling/domain/services/__init__.py +11 -0
- julee/contrib/polling/domain/services/poller.py +39 -0
- julee/contrib/polling/infrastructure/__init__.py +15 -0
- julee/contrib/polling/infrastructure/services/__init__.py +12 -0
- julee/contrib/polling/infrastructure/services/polling/__init__.py +12 -0
- julee/contrib/polling/infrastructure/services/polling/http/__init__.py +12 -0
- julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +80 -0
- julee/contrib/polling/infrastructure/temporal/__init__.py +20 -0
- julee/contrib/polling/infrastructure/temporal/activities.py +42 -0
- julee/contrib/polling/infrastructure/temporal/activity_names.py +20 -0
- julee/contrib/polling/infrastructure/temporal/proxies.py +45 -0
- julee/contrib/polling/tests/__init__.py +6 -0
- julee/contrib/polling/tests/unit/__init__.py +6 -0
- julee/contrib/polling/tests/unit/infrastructure/__init__.py +7 -0
- julee/contrib/polling/tests/unit/infrastructure/services/__init__.py +6 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
- julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +163 -0
- julee/docs/__init__.py +5 -0
- julee/docs/sphinx_hcd/__init__.py +82 -0
- julee/docs/sphinx_hcd/accelerators.py +1078 -0
- julee/docs/sphinx_hcd/apps.py +499 -0
- julee/docs/sphinx_hcd/config.py +148 -0
- julee/docs/sphinx_hcd/epics.py +448 -0
- julee/docs/sphinx_hcd/integrations.py +306 -0
- julee/docs/sphinx_hcd/journeys.py +783 -0
- julee/docs/sphinx_hcd/personas.py +435 -0
- julee/docs/sphinx_hcd/stories.py +932 -0
- julee/docs/sphinx_hcd/utils.py +180 -0
- julee/domain/models/__init__.py +5 -6
- julee/domain/models/assembly/assembly.py +7 -7
- julee/domain/models/assembly/tests/factories.py +2 -1
- julee/domain/models/assembly/tests/test_assembly.py +16 -13
- julee/domain/models/assembly_specification/assembly_specification.py +11 -10
- julee/domain/models/assembly_specification/knowledge_service_query.py +14 -9
- julee/domain/models/assembly_specification/tests/factories.py +2 -1
- julee/domain/models/assembly_specification/tests/test_assembly_specification.py +9 -6
- julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +3 -1
- julee/domain/models/custom_fields/content_stream.py +3 -2
- julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -1
- julee/domain/models/document/document.py +12 -10
- julee/domain/models/document/tests/factories.py +3 -2
- julee/domain/models/document/tests/test_document.py +6 -3
- julee/domain/models/knowledge_service_config/knowledge_service_config.py +4 -4
- julee/domain/models/policy/__init__.py +4 -4
- julee/domain/models/policy/document_policy_validation.py +17 -17
- julee/domain/models/policy/policy.py +12 -10
- julee/domain/models/policy/tests/factories.py +2 -1
- julee/domain/models/policy/tests/test_document_policy_validation.py +3 -1
- julee/domain/models/policy/tests/test_policy.py +2 -1
- julee/domain/repositories/__init__.py +3 -3
- julee/domain/repositories/assembly.py +3 -1
- julee/domain/repositories/assembly_specification.py +2 -0
- julee/domain/repositories/base.py +33 -16
- julee/domain/repositories/document.py +3 -1
- julee/domain/repositories/document_policy_validation.py +3 -1
- julee/domain/repositories/knowledge_service_config.py +2 -0
- julee/domain/repositories/knowledge_service_query.py +1 -0
- julee/domain/repositories/policy.py +3 -1
- julee/domain/use_cases/decorators.py +3 -2
- julee/domain/use_cases/extract_assemble_data.py +23 -13
- julee/domain/use_cases/initialize_system_data.py +13 -13
- julee/domain/use_cases/tests/test_extract_assemble_data.py +10 -10
- julee/domain/use_cases/tests/test_initialize_system_data.py +2 -2
- julee/domain/use_cases/tests/test_validate_document.py +11 -11
- julee/domain/use_cases/validate_document.py +26 -15
- julee/maintenance/__init__.py +1 -0
- julee/maintenance/release.py +188 -0
- julee/repositories/__init__.py +4 -1
- julee/repositories/memory/assembly.py +6 -5
- julee/repositories/memory/assembly_specification.py +9 -9
- julee/repositories/memory/base.py +12 -11
- julee/repositories/memory/document.py +8 -7
- julee/repositories/memory/document_policy_validation.py +7 -6
- julee/repositories/memory/knowledge_service_config.py +9 -7
- julee/repositories/memory/knowledge_service_query.py +9 -7
- julee/repositories/memory/policy.py +6 -5
- julee/repositories/memory/tests/test_document.py +6 -4
- julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
- julee/repositories/memory/tests/test_policy.py +2 -1
- julee/repositories/minio/assembly.py +4 -4
- julee/repositories/minio/assembly_specification.py +6 -8
- julee/repositories/minio/client.py +22 -25
- julee/repositories/minio/document.py +11 -11
- julee/repositories/minio/document_policy_validation.py +5 -5
- julee/repositories/minio/knowledge_service_config.py +8 -8
- julee/repositories/minio/knowledge_service_query.py +6 -9
- julee/repositories/minio/policy.py +4 -4
- julee/repositories/minio/tests/fake_client.py +11 -9
- julee/repositories/minio/tests/test_assembly.py +3 -1
- julee/repositories/minio/tests/test_assembly_specification.py +2 -1
- julee/repositories/minio/tests/test_client_protocol.py +5 -5
- julee/repositories/minio/tests/test_document.py +7 -6
- julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
- julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
- julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
- julee/repositories/minio/tests/test_policy.py +3 -1
- julee/repositories/temporal/activities.py +5 -5
- julee/repositories/temporal/proxies.py +5 -5
- julee/services/knowledge_service/__init__.py +1 -2
- julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
- julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
- julee/services/knowledge_service/factory.py +8 -8
- julee/services/knowledge_service/knowledge_service.py +22 -18
- julee/services/knowledge_service/memory/knowledge_service.py +13 -12
- julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
- julee/services/knowledge_service/test_factory.py +11 -10
- julee/services/temporal/activities.py +10 -10
- julee/services/temporal/proxies.py +2 -2
- julee/util/domain.py +6 -6
- julee/util/repos/minio/file_storage.py +8 -9
- julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
- julee/util/repos/temporal/data_converter.py +6 -6
- julee/util/repos/temporal/minio_file_storage.py +1 -1
- julee/util/repos/temporal/proxies/file_storage.py +2 -3
- julee/util/repositories.py +6 -7
- julee/util/temporal/activities.py +1 -1
- julee/util/temporal/decorators.py +28 -33
- julee/util/tests/test_decorators.py +13 -15
- julee/util/validation/repository.py +3 -3
- julee/util/validation/type_guards.py +12 -11
- julee/worker.py +9 -8
- julee/workflows/__init__.py +2 -2
- julee/workflows/extract_assemble.py +2 -1
- julee/workflows/validate_document.py +3 -2
- {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/METADATA +4 -1
- julee-0.1.3.dist-info/RECORD +197 -0
- julee-0.1.1.dist-info/RECORD +0 -161
- {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
- {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"""Sphinx extension to index and render Gherkin feature files as user stories.
|
|
2
|
+
|
|
3
|
+
Provides directives:
|
|
4
|
+
- story-app: Full rendering of stories for an app (with anchors)
|
|
5
|
+
- story-list-for-persona: List of stories for a persona
|
|
6
|
+
- story-list-for-app: List of stories for an app
|
|
7
|
+
- story-index: Toctree-style index of per-app story pages
|
|
8
|
+
- stories: Render specific stories by name
|
|
9
|
+
- story: Single story reference
|
|
10
|
+
|
|
11
|
+
Legacy aliases (deprecated, emit warnings):
|
|
12
|
+
- gherkin-app-stories -> story-app
|
|
13
|
+
- gherkin-stories-for-persona -> story-list-for-persona
|
|
14
|
+
- gherkin-stories-for-app -> story-list-for-app
|
|
15
|
+
- gherkin-stories-index -> story-index
|
|
16
|
+
- gherkin-stories -> stories
|
|
17
|
+
- gherkin-story -> story
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import warnings
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from docutils import nodes
|
|
25
|
+
from sphinx.util.docutils import SphinxDirective
|
|
26
|
+
from sphinx.util import logging
|
|
27
|
+
|
|
28
|
+
from .config import get_config
|
|
29
|
+
from .utils import normalize_name, slugify, path_to_root
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Global registry populated at build init
|
|
34
|
+
_story_registry: list[dict] = []
|
|
35
|
+
_known_apps: set[str] = set()
|
|
36
|
+
_known_personas: set[str] = set()
|
|
37
|
+
_apps_with_stories: set[str] = set()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_story_registry() -> list[dict]:
|
|
41
|
+
"""Get the story registry."""
|
|
42
|
+
return _story_registry
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_known_apps() -> set[str]:
|
|
46
|
+
"""Get set of known app names (normalized)."""
|
|
47
|
+
return _known_apps
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_known_personas() -> set[str]:
|
|
51
|
+
"""Get set of known persona names (normalized)."""
|
|
52
|
+
return _known_personas
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_apps_with_stories() -> set[str]:
|
|
56
|
+
"""Get set of apps that have stories."""
|
|
57
|
+
return _apps_with_stories
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_epics_for_story(story_title: str, env) -> list[str]:
|
|
61
|
+
"""Find epics that reference this story."""
|
|
62
|
+
from . import epics
|
|
63
|
+
epic_registry = epics.get_epic_registry(env)
|
|
64
|
+
story_normalized = normalize_name(story_title)
|
|
65
|
+
|
|
66
|
+
matching_epics = []
|
|
67
|
+
for slug, epic in epic_registry.items():
|
|
68
|
+
for epic_story in epic.get('stories', []):
|
|
69
|
+
if normalize_name(epic_story) == story_normalized:
|
|
70
|
+
matching_epics.append(slug)
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
return sorted(matching_epics)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_journeys_for_story(story_title: str, env) -> list[str]:
|
|
77
|
+
"""Find journeys that reference this story (directly or via epic)."""
|
|
78
|
+
from . import journeys
|
|
79
|
+
journey_registry = journeys.get_journey_registry(env)
|
|
80
|
+
story_normalized = normalize_name(story_title)
|
|
81
|
+
|
|
82
|
+
matching_journeys = []
|
|
83
|
+
for slug, journey in journey_registry.items():
|
|
84
|
+
for step in journey.get('steps', []):
|
|
85
|
+
if step.get('type') == 'story':
|
|
86
|
+
if normalize_name(step['ref']) == story_normalized:
|
|
87
|
+
matching_journeys.append(slug)
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
return sorted(matching_journeys)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_story_seealso(story: dict, env, docname: str):
|
|
94
|
+
"""Build seealso block with links to related persona, app, epics, and journeys."""
|
|
95
|
+
config = get_config()
|
|
96
|
+
prefix = path_to_root(docname)
|
|
97
|
+
|
|
98
|
+
links = []
|
|
99
|
+
|
|
100
|
+
# Persona link
|
|
101
|
+
persona = story.get('persona')
|
|
102
|
+
if persona and persona != 'unknown':
|
|
103
|
+
persona_slug = slugify(persona)
|
|
104
|
+
persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
|
|
105
|
+
links.append(('Persona', persona, persona_path))
|
|
106
|
+
|
|
107
|
+
# App link
|
|
108
|
+
app = story.get('app')
|
|
109
|
+
if app:
|
|
110
|
+
app_path = f"{prefix}{config.get_doc_path('applications')}/{app}.html"
|
|
111
|
+
links.append(('App', app.replace("-", " ").title(), app_path))
|
|
112
|
+
|
|
113
|
+
# Epic links
|
|
114
|
+
epics_list = get_epics_for_story(story['feature'], env)
|
|
115
|
+
for epic_slug in epics_list:
|
|
116
|
+
epic_title = epic_slug.replace("-", " ").title()
|
|
117
|
+
epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic_slug}.html"
|
|
118
|
+
links.append(('Epic', epic_title, epic_path))
|
|
119
|
+
|
|
120
|
+
# Journey links
|
|
121
|
+
journeys_list = get_journeys_for_story(story['feature'], env)
|
|
122
|
+
for journey_slug in journeys_list:
|
|
123
|
+
journey_title = journey_slug.replace("-", " ").title()
|
|
124
|
+
journey_path = f"{prefix}{config.get_doc_path('journeys')}/{journey_slug}.html"
|
|
125
|
+
links.append(('Journey', journey_title, journey_path))
|
|
126
|
+
|
|
127
|
+
if not links:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# Build seealso block with line_block for tight spacing
|
|
131
|
+
seealso = nodes.admonition(classes=['seealso'])
|
|
132
|
+
seealso += nodes.title(text='See also')
|
|
133
|
+
|
|
134
|
+
line_block = nodes.line_block()
|
|
135
|
+
for link_type, link_text, link_path in links:
|
|
136
|
+
line = nodes.line()
|
|
137
|
+
line += nodes.strong(text=f"{link_type}: ")
|
|
138
|
+
ref = nodes.reference("", "", refuri=link_path)
|
|
139
|
+
ref += nodes.Text(link_text)
|
|
140
|
+
line += ref
|
|
141
|
+
line_block += line
|
|
142
|
+
|
|
143
|
+
seealso += line_block
|
|
144
|
+
return seealso
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class StorySeeAlsoPlaceholder(nodes.General, nodes.Element):
|
|
148
|
+
"""Placeholder for story seealso block, replaced at doctree-read."""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def scan_feature_files(app):
|
|
153
|
+
"""Scan tests/e2e/**/features/*.feature and build the story registry."""
|
|
154
|
+
global _story_registry, _apps_with_stories
|
|
155
|
+
_story_registry = []
|
|
156
|
+
_apps_with_stories = set()
|
|
157
|
+
|
|
158
|
+
config = get_config()
|
|
159
|
+
project_root = config.project_root
|
|
160
|
+
tests_dir = config.get_path('feature_files')
|
|
161
|
+
|
|
162
|
+
if not tests_dir.exists():
|
|
163
|
+
logger.info(f"Feature files directory not found at {tests_dir} - no stories to index")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Scan for feature files
|
|
167
|
+
for feature_file in tests_dir.rglob("*.feature"):
|
|
168
|
+
rel_path = feature_file.relative_to(project_root)
|
|
169
|
+
|
|
170
|
+
# Extract app name from path: tests/e2e/{app}/features/{name}.feature
|
|
171
|
+
parts = rel_path.parts
|
|
172
|
+
if len(parts) >= 4 and parts[2] != "features":
|
|
173
|
+
app_name = parts[2] # e.g., "staff-portal"
|
|
174
|
+
else:
|
|
175
|
+
app_name = "unknown"
|
|
176
|
+
|
|
177
|
+
# Parse the feature file
|
|
178
|
+
try:
|
|
179
|
+
with open(feature_file) as f:
|
|
180
|
+
content = f.read()
|
|
181
|
+
lines = content.split('\n')
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.warning(f"Could not read {feature_file}: {e}")
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Extract header components
|
|
187
|
+
feature_match = re.search(r"^Feature:\s*(.+)$", content, re.MULTILINE)
|
|
188
|
+
as_a_match = re.search(r"^\s*As an?\s+(.+)$", content, re.MULTILINE)
|
|
189
|
+
i_want_match = re.search(r"^\s*I want to\s+(.+)$", content, re.MULTILINE)
|
|
190
|
+
so_that_match = re.search(r"^\s*So that\s+(.+)$", content, re.MULTILINE)
|
|
191
|
+
|
|
192
|
+
# Extract Gherkin snippet (user story header only, stop before Background/Scenario)
|
|
193
|
+
snippet_lines = []
|
|
194
|
+
for line in lines:
|
|
195
|
+
stripped = line.strip()
|
|
196
|
+
if stripped.startswith(('Scenario', 'Background', '@', 'Given', 'When', 'Then', 'And', 'But')):
|
|
197
|
+
break
|
|
198
|
+
if stripped:
|
|
199
|
+
snippet_lines.append(line)
|
|
200
|
+
gherkin_snippet = '\n'.join(snippet_lines)
|
|
201
|
+
|
|
202
|
+
feature_title = feature_match.group(1) if feature_match else "Unknown"
|
|
203
|
+
story = {
|
|
204
|
+
"app": app_name,
|
|
205
|
+
"app_normalized": normalize_name(app_name),
|
|
206
|
+
"feature": feature_title,
|
|
207
|
+
"slug": slugify(feature_title),
|
|
208
|
+
"persona": as_a_match.group(1) if as_a_match else "unknown",
|
|
209
|
+
"persona_normalized": normalize_name(as_a_match.group(1)) if as_a_match else "unknown",
|
|
210
|
+
"i_want": i_want_match.group(1) if i_want_match else "do something",
|
|
211
|
+
"so_that": so_that_match.group(1) if so_that_match else "achieve a goal",
|
|
212
|
+
"path": str(rel_path),
|
|
213
|
+
"abs_path": str(feature_file),
|
|
214
|
+
"gherkin_snippet": gherkin_snippet,
|
|
215
|
+
}
|
|
216
|
+
_story_registry.append(story)
|
|
217
|
+
_apps_with_stories.add(app_name)
|
|
218
|
+
|
|
219
|
+
logger.info(f"Indexed {len(_story_registry)} Gherkin stories")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def scan_known_entities(app):
|
|
223
|
+
"""Scan docs to find known applications and personas."""
|
|
224
|
+
global _known_apps, _known_personas
|
|
225
|
+
_known_apps = set()
|
|
226
|
+
_known_personas = set()
|
|
227
|
+
|
|
228
|
+
config = get_config()
|
|
229
|
+
docs_dir = config.docs_dir
|
|
230
|
+
|
|
231
|
+
# Scan applications
|
|
232
|
+
apps_dir = docs_dir / config.get_doc_path('applications')
|
|
233
|
+
if apps_dir.exists():
|
|
234
|
+
for rst_file in apps_dir.glob("*.rst"):
|
|
235
|
+
if rst_file.name != "index.rst":
|
|
236
|
+
app_name = rst_file.stem
|
|
237
|
+
_known_apps.add(normalize_name(app_name))
|
|
238
|
+
|
|
239
|
+
# Scan personas
|
|
240
|
+
personas_dir = docs_dir / config.get_doc_path('personas')
|
|
241
|
+
if personas_dir.exists():
|
|
242
|
+
for rst_file in personas_dir.glob("*.rst"):
|
|
243
|
+
if rst_file.name != "index.rst":
|
|
244
|
+
persona_name = rst_file.stem
|
|
245
|
+
_known_personas.add(normalize_name(persona_name))
|
|
246
|
+
|
|
247
|
+
logger.info(f"Found {len(_known_apps)} apps: {_known_apps}")
|
|
248
|
+
logger.info(f"Found {len(_known_personas)} personas: {_known_personas}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def builder_inited(app):
|
|
252
|
+
"""Called when builder is initialized - scan and index feature files."""
|
|
253
|
+
scan_feature_files(app)
|
|
254
|
+
scan_known_entities(app)
|
|
255
|
+
|
|
256
|
+
# Collect apps and personas that have stories
|
|
257
|
+
apps_with_stories = set()
|
|
258
|
+
personas_with_stories = set()
|
|
259
|
+
unknown_apps = set()
|
|
260
|
+
unknown_personas = set()
|
|
261
|
+
|
|
262
|
+
for story in _story_registry:
|
|
263
|
+
apps_with_stories.add(story["app_normalized"])
|
|
264
|
+
personas_with_stories.add(story["persona_normalized"])
|
|
265
|
+
|
|
266
|
+
if story["app_normalized"] not in _known_apps:
|
|
267
|
+
unknown_apps.add(story["app"])
|
|
268
|
+
if story["persona_normalized"] not in _known_personas:
|
|
269
|
+
unknown_personas.add(story["persona"])
|
|
270
|
+
|
|
271
|
+
# Warn about stories referencing undocumented entities
|
|
272
|
+
for app_name in sorted(unknown_apps):
|
|
273
|
+
logger.warning(f"Gherkin story references undocumented application: '{app_name}'")
|
|
274
|
+
for persona in sorted(unknown_personas):
|
|
275
|
+
logger.warning(f"Gherkin story references undocumented persona: '{persona}'")
|
|
276
|
+
|
|
277
|
+
# Warn about documented entities with no stories
|
|
278
|
+
apps_without_stories = _known_apps - apps_with_stories
|
|
279
|
+
personas_without_stories = _known_personas - personas_with_stories
|
|
280
|
+
|
|
281
|
+
for app_name in sorted(apps_without_stories):
|
|
282
|
+
logger.info(f"Application '{app_name}' has no Gherkin stories yet")
|
|
283
|
+
for persona in sorted(personas_without_stories):
|
|
284
|
+
logger.info(f"Persona '{persona}' has no Gherkin stories yet")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_story_ref_target(story: dict, from_docname: str) -> tuple[str, str]:
|
|
288
|
+
"""Get the reference target for a story from a given document.
|
|
289
|
+
|
|
290
|
+
Returns (docname, anchor) tuple for the story's location on its app's story page.
|
|
291
|
+
"""
|
|
292
|
+
config = get_config()
|
|
293
|
+
app_slug = story["app"]
|
|
294
|
+
story_slug = story["slug"]
|
|
295
|
+
return f"{config.get_doc_path('stories')}/{app_slug}", story_slug
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def make_story_reference(story: dict, from_docname: str, link_text: str | None = None) -> nodes.reference:
|
|
299
|
+
"""Create a reference node linking to a story's anchor on its app page."""
|
|
300
|
+
target_doc, anchor = get_story_ref_target(story, from_docname)
|
|
301
|
+
|
|
302
|
+
# Calculate relative path from current doc to target
|
|
303
|
+
from_parts = from_docname.split('/')
|
|
304
|
+
target_parts = target_doc.split('/')
|
|
305
|
+
|
|
306
|
+
# Find common prefix
|
|
307
|
+
common = 0
|
|
308
|
+
for i in range(min(len(from_parts), len(target_parts))):
|
|
309
|
+
if from_parts[i] == target_parts[i]:
|
|
310
|
+
common += 1
|
|
311
|
+
else:
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
# Build relative path
|
|
315
|
+
up_levels = len(from_parts) - common - 1
|
|
316
|
+
down_path = '/'.join(target_parts[common:])
|
|
317
|
+
|
|
318
|
+
if up_levels > 0:
|
|
319
|
+
rel_path = '../' * up_levels + down_path + '.html'
|
|
320
|
+
else:
|
|
321
|
+
rel_path = down_path + '.html'
|
|
322
|
+
|
|
323
|
+
ref_uri = f"{rel_path}#{anchor}"
|
|
324
|
+
|
|
325
|
+
ref = nodes.reference("", "", refuri=ref_uri)
|
|
326
|
+
ref += nodes.Text(link_text or story["i_want"])
|
|
327
|
+
return ref
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class StoryAppDirective(SphinxDirective):
|
|
331
|
+
"""Render all stories for an application with full details and anchors.
|
|
332
|
+
|
|
333
|
+
Usage::
|
|
334
|
+
|
|
335
|
+
.. story-app:: staff-portal
|
|
336
|
+
|
|
337
|
+
Renders stories grouped by persona, each with:
|
|
338
|
+
- Heading with anchor
|
|
339
|
+
- Gherkin snippet
|
|
340
|
+
- Feature file path
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
required_arguments = 1
|
|
344
|
+
|
|
345
|
+
def run(self):
|
|
346
|
+
config = get_config()
|
|
347
|
+
app_arg = self.arguments[0]
|
|
348
|
+
app_normalized = normalize_name(app_arg)
|
|
349
|
+
|
|
350
|
+
# Filter stories for this app
|
|
351
|
+
stories = [s for s in _story_registry
|
|
352
|
+
if s["app_normalized"] == app_normalized]
|
|
353
|
+
|
|
354
|
+
if not stories:
|
|
355
|
+
para = nodes.paragraph()
|
|
356
|
+
para += nodes.emphasis(text=f"No stories found for application '{app_arg}'")
|
|
357
|
+
return [para]
|
|
358
|
+
|
|
359
|
+
# Calculate relative paths
|
|
360
|
+
docname = self.env.docname
|
|
361
|
+
prefix = path_to_root(docname)
|
|
362
|
+
|
|
363
|
+
# Group stories by persona
|
|
364
|
+
by_persona = defaultdict(list)
|
|
365
|
+
for story in stories:
|
|
366
|
+
by_persona[story["persona"]].append(story)
|
|
367
|
+
|
|
368
|
+
result_nodes = []
|
|
369
|
+
|
|
370
|
+
# Build intro paragraph with app link and persona breakdown
|
|
371
|
+
persona_count = len(by_persona)
|
|
372
|
+
total_stories = len(stories)
|
|
373
|
+
app_display = app_arg.replace("-", " ").title()
|
|
374
|
+
app_path = f"{prefix}{config.get_doc_path('applications')}/{app_arg}.html"
|
|
375
|
+
app_valid = app_normalized in _known_apps
|
|
376
|
+
|
|
377
|
+
intro_para = nodes.paragraph()
|
|
378
|
+
intro_para += nodes.Text("The ")
|
|
379
|
+
|
|
380
|
+
if app_valid:
|
|
381
|
+
app_ref = nodes.reference("", "", refuri=app_path)
|
|
382
|
+
app_ref += nodes.Text(app_display)
|
|
383
|
+
intro_para += app_ref
|
|
384
|
+
else:
|
|
385
|
+
intro_para += nodes.Text(app_display)
|
|
386
|
+
|
|
387
|
+
if total_stories == 1:
|
|
388
|
+
intro_para += nodes.Text(" has one story for ")
|
|
389
|
+
else:
|
|
390
|
+
intro_para += nodes.Text(f" has {total_stories} stories ")
|
|
391
|
+
|
|
392
|
+
if persona_count == 1:
|
|
393
|
+
# Single persona
|
|
394
|
+
persona = list(by_persona.keys())[0]
|
|
395
|
+
persona_valid = normalize_name(persona) in _known_personas
|
|
396
|
+
persona_slug = persona.lower().replace(" ", "-")
|
|
397
|
+
persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
|
|
398
|
+
|
|
399
|
+
if total_stories != 1:
|
|
400
|
+
intro_para += nodes.Text("for ")
|
|
401
|
+
|
|
402
|
+
if persona_valid:
|
|
403
|
+
persona_ref = nodes.reference("", "", refuri=persona_path)
|
|
404
|
+
persona_ref += nodes.Text(persona)
|
|
405
|
+
intro_para += persona_ref
|
|
406
|
+
else:
|
|
407
|
+
intro_para += nodes.Text(persona)
|
|
408
|
+
|
|
409
|
+
intro_para += nodes.Text(".")
|
|
410
|
+
else:
|
|
411
|
+
# Multiple personas - list them with counts
|
|
412
|
+
intro_para += nodes.Text(f"across {persona_count} personas: ")
|
|
413
|
+
|
|
414
|
+
sorted_personas = sorted(by_persona.keys())
|
|
415
|
+
for i, persona in enumerate(sorted_personas):
|
|
416
|
+
count = len(by_persona[persona])
|
|
417
|
+
persona_valid = normalize_name(persona) in _known_personas
|
|
418
|
+
persona_slug = persona.lower().replace(" ", "-")
|
|
419
|
+
persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
|
|
420
|
+
|
|
421
|
+
if persona_valid:
|
|
422
|
+
persona_ref = nodes.reference("", "", refuri=persona_path)
|
|
423
|
+
persona_ref += nodes.Text(persona)
|
|
424
|
+
intro_para += persona_ref
|
|
425
|
+
else:
|
|
426
|
+
intro_para += nodes.Text(persona)
|
|
427
|
+
|
|
428
|
+
intro_para += nodes.Text(f" ({count})")
|
|
429
|
+
|
|
430
|
+
if i < len(sorted_personas) - 1:
|
|
431
|
+
intro_para += nodes.Text(", ")
|
|
432
|
+
else:
|
|
433
|
+
intro_para += nodes.Text(".")
|
|
434
|
+
|
|
435
|
+
result_nodes.append(intro_para)
|
|
436
|
+
|
|
437
|
+
for persona in sorted(by_persona.keys()):
|
|
438
|
+
persona_stories = by_persona[persona]
|
|
439
|
+
persona_slug_id = slugify(persona)
|
|
440
|
+
|
|
441
|
+
# Persona section
|
|
442
|
+
persona_section = nodes.section(ids=[persona_slug_id])
|
|
443
|
+
|
|
444
|
+
# Persona title (plain text, no count)
|
|
445
|
+
persona_title = nodes.title(text=persona)
|
|
446
|
+
persona_section += persona_title
|
|
447
|
+
|
|
448
|
+
# Stories for this persona
|
|
449
|
+
for story in sorted(persona_stories, key=lambda s: s["feature"]):
|
|
450
|
+
# Story section with anchor
|
|
451
|
+
story_section = nodes.section(ids=[story["slug"]])
|
|
452
|
+
|
|
453
|
+
# Title
|
|
454
|
+
title = nodes.title(text=story["feature"])
|
|
455
|
+
story_section += title
|
|
456
|
+
|
|
457
|
+
# Gherkin snippet as literal block
|
|
458
|
+
snippet = nodes.literal_block(text=story["gherkin_snippet"])
|
|
459
|
+
snippet['language'] = 'gherkin'
|
|
460
|
+
story_section += snippet
|
|
461
|
+
|
|
462
|
+
# Feature file path (for reference, not as broken link)
|
|
463
|
+
path_para = nodes.paragraph()
|
|
464
|
+
path_para += nodes.strong(text="Feature file: ")
|
|
465
|
+
path_para += nodes.literal(text=story["path"])
|
|
466
|
+
story_section += path_para
|
|
467
|
+
|
|
468
|
+
# Placeholder for seealso (filled in doctree-read when registries are complete)
|
|
469
|
+
seealso_placeholder = StorySeeAlsoPlaceholder()
|
|
470
|
+
seealso_placeholder['story_feature'] = story["feature"]
|
|
471
|
+
seealso_placeholder['story_persona'] = story["persona"]
|
|
472
|
+
seealso_placeholder['story_app'] = story["app"]
|
|
473
|
+
story_section += seealso_placeholder
|
|
474
|
+
|
|
475
|
+
persona_section += story_section
|
|
476
|
+
|
|
477
|
+
result_nodes.append(persona_section)
|
|
478
|
+
|
|
479
|
+
return result_nodes
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class StoryListForPersonaDirective(SphinxDirective):
|
|
483
|
+
"""Render stories for a specific persona as a simple bullet list.
|
|
484
|
+
|
|
485
|
+
Usage::
|
|
486
|
+
|
|
487
|
+
.. story-list-for-persona:: Pilot Manager
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
required_arguments = 1
|
|
491
|
+
final_argument_whitespace = True
|
|
492
|
+
|
|
493
|
+
def run(self):
|
|
494
|
+
config = get_config()
|
|
495
|
+
persona_arg = self.arguments[0]
|
|
496
|
+
persona_normalized = normalize_name(persona_arg)
|
|
497
|
+
|
|
498
|
+
# Filter stories for this persona
|
|
499
|
+
stories = [s for s in _story_registry
|
|
500
|
+
if s["persona_normalized"] == persona_normalized]
|
|
501
|
+
|
|
502
|
+
if not stories:
|
|
503
|
+
para = nodes.paragraph()
|
|
504
|
+
para += nodes.emphasis(text=f"No stories found for persona '{persona_arg}'")
|
|
505
|
+
return [para]
|
|
506
|
+
|
|
507
|
+
# Calculate relative paths
|
|
508
|
+
docname = self.env.docname
|
|
509
|
+
prefix = path_to_root(docname)
|
|
510
|
+
|
|
511
|
+
result_nodes = []
|
|
512
|
+
|
|
513
|
+
# Simple bullet list: "story name (App Name)"
|
|
514
|
+
story_list = nodes.bullet_list()
|
|
515
|
+
|
|
516
|
+
for story in sorted(stories, key=lambda s: s['feature'].lower()):
|
|
517
|
+
story_item = nodes.list_item()
|
|
518
|
+
story_para = nodes.paragraph()
|
|
519
|
+
|
|
520
|
+
# Story link
|
|
521
|
+
story_para += make_story_reference(story, docname)
|
|
522
|
+
|
|
523
|
+
# App in parentheses
|
|
524
|
+
story_para += nodes.Text(" (")
|
|
525
|
+
app_path = f"{prefix}{config.get_doc_path('applications')}/{story['app']}.html"
|
|
526
|
+
app_valid = normalize_name(story['app']) in _known_apps
|
|
527
|
+
|
|
528
|
+
if app_valid:
|
|
529
|
+
app_ref = nodes.reference("", "", refuri=app_path)
|
|
530
|
+
app_ref += nodes.Text(story['app'].replace("-", " ").title())
|
|
531
|
+
story_para += app_ref
|
|
532
|
+
else:
|
|
533
|
+
story_para += nodes.Text(story['app'].replace("-", " ").title())
|
|
534
|
+
|
|
535
|
+
story_para += nodes.Text(")")
|
|
536
|
+
|
|
537
|
+
story_item += story_para
|
|
538
|
+
story_list += story_item
|
|
539
|
+
|
|
540
|
+
result_nodes.append(story_list)
|
|
541
|
+
|
|
542
|
+
return result_nodes
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class StoryListForAppDirective(SphinxDirective):
|
|
546
|
+
"""Render stories for a specific application, grouped by persona then benefit.
|
|
547
|
+
|
|
548
|
+
Usage::
|
|
549
|
+
|
|
550
|
+
.. story-list-for-app:: staff-portal
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
required_arguments = 1
|
|
554
|
+
|
|
555
|
+
def run(self):
|
|
556
|
+
config = get_config()
|
|
557
|
+
app_arg = self.arguments[0]
|
|
558
|
+
app_normalized = normalize_name(app_arg)
|
|
559
|
+
|
|
560
|
+
# Filter stories for this app
|
|
561
|
+
stories = [s for s in _story_registry
|
|
562
|
+
if s["app_normalized"] == app_normalized]
|
|
563
|
+
|
|
564
|
+
if not stories:
|
|
565
|
+
para = nodes.paragraph()
|
|
566
|
+
para += nodes.emphasis(text=f"No stories found for application '{app_arg}'")
|
|
567
|
+
return [para]
|
|
568
|
+
|
|
569
|
+
# Calculate relative paths
|
|
570
|
+
docname = self.env.docname
|
|
571
|
+
prefix = path_to_root(docname)
|
|
572
|
+
|
|
573
|
+
# Group by persona, then by benefit
|
|
574
|
+
by_persona = defaultdict(lambda: defaultdict(list))
|
|
575
|
+
for story in stories:
|
|
576
|
+
by_persona[story["persona"]][story["so_that"]].append(story)
|
|
577
|
+
|
|
578
|
+
result_nodes = []
|
|
579
|
+
|
|
580
|
+
for persona in sorted(by_persona.keys()):
|
|
581
|
+
benefits = by_persona[persona]
|
|
582
|
+
persona_valid = normalize_name(persona) in _known_personas
|
|
583
|
+
|
|
584
|
+
# Persona heading (strong with link)
|
|
585
|
+
persona_heading = nodes.paragraph()
|
|
586
|
+
persona_slug = persona.lower().replace(" ", "-")
|
|
587
|
+
persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
|
|
588
|
+
|
|
589
|
+
if persona_valid:
|
|
590
|
+
persona_ref = nodes.reference("", "", refuri=persona_path)
|
|
591
|
+
persona_ref += nodes.strong(text=persona)
|
|
592
|
+
persona_heading += persona_ref
|
|
593
|
+
else:
|
|
594
|
+
persona_heading += nodes.strong(text=persona)
|
|
595
|
+
persona_heading += nodes.emphasis(text=" (?)")
|
|
596
|
+
|
|
597
|
+
result_nodes.append(persona_heading)
|
|
598
|
+
|
|
599
|
+
# Outer bullet list for benefits
|
|
600
|
+
benefit_list = nodes.bullet_list()
|
|
601
|
+
|
|
602
|
+
for benefit in sorted(benefits.keys()):
|
|
603
|
+
benefit_stories = benefits[benefit]
|
|
604
|
+
|
|
605
|
+
# Benefit list item
|
|
606
|
+
benefit_item = nodes.list_item()
|
|
607
|
+
|
|
608
|
+
# Benefit text with "So that" prefix
|
|
609
|
+
benefit_para = nodes.paragraph()
|
|
610
|
+
benefit_para += nodes.Text("So that ")
|
|
611
|
+
benefit_para += nodes.Text(benefit)
|
|
612
|
+
benefit_item += benefit_para
|
|
613
|
+
|
|
614
|
+
# Inner bullet list for features
|
|
615
|
+
feature_list = nodes.bullet_list()
|
|
616
|
+
|
|
617
|
+
for story in sorted(benefit_stories, key=lambda s: s["i_want"]):
|
|
618
|
+
feature_item = nodes.list_item()
|
|
619
|
+
feature_para = nodes.paragraph()
|
|
620
|
+
|
|
621
|
+
# Feature link with "I need to" prefix - links to story anchor
|
|
622
|
+
feature_para += nodes.Text("I need to ")
|
|
623
|
+
feature_para += make_story_reference(story, docname)
|
|
624
|
+
|
|
625
|
+
feature_item += feature_para
|
|
626
|
+
feature_list += feature_item
|
|
627
|
+
|
|
628
|
+
benefit_item += feature_list
|
|
629
|
+
benefit_list += benefit_item
|
|
630
|
+
|
|
631
|
+
result_nodes.append(benefit_list)
|
|
632
|
+
|
|
633
|
+
return result_nodes
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class StoryIndexDirective(SphinxDirective):
|
|
637
|
+
"""Render index pointing to per-app story pages.
|
|
638
|
+
|
|
639
|
+
Usage::
|
|
640
|
+
|
|
641
|
+
.. story-index::
|
|
642
|
+
|
|
643
|
+
Renders a list of links to per-app story pages with story counts.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
def run(self):
|
|
647
|
+
if not _story_registry:
|
|
648
|
+
para = nodes.paragraph()
|
|
649
|
+
para += nodes.emphasis(text="No Gherkin stories found")
|
|
650
|
+
return [para]
|
|
651
|
+
|
|
652
|
+
# Count stories per app
|
|
653
|
+
stories_per_app = defaultdict(int)
|
|
654
|
+
for story in _story_registry:
|
|
655
|
+
stories_per_app[story["app"]] += 1
|
|
656
|
+
|
|
657
|
+
result_nodes = []
|
|
658
|
+
|
|
659
|
+
# Create bullet list of app links
|
|
660
|
+
app_list = nodes.bullet_list()
|
|
661
|
+
|
|
662
|
+
for app in sorted(stories_per_app.keys()):
|
|
663
|
+
count = stories_per_app[app]
|
|
664
|
+
app_item = nodes.list_item()
|
|
665
|
+
app_para = nodes.paragraph()
|
|
666
|
+
|
|
667
|
+
# Link to app's story page
|
|
668
|
+
app_ref = nodes.reference("", "", refuri=f"{app}.html")
|
|
669
|
+
app_ref += nodes.strong(text=app.replace("-", " ").replace("_", " ").title())
|
|
670
|
+
app_para += app_ref
|
|
671
|
+
app_para += nodes.Text(f" ({count} stories)")
|
|
672
|
+
|
|
673
|
+
app_item += app_para
|
|
674
|
+
app_list += app_item
|
|
675
|
+
|
|
676
|
+
result_nodes.append(app_list)
|
|
677
|
+
|
|
678
|
+
return result_nodes
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class StoriesDirective(SphinxDirective):
|
|
682
|
+
"""Render multiple stories grouped by persona and benefit.
|
|
683
|
+
|
|
684
|
+
Usage::
|
|
685
|
+
|
|
686
|
+
.. stories::
|
|
687
|
+
Upload Scheme Documentation
|
|
688
|
+
Add External Standard to Knowledge Base
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
has_content = True
|
|
692
|
+
|
|
693
|
+
def run(self):
|
|
694
|
+
config = get_config()
|
|
695
|
+
|
|
696
|
+
# Parse feature names from content (one per line)
|
|
697
|
+
feature_names = [line.strip() for line in self.content if line.strip()]
|
|
698
|
+
|
|
699
|
+
if not feature_names:
|
|
700
|
+
para = nodes.paragraph()
|
|
701
|
+
para += nodes.emphasis(text="No stories specified")
|
|
702
|
+
return [para]
|
|
703
|
+
|
|
704
|
+
# Look up stories
|
|
705
|
+
stories = []
|
|
706
|
+
not_found = []
|
|
707
|
+
for feature_name in feature_names:
|
|
708
|
+
feature_normalized = normalize_name(feature_name)
|
|
709
|
+
story = None
|
|
710
|
+
for s in _story_registry:
|
|
711
|
+
if normalize_name(s["feature"]) == feature_normalized:
|
|
712
|
+
story = s
|
|
713
|
+
break
|
|
714
|
+
if story:
|
|
715
|
+
stories.append(story)
|
|
716
|
+
else:
|
|
717
|
+
not_found.append(feature_name)
|
|
718
|
+
|
|
719
|
+
# Calculate relative paths
|
|
720
|
+
docname = self.env.docname
|
|
721
|
+
prefix = path_to_root(docname)
|
|
722
|
+
|
|
723
|
+
# Group by persona, then by benefit
|
|
724
|
+
by_persona = defaultdict(lambda: defaultdict(list))
|
|
725
|
+
for story in stories:
|
|
726
|
+
by_persona[story["persona"]][story["so_that"]].append(story)
|
|
727
|
+
|
|
728
|
+
result_nodes = []
|
|
729
|
+
|
|
730
|
+
# Render each persona group
|
|
731
|
+
for persona in sorted(by_persona.keys()):
|
|
732
|
+
benefits = by_persona[persona]
|
|
733
|
+
|
|
734
|
+
# Persona heading (strong)
|
|
735
|
+
persona_heading = nodes.paragraph()
|
|
736
|
+
persona_slug = persona.lower().replace(" ", "-")
|
|
737
|
+
persona_path = f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
|
|
738
|
+
persona_valid = normalize_name(persona) in _known_personas
|
|
739
|
+
|
|
740
|
+
if persona_valid:
|
|
741
|
+
persona_ref = nodes.reference("", "", refuri=persona_path)
|
|
742
|
+
persona_ref += nodes.strong(text=persona)
|
|
743
|
+
persona_heading += persona_ref
|
|
744
|
+
else:
|
|
745
|
+
persona_heading += nodes.strong(text=persona)
|
|
746
|
+
persona_heading += nodes.emphasis(text=" (?)")
|
|
747
|
+
|
|
748
|
+
result_nodes.append(persona_heading)
|
|
749
|
+
|
|
750
|
+
# Outer bullet list for benefits
|
|
751
|
+
benefit_list = nodes.bullet_list()
|
|
752
|
+
|
|
753
|
+
for benefit in sorted(benefits.keys()):
|
|
754
|
+
benefit_stories = benefits[benefit]
|
|
755
|
+
|
|
756
|
+
# Benefit list item
|
|
757
|
+
benefit_item = nodes.list_item()
|
|
758
|
+
|
|
759
|
+
# Benefit text with "So that" prefix
|
|
760
|
+
benefit_para = nodes.paragraph()
|
|
761
|
+
benefit_para += nodes.Text("So that ")
|
|
762
|
+
benefit_para += nodes.Text(benefit)
|
|
763
|
+
benefit_item += benefit_para
|
|
764
|
+
|
|
765
|
+
# Inner bullet list for features
|
|
766
|
+
feature_list = nodes.bullet_list()
|
|
767
|
+
|
|
768
|
+
for story in sorted(benefit_stories, key=lambda s: s["i_want"]):
|
|
769
|
+
feature_item = nodes.list_item()
|
|
770
|
+
feature_para = nodes.paragraph()
|
|
771
|
+
|
|
772
|
+
# Feature link with "I need to" prefix
|
|
773
|
+
feature_para += nodes.Text("I need to ")
|
|
774
|
+
feature_para += make_story_reference(story, docname)
|
|
775
|
+
|
|
776
|
+
# App in parentheses
|
|
777
|
+
feature_para += nodes.Text(" (")
|
|
778
|
+
app_path = f"{prefix}{config.get_doc_path('applications')}/{story['app']}.html"
|
|
779
|
+
app_valid = story["app_normalized"] in _known_apps
|
|
780
|
+
|
|
781
|
+
if app_valid:
|
|
782
|
+
app_ref = nodes.reference("", "", refuri=app_path)
|
|
783
|
+
app_ref += nodes.Text(story["app"].replace("-", " ").title())
|
|
784
|
+
feature_para += app_ref
|
|
785
|
+
else:
|
|
786
|
+
feature_para += nodes.Text(story["app"].replace("-", " ").title())
|
|
787
|
+
feature_para += nodes.emphasis(text=" (?)")
|
|
788
|
+
|
|
789
|
+
feature_para += nodes.Text(")")
|
|
790
|
+
|
|
791
|
+
feature_item += feature_para
|
|
792
|
+
feature_list += feature_item
|
|
793
|
+
|
|
794
|
+
benefit_item += feature_list
|
|
795
|
+
benefit_list += benefit_item
|
|
796
|
+
|
|
797
|
+
result_nodes.append(benefit_list)
|
|
798
|
+
|
|
799
|
+
# Add warnings for not found stories
|
|
800
|
+
if not_found:
|
|
801
|
+
warning_para = nodes.paragraph()
|
|
802
|
+
warning_para += nodes.problematic(
|
|
803
|
+
text=f"[Stories not found: {', '.join(not_found)}]"
|
|
804
|
+
)
|
|
805
|
+
result_nodes.append(warning_para)
|
|
806
|
+
|
|
807
|
+
return result_nodes
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class StoryRefDirective(SphinxDirective):
|
|
811
|
+
"""Render a single story reference.
|
|
812
|
+
|
|
813
|
+
Usage::
|
|
814
|
+
|
|
815
|
+
.. story:: Upload CMA Documents for Analysis
|
|
816
|
+
"""
|
|
817
|
+
|
|
818
|
+
required_arguments = 1
|
|
819
|
+
final_argument_whitespace = True
|
|
820
|
+
|
|
821
|
+
def run(self):
|
|
822
|
+
# Delegate to StoriesDirective with single story
|
|
823
|
+
directive = StoriesDirective(
|
|
824
|
+
self.name,
|
|
825
|
+
[], # arguments
|
|
826
|
+
{}, # options
|
|
827
|
+
[self.arguments[0]], # content - single feature name
|
|
828
|
+
self.lineno,
|
|
829
|
+
self.content_offset,
|
|
830
|
+
self.block_text,
|
|
831
|
+
self.state,
|
|
832
|
+
self.state_machine,
|
|
833
|
+
)
|
|
834
|
+
return directive.run()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# Deprecated alias directives - emit warnings and delegate to new names
|
|
838
|
+
|
|
839
|
+
def _make_deprecated_directive(new_directive_class, old_name: str, new_name: str):
|
|
840
|
+
"""Create a deprecated alias directive that warns and delegates."""
|
|
841
|
+
|
|
842
|
+
class DeprecatedDirective(new_directive_class):
|
|
843
|
+
def run(self):
|
|
844
|
+
logger.warning(
|
|
845
|
+
f"Directive '{old_name}' is deprecated, use '{new_name}' instead. "
|
|
846
|
+
f"(in {self.env.docname})"
|
|
847
|
+
)
|
|
848
|
+
return super().run()
|
|
849
|
+
|
|
850
|
+
return DeprecatedDirective
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def process_story_seealso_placeholders(app, doctree):
|
|
854
|
+
"""Replace story seealso placeholders with actual content.
|
|
855
|
+
|
|
856
|
+
Uses doctree-read event so epic/journey registries are populated.
|
|
857
|
+
"""
|
|
858
|
+
env = app.env
|
|
859
|
+
docname = env.docname
|
|
860
|
+
|
|
861
|
+
for node in doctree.traverse(StorySeeAlsoPlaceholder):
|
|
862
|
+
story_feature = node['story_feature']
|
|
863
|
+
story_persona = node['story_persona']
|
|
864
|
+
story_app = node.get('story_app')
|
|
865
|
+
|
|
866
|
+
# Build a minimal story dict for the helper function
|
|
867
|
+
story = {
|
|
868
|
+
'feature': story_feature,
|
|
869
|
+
'persona': story_persona,
|
|
870
|
+
'app': story_app,
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
seealso = build_story_seealso(story, env, docname)
|
|
874
|
+
if seealso:
|
|
875
|
+
node.replace_self([seealso])
|
|
876
|
+
else:
|
|
877
|
+
node.replace_self([])
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def setup(app):
|
|
881
|
+
app.connect("builder-inited", builder_inited)
|
|
882
|
+
app.connect("doctree-read", process_story_seealso_placeholders)
|
|
883
|
+
|
|
884
|
+
# New directive names
|
|
885
|
+
app.add_directive("story", StoryRefDirective)
|
|
886
|
+
app.add_directive("stories", StoriesDirective)
|
|
887
|
+
app.add_directive("story-list-for-persona", StoryListForPersonaDirective)
|
|
888
|
+
app.add_directive("story-list-for-app", StoryListForAppDirective)
|
|
889
|
+
app.add_directive("story-index", StoryIndexDirective)
|
|
890
|
+
app.add_directive("story-app", StoryAppDirective)
|
|
891
|
+
|
|
892
|
+
# Deprecated aliases (gherkin-* -> story-*)
|
|
893
|
+
app.add_directive(
|
|
894
|
+
"gherkin-story",
|
|
895
|
+
_make_deprecated_directive(StoryRefDirective, "gherkin-story", "story")
|
|
896
|
+
)
|
|
897
|
+
app.add_directive(
|
|
898
|
+
"gherkin-stories",
|
|
899
|
+
_make_deprecated_directive(StoriesDirective, "gherkin-stories", "stories")
|
|
900
|
+
)
|
|
901
|
+
app.add_directive(
|
|
902
|
+
"gherkin-stories-for-persona",
|
|
903
|
+
_make_deprecated_directive(
|
|
904
|
+
StoryListForPersonaDirective,
|
|
905
|
+
"gherkin-stories-for-persona",
|
|
906
|
+
"story-list-for-persona"
|
|
907
|
+
)
|
|
908
|
+
)
|
|
909
|
+
app.add_directive(
|
|
910
|
+
"gherkin-stories-for-app",
|
|
911
|
+
_make_deprecated_directive(
|
|
912
|
+
StoryListForAppDirective,
|
|
913
|
+
"gherkin-stories-for-app",
|
|
914
|
+
"story-list-for-app"
|
|
915
|
+
)
|
|
916
|
+
)
|
|
917
|
+
app.add_directive(
|
|
918
|
+
"gherkin-stories-index",
|
|
919
|
+
_make_deprecated_directive(StoryIndexDirective, "gherkin-stories-index", "story-index")
|
|
920
|
+
)
|
|
921
|
+
app.add_directive(
|
|
922
|
+
"gherkin-app-stories",
|
|
923
|
+
_make_deprecated_directive(StoryAppDirective, "gherkin-app-stories", "story-app")
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
app.add_node(StorySeeAlsoPlaceholder)
|
|
927
|
+
|
|
928
|
+
return {
|
|
929
|
+
"version": "1.0",
|
|
930
|
+
"parallel_read_safe": False, # Uses environment registries
|
|
931
|
+
"parallel_write_safe": True,
|
|
932
|
+
}
|