julee 0.1.5__py3-none-any.whl → 0.1.6__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 (105) hide show
  1. julee/docs/sphinx_hcd/__init__.py +146 -13
  2. julee/docs/sphinx_hcd/domain/__init__.py +5 -0
  3. julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
  4. julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
  5. julee/docs/sphinx_hcd/domain/models/app.py +151 -0
  6. julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
  7. julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
  8. julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
  9. julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
  10. julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
  11. julee/docs/sphinx_hcd/domain/models/story.py +128 -0
  12. julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
  13. julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
  14. julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
  15. julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
  16. julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
  17. julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
  18. julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
  19. julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
  20. julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
  21. julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
  22. julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
  23. julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
  24. julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
  25. julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
  26. julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
  27. julee/docs/sphinx_hcd/parsers/ast.py +150 -0
  28. julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
  29. julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
  30. julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
  31. julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
  32. julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
  33. julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
  34. julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
  35. julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
  36. julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
  37. julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
  38. julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
  39. julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
  40. julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
  41. julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
  42. julee/docs/sphinx_hcd/sphinx/context.py +163 -0
  43. julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
  44. julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
  45. julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
  46. julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
  47. julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
  48. julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
  49. julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
  50. julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
  51. julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
  52. julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
  53. julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
  54. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
  55. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
  56. julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
  57. julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
  58. julee/docs/sphinx_hcd/tests/__init__.py +9 -0
  59. julee/docs/sphinx_hcd/tests/conftest.py +6 -0
  60. julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
  61. julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
  62. julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
  63. julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
  64. julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
  65. julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
  66. julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
  67. julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
  68. julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
  69. julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
  70. julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
  71. julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
  72. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
  73. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
  74. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
  75. julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
  76. julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
  77. julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
  78. julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
  79. julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
  80. julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
  81. julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
  82. julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
  83. julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
  84. julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
  85. julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
  86. julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
  87. julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
  88. julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
  89. julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
  90. julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
  91. julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
  92. julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
  93. julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
  94. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/METADATA +2 -1
  95. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/RECORD +98 -13
  96. julee/docs/sphinx_hcd/accelerators.py +0 -1175
  97. julee/docs/sphinx_hcd/apps.py +0 -518
  98. julee/docs/sphinx_hcd/epics.py +0 -453
  99. julee/docs/sphinx_hcd/integrations.py +0 -310
  100. julee/docs/sphinx_hcd/journeys.py +0 -797
  101. julee/docs/sphinx_hcd/personas.py +0 -457
  102. julee/docs/sphinx_hcd/stories.py +0 -960
  103. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
  104. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
  105. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,642 @@
1
+ """Journey directives for sphinx_hcd.
2
+
3
+ A journey represents a persona's path through the system to achieve a goal.
4
+ Each journey captures the user's motivation, the value they receive, and
5
+ the sequence of steps (stories/epics) they follow.
6
+
7
+ Provides directives:
8
+ - define-journey: Define a journey with persona, intent, outcome, and steps
9
+ - step-story: Reference a story as a journey step
10
+ - step-epic: Reference an epic as a journey step
11
+ - step-phase: Optional grouping label for steps
12
+ - journey-index: Render index of all journeys
13
+ - journey-dependency-graph: Generate PlantUML graph of journey dependencies
14
+ - journeys-for-persona: List journeys for a specific persona
15
+ """
16
+
17
+ from docutils import nodes
18
+ from docutils.parsers.rst import directives
19
+
20
+ from ...domain.models.journey import Journey, JourneyStep
21
+ from ...utils import (
22
+ normalize_name,
23
+ parse_csv_option,
24
+ parse_list_option,
25
+ path_to_root,
26
+ )
27
+ from .base import HCDDirective
28
+
29
+
30
+ class JourneyDependencyGraphPlaceholder(nodes.General, nodes.Element):
31
+ """Placeholder node for journey dependency graph, replaced at doctree-resolved."""
32
+
33
+ pass
34
+
35
+
36
+ class DefineJourneyDirective(HCDDirective):
37
+ """Define a journey with persona, intent, outcome, and metadata.
38
+
39
+ Options:
40
+ :persona: (required) The persona undertaking this journey.
41
+ :intent: The user's underlying motivation.
42
+ :outcome: The business value delivered when the journey succeeds.
43
+ :depends-on: Comma-separated list of journey slugs.
44
+ :preconditions: Bullet list of conditions that must be true before starting.
45
+ :postconditions: Bullet list of conditions that will be true after completing.
46
+
47
+ Example::
48
+
49
+ .. define-journey:: build-vocabulary
50
+ :persona: Knowledge Curator
51
+ :intent: Ensure consistent terminology across RBA programs
52
+ :outcome: Semantic interoperability enabling automated compliance mapping
53
+ :depends-on: operate-pipelines
54
+ """
55
+
56
+ required_arguments = 1 # journey slug
57
+ has_content = True
58
+ option_spec = {
59
+ "persona": directives.unchanged_required,
60
+ "intent": directives.unchanged,
61
+ "outcome": directives.unchanged,
62
+ "depends-on": directives.unchanged,
63
+ "preconditions": directives.unchanged,
64
+ "postconditions": directives.unchanged,
65
+ }
66
+
67
+ def run(self):
68
+ journey_slug = self.arguments[0]
69
+ docname = self.env.docname
70
+
71
+ # Parse options
72
+ persona = self.options.get("persona", "").strip()
73
+ intent = self.options.get("intent", "").strip()
74
+ outcome = self.options.get("outcome", "").strip()
75
+ depends_on = parse_csv_option(self.options.get("depends-on", ""))
76
+ preconditions = parse_list_option(self.options.get("preconditions", ""))
77
+ postconditions = parse_list_option(self.options.get("postconditions", ""))
78
+ goal = "\n".join(self.content).strip()
79
+
80
+ # Create and register journey entity
81
+ journey = Journey(
82
+ slug=journey_slug,
83
+ persona=persona,
84
+ intent=intent,
85
+ outcome=outcome,
86
+ goal=goal,
87
+ depends_on=depends_on,
88
+ preconditions=preconditions,
89
+ postconditions=postconditions,
90
+ steps=[], # Will be populated by step directives
91
+ docname=docname,
92
+ )
93
+
94
+ # Add to repository
95
+ self.hcd_context.journey_repo.save(journey)
96
+
97
+ # Track current journey in environment for step directives
98
+ if not hasattr(self.env, "journey_current"):
99
+ self.env.journey_current = {}
100
+ self.env.journey_current[docname] = journey_slug
101
+
102
+ # Build output nodes
103
+ result_nodes = []
104
+
105
+ # Intent and outcome paragraph
106
+ if persona and intent:
107
+ intro_para = nodes.paragraph()
108
+ intro_para += nodes.Text("The ")
109
+ intro_para += self.make_persona_link(persona)
110
+ intro_para += nodes.Text(" wants to ")
111
+ intent_text = intent[0].lower() + intent[1:] if intent else ""
112
+ intro_para += nodes.Text(intent_text + ".")
113
+
114
+ if outcome:
115
+ intro_para += nodes.Text(" Success means ")
116
+ outcome_text = outcome[0].lower() + outcome[1:] if outcome else ""
117
+ intro_para += nodes.Text(outcome_text + ".")
118
+
119
+ result_nodes.append(intro_para)
120
+
121
+ # Goal paragraph (backward compat for when intent not provided)
122
+ if goal and not intent:
123
+ intro_para = nodes.paragraph()
124
+ intro_para += nodes.Text("The goal of the ")
125
+ intro_para += self.make_persona_link(persona)
126
+ intro_para += nodes.Text(" is to ")
127
+ goal_text = goal[0].lower() + goal[1:] if goal else ""
128
+ intro_para += nodes.Text(goal_text)
129
+ result_nodes.append(intro_para)
130
+ elif goal and intent:
131
+ goal_para = nodes.paragraph()
132
+ goal_para += nodes.Text(goal)
133
+ result_nodes.append(goal_para)
134
+
135
+ # Placeholder for steps (filled in doctree-read)
136
+ steps_placeholder = nodes.container()
137
+ steps_placeholder["classes"].append("journey-steps-placeholder")
138
+ steps_placeholder["journey_slug"] = journey_slug
139
+ result_nodes.append(steps_placeholder)
140
+
141
+ return result_nodes
142
+
143
+
144
+ class StepStoryDirective(HCDDirective):
145
+ """Reference a story as a journey step.
146
+
147
+ Usage::
148
+
149
+ .. step-story:: Upload Scheme Documentation
150
+ """
151
+
152
+ required_arguments = 1
153
+ final_argument_whitespace = True
154
+
155
+ def run(self):
156
+ story_title = self.arguments[0]
157
+ docname = self.env.docname
158
+
159
+ # Get current journey and add step
160
+ journey_current = getattr(self.env, "journey_current", {})
161
+ journey_slug = journey_current.get(docname)
162
+
163
+ if journey_slug:
164
+ journey = self.hcd_context.journey_repo.get(journey_slug)
165
+ if journey:
166
+ step = JourneyStep.story(story_title)
167
+ journey.steps.append(step)
168
+
169
+ return []
170
+
171
+
172
+ class StepEpicDirective(HCDDirective):
173
+ """Reference an epic as a journey step.
174
+
175
+ Usage::
176
+
177
+ .. step-epic:: vocabulary-management
178
+ """
179
+
180
+ required_arguments = 1
181
+
182
+ def run(self):
183
+ epic_slug = self.arguments[0]
184
+ docname = self.env.docname
185
+
186
+ journey_current = getattr(self.env, "journey_current", {})
187
+ journey_slug = journey_current.get(docname)
188
+
189
+ if journey_slug:
190
+ journey = self.hcd_context.journey_repo.get(journey_slug)
191
+ if journey:
192
+ step = JourneyStep.epic(epic_slug)
193
+ journey.steps.append(step)
194
+
195
+ return []
196
+
197
+
198
+ class StepPhaseDirective(HCDDirective):
199
+ """Optional grouping label for journey steps.
200
+
201
+ Usage::
202
+
203
+ .. step-phase:: Upload Sources
204
+
205
+ Add reference materials to the knowledge base.
206
+ """
207
+
208
+ required_arguments = 1
209
+ final_argument_whitespace = True
210
+ has_content = True
211
+
212
+ def run(self):
213
+ phase_title = self.arguments[0]
214
+ docname = self.env.docname
215
+ description = "\n".join(self.content).strip()
216
+
217
+ journey_current = getattr(self.env, "journey_current", {})
218
+ journey_slug = journey_current.get(docname)
219
+
220
+ if journey_slug:
221
+ journey = self.hcd_context.journey_repo.get(journey_slug)
222
+ if journey:
223
+ step = JourneyStep.phase(phase_title, description)
224
+ journey.steps.append(step)
225
+
226
+ return []
227
+
228
+
229
+ class JourneyIndexDirective(HCDDirective):
230
+ """Render index of all journeys.
231
+
232
+ Usage::
233
+
234
+ .. journey-index::
235
+ """
236
+
237
+ def run(self):
238
+ all_journeys = self.hcd_context.journey_repo.list_all()
239
+
240
+ if not all_journeys:
241
+ return self.empty_result("No journeys defined")
242
+
243
+ bullet_list = nodes.bullet_list()
244
+
245
+ for journey in sorted(all_journeys, key=lambda j: j.slug):
246
+ item = nodes.list_item()
247
+ para = nodes.paragraph()
248
+
249
+ # Link to journey
250
+ journey_path = f"{journey.slug}.html"
251
+ journey_ref = nodes.reference("", "", refuri=journey_path)
252
+ journey_ref += nodes.strong(text=journey.slug.replace("-", " ").title())
253
+ para += journey_ref
254
+
255
+ # Persona in parentheses
256
+ if journey.persona:
257
+ para += nodes.Text(f" ({journey.persona})")
258
+
259
+ item += para
260
+
261
+ # Intent as sub-paragraph
262
+ display_text = journey.intent or journey.goal or ""
263
+ if display_text:
264
+ desc_para = nodes.paragraph()
265
+ if len(display_text) > 100:
266
+ display_text = display_text[:100] + "..."
267
+ desc_para += nodes.Text(display_text)
268
+ item += desc_para
269
+
270
+ bullet_list += item
271
+
272
+ return [bullet_list]
273
+
274
+
275
+ class JourneyDependencyGraphDirective(HCDDirective):
276
+ """Generate a PlantUML dependency graph from journey dependencies.
277
+
278
+ Usage::
279
+
280
+ .. journey-dependency-graph::
281
+ """
282
+
283
+ def run(self):
284
+ # Return placeholder - rendered in doctree-resolved
285
+ node = JourneyDependencyGraphPlaceholder()
286
+ return [node]
287
+
288
+
289
+ class JourneysForPersonaDirective(HCDDirective):
290
+ """List journeys for a specific persona.
291
+
292
+ Usage::
293
+
294
+ .. journeys-for-persona:: Analyst
295
+ """
296
+
297
+ required_arguments = 1
298
+ final_argument_whitespace = True
299
+
300
+ def run(self):
301
+ persona_arg = self.arguments[0]
302
+ persona_normalized = normalize_name(persona_arg)
303
+
304
+ all_journeys = self.hcd_context.journey_repo.list_all()
305
+
306
+ # Find journeys for this persona
307
+ journeys = [
308
+ j for j in all_journeys if normalize_name(j.persona) == persona_normalized
309
+ ]
310
+
311
+ if not journeys:
312
+ return self.empty_result(f"No journeys found for persona '{persona_arg}'")
313
+
314
+ bullet_list = nodes.bullet_list()
315
+
316
+ for journey in sorted(journeys, key=lambda j: j.slug):
317
+ item = nodes.list_item()
318
+ para = nodes.paragraph()
319
+ para += self.make_journey_link(journey.slug)
320
+ item += para
321
+ bullet_list += item
322
+
323
+ return [bullet_list]
324
+
325
+
326
+ def build_story_node(story_title: str, docname: str, hcd_context):
327
+ """Build a paragraph node for a story reference."""
328
+ from ...config import get_config
329
+
330
+ config = get_config()
331
+ all_stories = hcd_context.story_repo.list_all()
332
+ all_apps = hcd_context.app_repo.list_all()
333
+ known_apps = {normalize_name(a.name) for a in all_apps}
334
+ prefix = path_to_root(docname)
335
+
336
+ # Find the story
337
+ story_normalized = normalize_name(story_title)
338
+ story = None
339
+ for s in all_stories:
340
+ if normalize_name(s.feature_title) == story_normalized:
341
+ story = s
342
+ break
343
+
344
+ para = nodes.paragraph()
345
+
346
+ if story:
347
+ # Story link
348
+ story_doc = f"{config.get_doc_path('stories')}/{story.app_slug}"
349
+ story_ref_uri = _build_relative_uri(docname, story_doc, story.slug)
350
+ story_ref = nodes.reference("", "", refuri=story_ref_uri)
351
+ story_ref += nodes.Text(story.feature_title)
352
+ para += story_ref
353
+
354
+ # App in parentheses
355
+ para += nodes.Text(" (")
356
+ app_path = (
357
+ f"{prefix}{config.get_doc_path('applications')}/{story.app_slug}.html"
358
+ )
359
+ app_valid = story.app_normalized in known_apps
360
+
361
+ if app_valid:
362
+ app_ref = nodes.reference("", "", refuri=app_path)
363
+ app_ref += nodes.Text(story.app_slug.replace("-", " ").title())
364
+ para += app_ref
365
+ else:
366
+ para += nodes.Text(story.app_slug.replace("-", " ").title())
367
+ para += nodes.Text(")")
368
+ else:
369
+ para += nodes.problematic(text=f"{story_title} [not found]")
370
+
371
+ return para
372
+
373
+
374
+ def build_epic_node(epic_slug: str, docname: str):
375
+ """Build a paragraph node for an epic reference."""
376
+ from ...config import get_config
377
+
378
+ config = get_config()
379
+ prefix = path_to_root(docname)
380
+ epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic_slug}.html"
381
+
382
+ para = nodes.paragraph()
383
+ epic_ref = nodes.reference("", "", refuri=epic_path)
384
+ epic_ref += nodes.Text(epic_slug.replace("-", " ").title())
385
+ para += epic_ref
386
+ para += nodes.Text(" (epic)")
387
+
388
+ return para
389
+
390
+
391
+ def _build_relative_uri(from_docname: str, target_doc: str, anchor: str = None) -> str:
392
+ """Build a relative URI from one doc to another."""
393
+ from_parts = from_docname.split("/")
394
+ target_parts = target_doc.split("/")
395
+
396
+ common = 0
397
+ for i in range(min(len(from_parts), len(target_parts))):
398
+ if from_parts[i] == target_parts[i]:
399
+ common += 1
400
+ else:
401
+ break
402
+
403
+ up_levels = len(from_parts) - common - 1
404
+ down_path = "/".join(target_parts[common:])
405
+
406
+ if up_levels > 0:
407
+ rel_path = "../" * up_levels + down_path + ".html"
408
+ else:
409
+ rel_path = down_path + ".html"
410
+
411
+ if anchor:
412
+ return f"{rel_path}#{anchor}"
413
+ return rel_path
414
+
415
+
416
+ def render_journey_steps(journey: Journey, docname: str, hcd_context):
417
+ """Render journey steps as a numbered list with phases grouping stories."""
418
+ steps = journey.steps
419
+ if not steps:
420
+ return None
421
+
422
+ # Group steps by phase
423
+ phases = []
424
+ current_phase = None
425
+
426
+ for step in steps:
427
+ if step.step_type.value == "phase":
428
+ current_phase = {
429
+ "title": step.ref,
430
+ "description": step.description,
431
+ "items": [],
432
+ }
433
+ phases.append(current_phase)
434
+ elif step.step_type.value in ("story", "epic"):
435
+ if current_phase is None:
436
+ current_phase = {"title": None, "description": "", "items": []}
437
+ phases.append(current_phase)
438
+ current_phase["items"].append(step)
439
+
440
+ # Build enumerated list
441
+ enum_list = nodes.enumerated_list()
442
+ enum_list["enumtype"] = "arabic"
443
+
444
+ for phase in phases:
445
+ list_item = nodes.list_item()
446
+
447
+ if phase["title"]:
448
+ header_para = nodes.paragraph()
449
+ header_para += nodes.strong(text=phase["title"])
450
+ if phase["description"]:
451
+ header_para += nodes.Text(" — ")
452
+ header_para += nodes.Text(phase["description"])
453
+ list_item += header_para
454
+
455
+ if phase["items"]:
456
+ bullet_list = nodes.bullet_list()
457
+ for item in phase["items"]:
458
+ bullet_item = nodes.list_item()
459
+ if item.step_type.value == "story":
460
+ bullet_item += build_story_node(item.ref, docname, hcd_context)
461
+ elif item.step_type.value == "epic":
462
+ bullet_item += build_epic_node(item.ref, docname)
463
+ bullet_list += bullet_item
464
+ list_item += bullet_list
465
+
466
+ enum_list += list_item
467
+
468
+ return enum_list
469
+
470
+
471
+ def make_labelled_list(
472
+ term: str, items: list, hcd_context, docname: str = None, item_type: str = "text"
473
+ ):
474
+ """Create a labelled bullet list with term as heading."""
475
+ container = nodes.container()
476
+
477
+ term_para = nodes.paragraph()
478
+ term_para += nodes.strong(text=term)
479
+ container += term_para
480
+
481
+ bullet_list = nodes.bullet_list()
482
+ all_journeys = hcd_context.journey_repo.list_all()
483
+ journey_slugs = {j.slug for j in all_journeys}
484
+
485
+ for item in items:
486
+ list_item = nodes.list_item()
487
+ inline = nodes.inline()
488
+
489
+ if item_type == "journey":
490
+ related_slug = item
491
+ related_path = f"{related_slug}.html"
492
+ if related_slug in journey_slugs:
493
+ ref = nodes.reference("", "", refuri=related_path)
494
+ ref += nodes.Text(related_slug.replace("-", " ").title())
495
+ inline += ref
496
+ else:
497
+ inline += nodes.Text(related_slug.replace("-", " ").title())
498
+ inline += nodes.emphasis(text=" [not found]")
499
+ else:
500
+ inline += nodes.Text(item)
501
+
502
+ list_item += inline
503
+ bullet_list += list_item
504
+
505
+ container += bullet_list
506
+ return container
507
+
508
+
509
+ def clear_journey_state(app, env, docname):
510
+ """Clear journey state when a document is re-read."""
511
+ from ..context import get_hcd_context
512
+
513
+ # Clear current journey tracker
514
+ if hasattr(env, "journey_current") and docname in env.journey_current:
515
+ del env.journey_current[docname]
516
+
517
+ # Clear journeys from this document via repository
518
+ hcd_context = get_hcd_context(app)
519
+ hcd_context.journey_repo.run_async(
520
+ hcd_context.journey_repo.async_repo.clear_by_docname(docname)
521
+ )
522
+
523
+
524
+ def process_journey_steps(app, doctree):
525
+ """Replace journey steps placeholder with rendered steps."""
526
+ from ..context import get_hcd_context
527
+
528
+ env = app.env
529
+ docname = env.docname
530
+ hcd_context = get_hcd_context(app)
531
+ journey_current = getattr(env, "journey_current", {})
532
+
533
+ journey_slug = journey_current.get(docname)
534
+ if not journey_slug:
535
+ return
536
+
537
+ journey = hcd_context.journey_repo.get(journey_slug)
538
+ if not journey:
539
+ return
540
+
541
+ # Replace steps placeholder
542
+ for node in doctree.traverse(nodes.container):
543
+ if "journey-steps-placeholder" in node.get("classes", []):
544
+ steps_node = render_journey_steps(journey, docname, hcd_context)
545
+ if steps_node:
546
+ node.replace_self(steps_node)
547
+ else:
548
+ node.replace_self([])
549
+ break
550
+
551
+ # Add preconditions
552
+ if journey.preconditions:
553
+ doctree += make_labelled_list(
554
+ "Preconditions", journey.preconditions, hcd_context
555
+ )
556
+
557
+ # Add postconditions
558
+ if journey.postconditions:
559
+ doctree += make_labelled_list(
560
+ "Postconditions", journey.postconditions, hcd_context
561
+ )
562
+
563
+ # Add depends-on
564
+ if journey.depends_on:
565
+ doctree += make_labelled_list(
566
+ "Depends On", journey.depends_on, hcd_context, docname, item_type="journey"
567
+ )
568
+
569
+ # Add depended-on-by (inferred)
570
+ all_journeys = hcd_context.journey_repo.list_all()
571
+ depended_on_by = [j.slug for j in all_journeys if journey_slug in j.depends_on]
572
+ if depended_on_by:
573
+ doctree += make_labelled_list(
574
+ "Depended On By",
575
+ sorted(depended_on_by),
576
+ hcd_context,
577
+ docname,
578
+ item_type="journey",
579
+ )
580
+
581
+
582
+ def build_dependency_graph_node(env, hcd_context):
583
+ """Build the PlantUML node for the journey dependency graph."""
584
+ try:
585
+ from sphinxcontrib.plantuml import plantuml
586
+ except ImportError:
587
+ para = nodes.paragraph()
588
+ para += nodes.emphasis(text="PlantUML extension not available")
589
+ return para
590
+
591
+ all_journeys = hcd_context.journey_repo.list_all()
592
+
593
+ if not all_journeys:
594
+ para = nodes.paragraph()
595
+ para += nodes.emphasis(text="No journeys defined")
596
+ return para
597
+
598
+ # Build PlantUML content
599
+ lines = [
600
+ "@startuml",
601
+ "skinparam componentStyle rectangle",
602
+ "skinparam defaultTextAlignment center",
603
+ "",
604
+ ]
605
+
606
+ journey_slugs = {j.slug for j in all_journeys}
607
+
608
+ for journey in sorted(all_journeys, key=lambda j: j.slug):
609
+ title = journey.slug.replace("-", " ").title()
610
+ lines.append(f"[{title}] as {journey.slug.replace('-', '_')}")
611
+
612
+ lines.append("")
613
+
614
+ for journey in sorted(all_journeys, key=lambda j: j.slug):
615
+ for dep in journey.depends_on:
616
+ if dep in journey_slugs:
617
+ from_id = journey.slug.replace("-", "_")
618
+ to_id = dep.replace("-", "_")
619
+ lines.append(f"{from_id} --> {to_id}")
620
+
621
+ lines.append("")
622
+ lines.append("@enduml")
623
+
624
+ puml_content = "\n".join(lines)
625
+
626
+ puml_node = plantuml(puml_content)
627
+ puml_node["uml"] = puml_content
628
+ puml_node["incdir"] = ""
629
+ puml_node["filename"] = "journey-dependency-graph"
630
+
631
+ return puml_node
632
+
633
+
634
+ def process_dependency_graph_placeholder(app, doctree, docname):
635
+ """Replace dependency graph placeholder with actual PlantUML node."""
636
+ from ..context import get_hcd_context
637
+
638
+ hcd_context = get_hcd_context(app)
639
+
640
+ for node in doctree.traverse(JourneyDependencyGraphPlaceholder):
641
+ puml_node = build_dependency_graph_node(app.env, hcd_context)
642
+ node.replace_self(puml_node)