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