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
@@ -0,0 +1,144 @@
1
+ """Use case for resolving app references.
2
+
3
+ Finds stories, personas, journeys, and epics related to an app.
4
+ """
5
+
6
+ from ...utils import normalize_name
7
+ from ..models.app import App
8
+ from ..models.epic import Epic
9
+ from ..models.journey import Journey
10
+ from ..models.persona import Persona
11
+ from ..models.story import Story
12
+ from .derive_personas import derive_personas
13
+
14
+
15
+ def get_stories_for_app(
16
+ app: App,
17
+ stories: list[Story],
18
+ ) -> list[Story]:
19
+ """Get stories that belong to an app.
20
+
21
+ Args:
22
+ app: App to find stories for
23
+ stories: All Story entities
24
+
25
+ Returns:
26
+ List of Story entities for this app, sorted by feature_title
27
+ """
28
+ matching = [s for s in stories if s.app_slug == app.slug]
29
+ return sorted(matching, key=lambda s: s.feature_title)
30
+
31
+
32
+ def get_personas_for_app(
33
+ app: App,
34
+ stories: list[Story],
35
+ epics: list[Epic],
36
+ ) -> list[Persona]:
37
+ """Get personas that use an app.
38
+
39
+ Args:
40
+ app: App to find personas for
41
+ stories: All Story entities
42
+ epics: All Epic entities (for persona derivation)
43
+
44
+ Returns:
45
+ List of Persona entities that use this app, sorted by name
46
+ """
47
+ # Derive all personas
48
+ all_personas = derive_personas(stories, epics)
49
+
50
+ # Filter to those using this app
51
+ matching = [p for p in all_personas if app.slug in p.app_slugs]
52
+ return sorted(matching, key=lambda p: p.name)
53
+
54
+
55
+ def get_journeys_for_app(
56
+ app: App,
57
+ stories: list[Story],
58
+ journeys: list[Journey],
59
+ ) -> list[Journey]:
60
+ """Get journeys that include stories from an app.
61
+
62
+ Args:
63
+ app: App to find journeys for
64
+ stories: All Story entities
65
+ journeys: All Journey entities
66
+
67
+ Returns:
68
+ List of Journey entities containing stories from this app, sorted by slug
69
+ """
70
+ # Get story titles for this app
71
+ app_story_titles = {
72
+ normalize_name(s.feature_title) for s in stories if s.app_slug == app.slug
73
+ }
74
+
75
+ if not app_story_titles:
76
+ return []
77
+
78
+ # Find journeys containing these stories
79
+ matching = []
80
+ for journey in journeys:
81
+ story_refs = journey.get_story_refs()
82
+ if any(normalize_name(ref) in app_story_titles for ref in story_refs):
83
+ matching.append(journey)
84
+
85
+ return sorted(matching, key=lambda j: j.slug)
86
+
87
+
88
+ def get_epics_for_app(
89
+ app: App,
90
+ stories: list[Story],
91
+ epics: list[Epic],
92
+ ) -> list[Epic]:
93
+ """Get epics that contain stories from an app.
94
+
95
+ Args:
96
+ app: App to find epics for
97
+ stories: All Story entities
98
+ epics: All Epic entities
99
+
100
+ Returns:
101
+ List of Epic entities containing stories from this app, sorted by slug
102
+ """
103
+ # Get story titles for this app
104
+ app_story_titles = {
105
+ normalize_name(s.feature_title) for s in stories if s.app_slug == app.slug
106
+ }
107
+
108
+ if not app_story_titles:
109
+ return []
110
+
111
+ # Find epics containing these stories
112
+ matching = []
113
+ for epic in epics:
114
+ if any(normalize_name(ref) in app_story_titles for ref in epic.story_refs):
115
+ matching.append(epic)
116
+
117
+ return sorted(matching, key=lambda e: e.slug)
118
+
119
+
120
+ def get_app_cross_references(
121
+ app: App,
122
+ stories: list[Story],
123
+ epics: list[Epic],
124
+ journeys: list[Journey],
125
+ ) -> dict:
126
+ """Get all cross-references for an app.
127
+
128
+ Convenience function to get all related entities at once.
129
+
130
+ Args:
131
+ app: App to find references for
132
+ stories: All Story entities
133
+ epics: All Epic entities
134
+ journeys: All Journey entities
135
+
136
+ Returns:
137
+ Dict with keys: stories, personas, journeys, epics
138
+ """
139
+ return {
140
+ "stories": get_stories_for_app(app, stories),
141
+ "personas": get_personas_for_app(app, stories, epics),
142
+ "journeys": get_journeys_for_app(app, stories, journeys),
143
+ "epics": get_epics_for_app(app, stories, epics),
144
+ }
@@ -0,0 +1,121 @@
1
+ """Use case for resolving story references.
2
+
3
+ Finds epics and journeys that reference a specific story.
4
+ """
5
+
6
+ from ...utils import normalize_name
7
+ from ..models.epic import Epic
8
+ from ..models.journey import Journey
9
+ from ..models.story import Story
10
+
11
+
12
+ def get_epics_for_story(
13
+ story: Story,
14
+ epics: list[Epic],
15
+ ) -> list[Epic]:
16
+ """Get epics that contain a specific story.
17
+
18
+ Args:
19
+ story: Story to find epics for
20
+ epics: All Epic entities to search
21
+
22
+ Returns:
23
+ List of Epic entities containing this story, sorted by slug
24
+ """
25
+ story_normalized = normalize_name(story.feature_title)
26
+ matching = []
27
+
28
+ for epic in epics:
29
+ if any(normalize_name(ref) == story_normalized for ref in epic.story_refs):
30
+ matching.append(epic)
31
+
32
+ return sorted(matching, key=lambda e: e.slug)
33
+
34
+
35
+ def get_journeys_for_story(
36
+ story: Story,
37
+ journeys: list[Journey],
38
+ ) -> list[Journey]:
39
+ """Get journeys that reference a specific story.
40
+
41
+ Args:
42
+ story: Story to find journeys for
43
+ journeys: All Journey entities to search
44
+
45
+ Returns:
46
+ List of Journey entities containing this story, sorted by slug
47
+ """
48
+ story_normalized = normalize_name(story.feature_title)
49
+ matching = []
50
+
51
+ for journey in journeys:
52
+ story_refs = journey.get_story_refs()
53
+ if any(normalize_name(ref) == story_normalized for ref in story_refs):
54
+ matching.append(journey)
55
+
56
+ return sorted(matching, key=lambda j: j.slug)
57
+
58
+
59
+ def get_related_stories(
60
+ story: Story,
61
+ stories: list[Story],
62
+ epics: list[Epic],
63
+ ) -> list[Story]:
64
+ """Get stories related to a story via shared epics.
65
+
66
+ Finds other stories that are in the same epic(s) as the given story.
67
+
68
+ Args:
69
+ story: Story to find related stories for
70
+ stories: All Story entities
71
+ epics: All Epic entities
72
+
73
+ Returns:
74
+ List of related Story entities (excluding the input story), sorted by feature_title
75
+ """
76
+ # Find epics containing this story
77
+ story_epics = get_epics_for_story(story, epics)
78
+
79
+ # Collect all story refs from those epics
80
+ related_refs: set[str] = set()
81
+ for epic in story_epics:
82
+ for ref in epic.story_refs:
83
+ related_refs.add(normalize_name(ref))
84
+
85
+ # Remove the original story
86
+ story_normalized = normalize_name(story.feature_title)
87
+ related_refs.discard(story_normalized)
88
+
89
+ # Find matching stories
90
+ related = []
91
+ for s in stories:
92
+ if normalize_name(s.feature_title) in related_refs:
93
+ related.append(s)
94
+
95
+ return sorted(related, key=lambda s: s.feature_title)
96
+
97
+
98
+ def get_story_cross_references(
99
+ story: Story,
100
+ stories: list[Story],
101
+ epics: list[Epic],
102
+ journeys: list[Journey],
103
+ ) -> dict:
104
+ """Get all cross-references for a story.
105
+
106
+ Convenience function to get all related entities at once.
107
+
108
+ Args:
109
+ story: Story to find references for
110
+ stories: All Story entities
111
+ epics: All Epic entities
112
+ journeys: All Journey entities
113
+
114
+ Returns:
115
+ Dict with keys: epics, journeys, related_stories
116
+ """
117
+ return {
118
+ "epics": get_epics_for_story(story, epics),
119
+ "journeys": get_journeys_for_story(story, journeys),
120
+ "related_stories": get_related_stories(story, stories, epics),
121
+ }
@@ -0,0 +1,48 @@
1
+ """Parsers for sphinx_hcd.
2
+
3
+ Contains parsing logic for:
4
+ - gherkin.py: Feature file parsing (.feature files)
5
+ - yaml.py: App and integration manifest parsing
6
+ - ast.py: Python code introspection for accelerators
7
+ """
8
+
9
+ from .ast import (
10
+ parse_bounded_context,
11
+ parse_module_docstring,
12
+ parse_python_classes,
13
+ scan_bounded_contexts,
14
+ )
15
+ from .gherkin import (
16
+ ParsedFeature,
17
+ parse_feature_content,
18
+ parse_feature_file,
19
+ scan_feature_directory,
20
+ )
21
+ from .yaml import (
22
+ parse_app_manifest,
23
+ parse_integration_manifest,
24
+ parse_manifest_content,
25
+ scan_app_manifests,
26
+ scan_integration_manifests,
27
+ )
28
+
29
+ __all__ = [
30
+ # AST - Python introspection
31
+ "parse_bounded_context",
32
+ "parse_module_docstring",
33
+ "parse_python_classes",
34
+ "scan_bounded_contexts",
35
+ # Gherkin
36
+ "ParsedFeature",
37
+ "parse_feature_content",
38
+ "parse_feature_file",
39
+ "scan_feature_directory",
40
+ # YAML - Apps
41
+ "parse_app_manifest",
42
+ "scan_app_manifests",
43
+ # YAML - Integrations
44
+ "parse_integration_manifest",
45
+ "scan_integration_manifests",
46
+ # YAML - Common
47
+ "parse_manifest_content",
48
+ ]
@@ -0,0 +1,150 @@
1
+ """Python code introspection parser.
2
+
3
+ Parses Python source files using AST to extract class information
4
+ for ADR 001-compliant bounded contexts.
5
+ """
6
+
7
+ import ast
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ from ..domain.models.code_info import BoundedContextInfo, ClassInfo
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def parse_python_classes(directory: Path) -> list[ClassInfo]:
17
+ """Extract class information from Python files in a directory using AST.
18
+
19
+ Args:
20
+ directory: Directory to scan for .py files
21
+
22
+ Returns:
23
+ List of ClassInfo objects sorted by class name
24
+ """
25
+ if not directory.exists():
26
+ return []
27
+
28
+ classes = []
29
+ for py_file in directory.glob("*.py"):
30
+ if py_file.name.startswith("_"):
31
+ continue
32
+
33
+ try:
34
+ source = py_file.read_text()
35
+ tree = ast.parse(source, filename=str(py_file))
36
+
37
+ for node in ast.walk(tree):
38
+ if isinstance(node, ast.ClassDef):
39
+ docstring = ast.get_docstring(node) or ""
40
+ first_line = docstring.split("\n")[0].strip() if docstring else ""
41
+ classes.append(
42
+ ClassInfo(
43
+ name=node.name,
44
+ docstring=first_line,
45
+ file=py_file.name,
46
+ )
47
+ )
48
+ except SyntaxError as e:
49
+ logger.warning(f"Syntax error in {py_file}: {e}")
50
+ except Exception as e:
51
+ logger.warning(f"Could not parse {py_file}: {e}")
52
+
53
+ return sorted(classes, key=lambda c: c.name)
54
+
55
+
56
+ def parse_module_docstring(module_path: Path) -> tuple[str | None, str | None]:
57
+ """Extract module docstring from a Python file using AST.
58
+
59
+ Args:
60
+ module_path: Path to Python file
61
+
62
+ Returns:
63
+ Tuple of (first_line, full_docstring) or (None, None) if not found
64
+ """
65
+ if not module_path.exists():
66
+ return None, None
67
+
68
+ try:
69
+ source = module_path.read_text()
70
+ tree = ast.parse(source, filename=str(module_path))
71
+ docstring = ast.get_docstring(tree)
72
+ if docstring:
73
+ first_line = docstring.split("\n")[0].strip()
74
+ return first_line, docstring
75
+ except SyntaxError as e:
76
+ logger.warning(f"Syntax error in {module_path}: {e}")
77
+ except Exception as e:
78
+ logger.warning(f"Could not parse {module_path}: {e}")
79
+
80
+ return None, None
81
+
82
+
83
+ def parse_bounded_context(context_dir: Path) -> BoundedContextInfo | None:
84
+ """Introspect a bounded context directory for ADR 001-compliant code structure.
85
+
86
+ Expected directory structure:
87
+ - context_dir/
88
+ - __init__.py (module docstring becomes objective)
89
+ - domain/
90
+ - models/ (entities)
91
+ - repositories/ (repository protocols)
92
+ - services/ (service protocols)
93
+ - use_cases/ (use case classes)
94
+ - infrastructure/ (optional)
95
+
96
+ Args:
97
+ context_dir: Path to the bounded context directory
98
+
99
+ Returns:
100
+ BoundedContextInfo if directory exists, None otherwise
101
+ """
102
+ if not context_dir.exists() or not context_dir.is_dir():
103
+ return None
104
+
105
+ init_file = context_dir / "__init__.py"
106
+ objective, full_docstring = parse_module_docstring(init_file)
107
+
108
+ return BoundedContextInfo(
109
+ slug=context_dir.name,
110
+ entities=parse_python_classes(context_dir / "domain" / "models"),
111
+ use_cases=parse_python_classes(context_dir / "use_cases"),
112
+ repository_protocols=parse_python_classes(
113
+ context_dir / "domain" / "repositories"
114
+ ),
115
+ service_protocols=parse_python_classes(context_dir / "domain" / "services"),
116
+ has_infrastructure=(context_dir / "infrastructure").exists(),
117
+ code_dir=context_dir.name,
118
+ objective=objective,
119
+ docstring=full_docstring,
120
+ )
121
+
122
+
123
+ def scan_bounded_contexts(src_dir: Path) -> list[BoundedContextInfo]:
124
+ """Scan a source directory for all bounded contexts.
125
+
126
+ Args:
127
+ src_dir: Root source directory (e.g., project/src/)
128
+
129
+ Returns:
130
+ List of BoundedContextInfo objects for all discovered contexts
131
+ """
132
+ if not src_dir.exists():
133
+ logger.info(f"Source directory not found: {src_dir}")
134
+ return []
135
+
136
+ contexts = []
137
+ for context_dir in src_dir.iterdir():
138
+ if not context_dir.is_dir():
139
+ continue
140
+ if context_dir.name.startswith((".", "_")):
141
+ continue
142
+
143
+ context_info = parse_bounded_context(context_dir)
144
+ if context_info:
145
+ contexts.append(context_info)
146
+ logger.info(
147
+ f"Introspected bounded context '{context_info.slug}': {context_info.summary()}"
148
+ )
149
+
150
+ return contexts
@@ -0,0 +1,155 @@
1
+ """Gherkin feature file parser.
2
+
3
+ Parses .feature files to extract user story information.
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from ..domain.models.story import Story
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ParsedFeature:
18
+ """Raw parsed data from a feature file.
19
+
20
+ This intermediate representation holds the extracted values
21
+ before creating a Story entity.
22
+ """
23
+
24
+ feature_title: str
25
+ persona: str
26
+ i_want: str
27
+ so_that: str
28
+ gherkin_snippet: str
29
+
30
+
31
+ def parse_feature_content(content: str) -> ParsedFeature:
32
+ """Parse the content of a Gherkin feature file.
33
+
34
+ Extracts:
35
+ - Feature: <title>
36
+ - As a <persona>
37
+ - I want to <action>
38
+ - So that <benefit>
39
+ - The story header (everything before Scenario/Background)
40
+
41
+ Args:
42
+ content: The full text content of a .feature file
43
+
44
+ Returns:
45
+ ParsedFeature with extracted values (defaults for missing fields)
46
+ """
47
+ # Extract header components using regex
48
+ feature_match = re.search(r"^Feature:\s*(.+)$", content, re.MULTILINE)
49
+ as_a_match = re.search(r"^\s*As an?\s+(.+)$", content, re.MULTILINE)
50
+ i_want_match = re.search(r"^\s*I want to\s+(.+)$", content, re.MULTILINE)
51
+ so_that_match = re.search(r"^\s*So that\s+(.+)$", content, re.MULTILINE)
52
+
53
+ # Extract Gherkin snippet (story header only, stop before scenarios)
54
+ lines = content.split("\n")
55
+ snippet_lines = []
56
+ for line in lines:
57
+ stripped = line.strip()
58
+ # Stop at scenario markers or step keywords at start of line
59
+ if stripped.startswith(
60
+ ("Scenario", "Background", "@", "Given", "When", "Then", "And", "But")
61
+ ):
62
+ break
63
+ if stripped:
64
+ snippet_lines.append(line)
65
+ gherkin_snippet = "\n".join(snippet_lines)
66
+
67
+ return ParsedFeature(
68
+ feature_title=feature_match.group(1).strip() if feature_match else "Unknown",
69
+ persona=as_a_match.group(1).strip() if as_a_match else "unknown",
70
+ i_want=i_want_match.group(1).strip() if i_want_match else "do something",
71
+ so_that=so_that_match.group(1).strip() if so_that_match else "achieve a goal",
72
+ gherkin_snippet=gherkin_snippet,
73
+ )
74
+
75
+
76
+ def parse_feature_file(
77
+ file_path: Path,
78
+ project_root: Path,
79
+ app_slug: str | None = None,
80
+ ) -> Story | None:
81
+ """Parse a single feature file and return a Story.
82
+
83
+ Args:
84
+ file_path: Absolute path to the .feature file
85
+ project_root: Project root for computing relative paths
86
+ app_slug: Optional app slug override. If None, extracted from path.
87
+
88
+ Returns:
89
+ Story entity, or None if parsing fails
90
+ """
91
+ try:
92
+ content = file_path.read_text()
93
+ except Exception as e:
94
+ logger.warning(f"Could not read {file_path}: {e}")
95
+ return None
96
+
97
+ # Parse the content
98
+ parsed = parse_feature_content(content)
99
+
100
+ # Compute relative path
101
+ try:
102
+ rel_path = file_path.relative_to(project_root)
103
+ except ValueError:
104
+ rel_path = file_path
105
+ logger.warning(f"Feature file {file_path} is not under project root")
106
+
107
+ # Extract app slug from path if not provided
108
+ # Expected: tests/e2e/{app}/features/{name}.feature
109
+ if app_slug is None:
110
+ parts = rel_path.parts
111
+ if len(parts) >= 4 and parts[2] != "features":
112
+ app_slug = parts[2]
113
+ else:
114
+ app_slug = "unknown"
115
+
116
+ return Story.from_feature_file(
117
+ feature_title=parsed.feature_title,
118
+ persona=parsed.persona,
119
+ i_want=parsed.i_want,
120
+ so_that=parsed.so_that,
121
+ app_slug=app_slug,
122
+ file_path=str(rel_path),
123
+ abs_path=str(file_path),
124
+ gherkin_snippet=parsed.gherkin_snippet,
125
+ )
126
+
127
+
128
+ def scan_feature_directory(
129
+ feature_dir: Path,
130
+ project_root: Path,
131
+ ) -> list[Story]:
132
+ """Scan a directory tree for .feature files and parse them.
133
+
134
+ Args:
135
+ feature_dir: Root directory to scan (e.g., tests/e2e/)
136
+ project_root: Project root for computing relative paths
137
+
138
+ Returns:
139
+ List of parsed Story entities
140
+ """
141
+ stories = []
142
+
143
+ if not feature_dir.exists():
144
+ logger.info(
145
+ f"Feature files directory not found at {feature_dir} - no stories to index"
146
+ )
147
+ return stories
148
+
149
+ for feature_file in feature_dir.rglob("*.feature"):
150
+ story = parse_feature_file(feature_file, project_root)
151
+ if story:
152
+ stories.append(story)
153
+
154
+ logger.info(f"Indexed {len(stories)} Gherkin stories from {feature_dir}")
155
+ return stories