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.
Files changed (157) hide show
  1. julee/api/app.py +9 -8
  2. julee/api/dependencies.py +15 -15
  3. julee/api/requests.py +10 -9
  4. julee/api/responses.py +2 -1
  5. julee/api/routers/__init__.py +5 -5
  6. julee/api/routers/assembly_specifications.py +5 -4
  7. julee/api/routers/documents.py +1 -1
  8. julee/api/routers/knowledge_service_configs.py +4 -3
  9. julee/api/routers/knowledge_service_queries.py +7 -6
  10. julee/api/routers/system.py +4 -3
  11. julee/api/routers/workflows.py +4 -5
  12. julee/api/services/system_initialization.py +6 -6
  13. julee/api/tests/routers/test_assembly_specifications.py +4 -3
  14. julee/api/tests/routers/test_documents.py +5 -4
  15. julee/api/tests/routers/test_knowledge_service_configs.py +7 -6
  16. julee/api/tests/routers/test_knowledge_service_queries.py +4 -3
  17. julee/api/tests/routers/test_system.py +5 -4
  18. julee/api/tests/routers/test_workflows.py +5 -4
  19. julee/api/tests/test_app.py +5 -4
  20. julee/api/tests/test_dependencies.py +3 -2
  21. julee/api/tests/test_requests.py +2 -1
  22. julee/contrib/__init__.py +15 -0
  23. julee/contrib/polling/__init__.py +47 -0
  24. julee/contrib/polling/domain/__init__.py +17 -0
  25. julee/contrib/polling/domain/models/__init__.py +13 -0
  26. julee/contrib/polling/domain/models/polling_config.py +39 -0
  27. julee/contrib/polling/domain/services/__init__.py +11 -0
  28. julee/contrib/polling/domain/services/poller.py +39 -0
  29. julee/contrib/polling/infrastructure/__init__.py +15 -0
  30. julee/contrib/polling/infrastructure/services/__init__.py +12 -0
  31. julee/contrib/polling/infrastructure/services/polling/__init__.py +12 -0
  32. julee/contrib/polling/infrastructure/services/polling/http/__init__.py +12 -0
  33. julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +80 -0
  34. julee/contrib/polling/infrastructure/temporal/__init__.py +20 -0
  35. julee/contrib/polling/infrastructure/temporal/activities.py +42 -0
  36. julee/contrib/polling/infrastructure/temporal/activity_names.py +20 -0
  37. julee/contrib/polling/infrastructure/temporal/proxies.py +45 -0
  38. julee/contrib/polling/tests/__init__.py +6 -0
  39. julee/contrib/polling/tests/unit/__init__.py +6 -0
  40. julee/contrib/polling/tests/unit/infrastructure/__init__.py +7 -0
  41. julee/contrib/polling/tests/unit/infrastructure/services/__init__.py +6 -0
  42. julee/contrib/polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
  43. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
  44. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +163 -0
  45. julee/docs/__init__.py +5 -0
  46. julee/docs/sphinx_hcd/__init__.py +82 -0
  47. julee/docs/sphinx_hcd/accelerators.py +1078 -0
  48. julee/docs/sphinx_hcd/apps.py +499 -0
  49. julee/docs/sphinx_hcd/config.py +148 -0
  50. julee/docs/sphinx_hcd/epics.py +448 -0
  51. julee/docs/sphinx_hcd/integrations.py +306 -0
  52. julee/docs/sphinx_hcd/journeys.py +783 -0
  53. julee/docs/sphinx_hcd/personas.py +435 -0
  54. julee/docs/sphinx_hcd/stories.py +932 -0
  55. julee/docs/sphinx_hcd/utils.py +180 -0
  56. julee/domain/models/__init__.py +5 -6
  57. julee/domain/models/assembly/assembly.py +7 -7
  58. julee/domain/models/assembly/tests/factories.py +2 -1
  59. julee/domain/models/assembly/tests/test_assembly.py +16 -13
  60. julee/domain/models/assembly_specification/assembly_specification.py +11 -10
  61. julee/domain/models/assembly_specification/knowledge_service_query.py +14 -9
  62. julee/domain/models/assembly_specification/tests/factories.py +2 -1
  63. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +9 -6
  64. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +3 -1
  65. julee/domain/models/custom_fields/content_stream.py +3 -2
  66. julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -1
  67. julee/domain/models/document/document.py +12 -10
  68. julee/domain/models/document/tests/factories.py +3 -2
  69. julee/domain/models/document/tests/test_document.py +6 -3
  70. julee/domain/models/knowledge_service_config/knowledge_service_config.py +4 -4
  71. julee/domain/models/policy/__init__.py +4 -4
  72. julee/domain/models/policy/document_policy_validation.py +17 -17
  73. julee/domain/models/policy/policy.py +12 -10
  74. julee/domain/models/policy/tests/factories.py +2 -1
  75. julee/domain/models/policy/tests/test_document_policy_validation.py +3 -1
  76. julee/domain/models/policy/tests/test_policy.py +2 -1
  77. julee/domain/repositories/__init__.py +3 -3
  78. julee/domain/repositories/assembly.py +3 -1
  79. julee/domain/repositories/assembly_specification.py +2 -0
  80. julee/domain/repositories/base.py +33 -16
  81. julee/domain/repositories/document.py +3 -1
  82. julee/domain/repositories/document_policy_validation.py +3 -1
  83. julee/domain/repositories/knowledge_service_config.py +2 -0
  84. julee/domain/repositories/knowledge_service_query.py +1 -0
  85. julee/domain/repositories/policy.py +3 -1
  86. julee/domain/use_cases/decorators.py +3 -2
  87. julee/domain/use_cases/extract_assemble_data.py +23 -13
  88. julee/domain/use_cases/initialize_system_data.py +13 -13
  89. julee/domain/use_cases/tests/test_extract_assemble_data.py +10 -10
  90. julee/domain/use_cases/tests/test_initialize_system_data.py +2 -2
  91. julee/domain/use_cases/tests/test_validate_document.py +11 -11
  92. julee/domain/use_cases/validate_document.py +26 -15
  93. julee/maintenance/__init__.py +1 -0
  94. julee/maintenance/release.py +188 -0
  95. julee/repositories/__init__.py +4 -1
  96. julee/repositories/memory/assembly.py +6 -5
  97. julee/repositories/memory/assembly_specification.py +9 -9
  98. julee/repositories/memory/base.py +12 -11
  99. julee/repositories/memory/document.py +8 -7
  100. julee/repositories/memory/document_policy_validation.py +7 -6
  101. julee/repositories/memory/knowledge_service_config.py +9 -7
  102. julee/repositories/memory/knowledge_service_query.py +9 -7
  103. julee/repositories/memory/policy.py +6 -5
  104. julee/repositories/memory/tests/test_document.py +6 -4
  105. julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
  106. julee/repositories/memory/tests/test_policy.py +2 -1
  107. julee/repositories/minio/assembly.py +4 -4
  108. julee/repositories/minio/assembly_specification.py +6 -8
  109. julee/repositories/minio/client.py +22 -25
  110. julee/repositories/minio/document.py +11 -11
  111. julee/repositories/minio/document_policy_validation.py +5 -5
  112. julee/repositories/minio/knowledge_service_config.py +8 -8
  113. julee/repositories/minio/knowledge_service_query.py +6 -9
  114. julee/repositories/minio/policy.py +4 -4
  115. julee/repositories/minio/tests/fake_client.py +11 -9
  116. julee/repositories/minio/tests/test_assembly.py +3 -1
  117. julee/repositories/minio/tests/test_assembly_specification.py +2 -1
  118. julee/repositories/minio/tests/test_client_protocol.py +5 -5
  119. julee/repositories/minio/tests/test_document.py +7 -6
  120. julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
  121. julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
  122. julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
  123. julee/repositories/minio/tests/test_policy.py +3 -1
  124. julee/repositories/temporal/activities.py +5 -5
  125. julee/repositories/temporal/proxies.py +5 -5
  126. julee/services/knowledge_service/__init__.py +1 -2
  127. julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
  128. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
  129. julee/services/knowledge_service/factory.py +8 -8
  130. julee/services/knowledge_service/knowledge_service.py +22 -18
  131. julee/services/knowledge_service/memory/knowledge_service.py +13 -12
  132. julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
  133. julee/services/knowledge_service/test_factory.py +11 -10
  134. julee/services/temporal/activities.py +10 -10
  135. julee/services/temporal/proxies.py +2 -2
  136. julee/util/domain.py +6 -6
  137. julee/util/repos/minio/file_storage.py +8 -9
  138. julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
  139. julee/util/repos/temporal/data_converter.py +6 -6
  140. julee/util/repos/temporal/minio_file_storage.py +1 -1
  141. julee/util/repos/temporal/proxies/file_storage.py +2 -3
  142. julee/util/repositories.py +6 -7
  143. julee/util/temporal/activities.py +1 -1
  144. julee/util/temporal/decorators.py +28 -33
  145. julee/util/tests/test_decorators.py +13 -15
  146. julee/util/validation/repository.py +3 -3
  147. julee/util/validation/type_guards.py +12 -11
  148. julee/worker.py +9 -8
  149. julee/workflows/__init__.py +2 -2
  150. julee/workflows/extract_assemble.py +2 -1
  151. julee/workflows/validate_document.py +3 -2
  152. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/METADATA +4 -1
  153. julee-0.1.3.dist-info/RECORD +197 -0
  154. julee-0.1.1.dist-info/RECORD +0 -161
  155. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
  156. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
  157. {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
+ }