Skip to content

Commit 7ba693b

Browse files
authored
doctrine: wire CI and fix existing violations
The doctrine tests have no CI coverage until this PR — they exist but nothing runs them automatically. This adds a dedicated job so violations are caught before merge. Four use cases in the existing codebase pre-date the doctrine rules and were never updated to conform. Without fixing them the CI job would fail immediately on first run, defeating the purpose of adding it. The initial doctrine tests only check for the existence of Request/Response classes and an execute() method — they don't verify that execute() actually uses them. Two new tests enforce the full contract: execute() must declare the matching Request as its parameter and the matching Response as its return type. With the signature checks in place, the scanner's blind spot became visible: the contrib directory was listed in RESERVED_WORDS, but the scanner used that list to skip directories entirely rather than just excluding them from being bounded contexts themselves. This meant contrib/polling was never scanned. The fix lets reserved-word directories still be treated as nested solution containers.
1 parent a83095f commit 7ba693b

12 files changed

Lines changed: 283 additions & 51 deletions

File tree

.github/workflows/python.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,17 @@ jobs:
4949

5050
- name: Test
5151
run: make test-python-unit
52+
53+
doctrine:
54+
name: Doctrine
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: actions/checkout@v4
58+
- name: Set up Python
59+
uses: actions/setup-python@v5
60+
with:
61+
python-version: "3.12"
62+
- name: Install dependencies
63+
run: pip install -r requirements-dev.txt
64+
- name: Doctrine tests
65+
run: PYTHONPATH=src pytest src/julee/core/doctrine/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "julee"
7-
version = "0.1.12"
7+
version = "0.1.13"
88
description = "Julee - Clean architecture for accountable and transparent digital supply chains"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/julee/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Julee - Clean architecture for accountable and transparent digital supply chains."""
22

3-
__version__ = "0.1.12"
3+
__version__ = "0.1.13"

src/julee/api/services/system_initialization.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import Any
1515

1616
from julee.domain.use_cases.initialize_system_data import (
17+
InitializeSystemDataRequest,
1718
InitializeSystemDataUseCase,
1819
)
1920

@@ -128,7 +129,9 @@ async def _execute_system_data_initialization(
128129
try:
129130
self.logger.debug("Starting task: %s", task_name)
130131

131-
await self.initialize_system_data_use_case.execute()
132+
await self.initialize_system_data_use_case.execute(
133+
InitializeSystemDataRequest()
134+
)
132135

133136
results["tasks_completed"].append(task_name)
134137
results["metadata"][task_name] = {

src/julee/contrib/polling/apps/worker/pipelines.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
from julee.contrib.polling.infrastructure.temporal.proxies import (
2121
WorkflowPollerServiceProxy,
2222
)
23-
from julee.contrib.polling.use_cases.poll_data import PollDataRequest, PollDataUseCase
23+
from julee.contrib.polling.use_cases.poll_data import (
24+
PollDataRequest,
25+
PollDataUseCase,
26+
)
2427

2528
logger = logging.getLogger(__name__)
2629

@@ -123,16 +126,12 @@ async def run(
123126
handler=self.get_handler(),
124127
analyzer=self.get_analyzer(),
125128
)
126-
result = await use_case.execute(request)
129+
response = await use_case.execute(request)
127130

128-
self.endpoint_id = result.get("endpoint_id", self.endpoint_id)
129-
self.has_new_data = result.get("detection_result", {}).get(
130-
"has_new_data", False
131-
)
131+
self.endpoint_id = response.endpoint_id
132+
self.has_new_data = response.new_items_found
132133
self.current_step = "completed"
133134

134-
result["completed_at"] = workflow.now().isoformat()
135-
136135
workflow.logger.info(
137136
"New data detection pipeline completed successfully",
138137
extra={
@@ -141,7 +140,19 @@ async def run(
141140
},
142141
)
143142

144-
return result
143+
return {
144+
"polling_result": {
145+
"content_hash": response.content_hash,
146+
"content": response.content,
147+
"polled_at": response.polled_at,
148+
},
149+
"detection_result": {
150+
"has_new_data": response.new_items_found,
151+
"current_hash": response.content_hash,
152+
},
153+
"endpoint_id": response.endpoint_id,
154+
"completed_at": workflow.now().isoformat(),
155+
}
145156

146157
except Exception as e:
147158
self.current_step = "failed"

src/julee/contrib/polling/use_cases/poll_data.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ class PollDataRequest(BaseModel):
2828
previous_completion: dict | None = None
2929

3030

31+
class PollDataResponse(BaseModel):
32+
"""Output for PollDataUseCase."""
33+
34+
endpoint_id: str
35+
content_hash: str
36+
content: str
37+
polled_at: str
38+
new_items_found: bool
39+
items_processed: int
40+
41+
3142
class PollDataUseCase:
3243
"""
3344
Use case for polling an endpoint and detecting new data.
@@ -38,9 +49,8 @@ class PollDataUseCase:
3849
3. Compare with the previous run's hash (from previous_completion)
3950
4. If content has changed and an analyzer is set, identify new item IDs
4051
5. Delegate to the optional PollingResultHandler with the item IDs
41-
6. Return a completion result dict in the same shape that
42-
NewDataDetectionPipeline returns, so Temporal schedule
43-
last-completion-result works unchanged.
52+
6. Return a PollDataResponse; the pipeline builds the Temporal
53+
last-completion-result dict from it.
4454
4555
"""
4656

@@ -54,7 +64,7 @@ def __init__(
5464
self._handler = handler
5565
self._analyzer = analyzer
5666

57-
async def execute(self, request: PollDataRequest) -> dict:
67+
async def execute(self, request: PollDataRequest) -> PollDataResponse:
5868
"""
5969
Execute the poll-and-detect use case.
6070
@@ -64,9 +74,7 @@ async def execute(self, request: PollDataRequest) -> dict:
6474
for the first run).
6575
6676
Returns:
67-
Completion result dict with keys:
68-
polling_result, detection_result, handler_acknowledgement,
69-
endpoint_id
77+
PollDataResponse with polling outcome and detection results.
7078
"""
7179
config = request.config
7280
endpoint_id = config.endpoint_identifier
@@ -99,13 +107,14 @@ async def execute(self, request: PollDataRequest) -> dict:
99107
has_new_data = previous_hash != current_hash
100108

101109
# Step 5: Analyze and invoke handler if new data detected
102-
handler_acknowledgement = None
110+
items_processed = 0
103111
if has_new_data:
104112
try:
105113
item_ids = await self._analyzer.identify_new_items(
106114
previous_data, current_content
107115
)
108-
handler_acknowledgement = await self._handler.handle_new_data(
116+
items_processed = len(item_ids)
117+
await self._handler.handle_new_data(
109118
endpoint_id,
110119
item_ids,
111120
current_hash,
@@ -120,22 +129,13 @@ async def execute(self, request: PollDataRequest) -> dict:
120129
},
121130
exc_info=True,
122131
)
123-
# handler_acknowledgement stays None to signal the exception
124132

125133
# Step 6: Return completion result
126-
return {
127-
"polling_result": {
128-
"success": polling_result.success,
129-
"content_hash": current_hash,
130-
"content": current_content.decode("utf-8", errors="ignore"),
131-
"polled_at": polled_at,
132-
"content_length": len(current_content),
133-
},
134-
"detection_result": {
135-
"has_new_data": has_new_data,
136-
"previous_hash": previous_hash,
137-
"current_hash": current_hash,
138-
},
139-
"handler_acknowledgement": handler_acknowledgement,
140-
"endpoint_id": endpoint_id,
141-
}
134+
return PollDataResponse(
135+
endpoint_id=endpoint_id,
136+
content_hash=current_hash,
137+
content=current_content.decode("utf-8", errors="ignore"),
138+
polled_at=polled_at,
139+
new_items_found=has_new_data,
140+
items_processed=items_processed,
141+
)

src/julee/core/doctrine/test_use_case.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import importlib
8+
import inspect
89

910
import pytest
1011

@@ -216,3 +217,152 @@ async def test_all_use_cases_MUST_have_matching_response(self, repo):
216217
assert not violations, "Use cases missing matching responses:\n" + "\n".join(
217218
violations
218219
)
220+
221+
@pytest.mark.asyncio
222+
async def test_execute_MUST_accept_matching_request(self, repo):
223+
"""execute() MUST declare its first parameter as {Prefix}Request.
224+
225+
Ensures the request class is part of the method's contract, not
226+
just present in the module.
227+
"""
228+
use_case = ListUseCasesUseCase(repo)
229+
response = await use_case.execute(ListCodeArtifactsRequest())
230+
231+
bounded_contexts = await repo.list_all()
232+
import_paths = {bc.slug: bc.import_path for bc in bounded_contexts}
233+
234+
violations = []
235+
suffix_len = len(USE_CASE_SUFFIX)
236+
for artifact in response.artifacts:
237+
name = artifact.artifact.name
238+
ctx = artifact.bounded_context
239+
if name in GENERIC_BASE_CLASSES:
240+
continue
241+
if not name.endswith(USE_CASE_SUFFIX):
242+
continue
243+
244+
prefix = name[:-suffix_len]
245+
expected_request = f"{prefix}{REQUEST_SUFFIX}"
246+
247+
bc_import_path = import_paths.get(ctx)
248+
cls = (
249+
_resolve_class(bc_import_path, artifact.artifact.file, name)
250+
if bc_import_path
251+
else None
252+
)
253+
254+
if cls is not None:
255+
execute = getattr(cls, "execute", None)
256+
if not callable(execute):
257+
continue
258+
sig = inspect.signature(execute)
259+
params = [p for p in sig.parameters.values() if p.name != "self"]
260+
if not params:
261+
violations.append(
262+
f"{ctx}.{name}: execute() has no request parameter"
263+
)
264+
continue
265+
annotation = params[0].annotation
266+
actual_name = (
267+
annotation.__name__
268+
if hasattr(annotation, "__name__")
269+
else str(annotation)
270+
)
271+
else:
272+
# Fall back to AST-parsed signature
273+
execute_method = next(
274+
(m for m in artifact.artifact.methods if m.name == "execute"),
275+
None,
276+
)
277+
if execute_method is None:
278+
continue
279+
if not execute_method.parameters:
280+
violations.append(
281+
f"{ctx}.{name}: execute() has no request parameter"
282+
)
283+
continue
284+
actual_name = execute_method.parameters[0].type_annotation
285+
286+
if actual_name != expected_request:
287+
violations.append(
288+
f"{ctx}.{name}: execute() first parameter is '{actual_name}'"
289+
f", expected '{expected_request}'"
290+
)
291+
292+
assert not violations, "Use cases with wrong request type:\n" + "\n".join(
293+
violations
294+
)
295+
296+
@pytest.mark.asyncio
297+
async def test_execute_MUST_return_matching_response(self, repo):
298+
"""execute() MUST declare its return type as {Prefix}Response.
299+
300+
Ensures the response class is actually wired into execute(), not
301+
just present in the module.
302+
"""
303+
use_case = ListUseCasesUseCase(repo)
304+
response = await use_case.execute(ListCodeArtifactsRequest())
305+
306+
bounded_contexts = await repo.list_all()
307+
import_paths = {bc.slug: bc.import_path for bc in bounded_contexts}
308+
309+
violations = []
310+
suffix_len = len(USE_CASE_SUFFIX)
311+
for artifact in response.artifacts:
312+
name = artifact.artifact.name
313+
ctx = artifact.bounded_context
314+
if name in GENERIC_BASE_CLASSES:
315+
continue
316+
if not name.endswith(USE_CASE_SUFFIX):
317+
continue
318+
319+
prefix = name[:-suffix_len]
320+
expected_response = f"{prefix}{RESPONSE_SUFFIX}"
321+
322+
bc_import_path = import_paths.get(ctx)
323+
cls = (
324+
_resolve_class(bc_import_path, artifact.artifact.file, name)
325+
if bc_import_path
326+
else None
327+
)
328+
329+
if cls is not None:
330+
execute = getattr(cls, "execute", None)
331+
if not callable(execute):
332+
continue
333+
sig = inspect.signature(execute)
334+
return_annotation = sig.return_annotation
335+
if return_annotation is inspect.Parameter.empty:
336+
violations.append(
337+
f"{ctx}.{name}: execute() has no return annotation"
338+
)
339+
continue
340+
actual_name = (
341+
return_annotation.__name__
342+
if hasattr(return_annotation, "__name__")
343+
else str(return_annotation)
344+
)
345+
else:
346+
# Fall back to AST-parsed signature
347+
execute_method = next(
348+
(m for m in artifact.artifact.methods if m.name == "execute"),
349+
None,
350+
)
351+
if execute_method is None:
352+
continue
353+
if not execute_method.return_type:
354+
violations.append(
355+
f"{ctx}.{name}: execute() has no return annotation"
356+
)
357+
continue
358+
actual_name = execute_method.return_type
359+
360+
if actual_name != expected_response:
361+
violations.append(
362+
f"{ctx}.{name}: execute() returns '{actual_name}'"
363+
f", expected '{expected_response}'"
364+
)
365+
366+
assert not violations, "Use cases with wrong return type:\n" + "\n".join(
367+
violations
368+
)

src/julee/core/infrastructure/repositories/introspection/bounded_context.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,16 @@ def _discover_all(self) -> list[BoundedContext]:
235235
continue
236236
if _is_gitignored(candidate, self.project_root):
237237
continue
238-
if candidate.name in RESERVED_WORDS:
239-
continue
240238
if not self._is_python_package(candidate):
241239
continue
242240

241+
# Reserved words cannot be bounded contexts themselves, but may
242+
# still be nested solution containers (e.g. contrib/, apps/).
243+
is_reserved = candidate.name in RESERVED_WORDS
244+
243245
markers = self._detect_markers(candidate)
244246

245-
if self._is_bounded_context(markers):
247+
if not is_reserved and self._is_bounded_context(markers):
246248
# It's a bounded context
247249
is_contrib = candidate.name == CONTRIB_DIR
248250
context = BoundedContext(

0 commit comments

Comments
 (0)