atdd 0.4.6__py3-none-any.whl → 0.4.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.
- atdd/coach/commands/inventory.py +91 -3
- atdd/coach/commands/registry.py +114 -5
- atdd/coach/utils/config.py +131 -0
- atdd/coach/utils/train_spec_phase.py +97 -0
- atdd/coach/validators/shared_fixtures.py +68 -1
- atdd/coach/validators/test_train_registry.py +189 -0
- atdd/coder/validators/test_train_infrastructure.py +236 -2
- atdd/planner/schemas/train.schema.json +125 -2
- atdd/planner/validators/test_train_validation.py +667 -2
- atdd/tester/validators/test_train_backend_e2e.py +371 -0
- atdd/tester/validators/test_train_frontend_e2e.py +292 -0
- atdd/tester/validators/test_train_frontend_python.py +282 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/METADATA +1 -1
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/RECORD +18 -12
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/WHEEL +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/entry_points.txt +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/licenses/LICENSE +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coach validators: Train registry gap validation.
|
|
3
|
+
|
|
4
|
+
Train First-Class Spec v0.6 Section 14: Inventory Reporting
|
|
5
|
+
|
|
6
|
+
Validates:
|
|
7
|
+
- SPEC-TRAIN-VAL-0037: Inventory reports train gaps
|
|
8
|
+
- SPEC-TRAIN-VAL-0038: Gap report format validation
|
|
9
|
+
|
|
10
|
+
Ensures the inventory command properly reports gaps in train implementation
|
|
11
|
+
across backend, frontend, and frontend_python platforms.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Any, List
|
|
17
|
+
|
|
18
|
+
from atdd.coach.utils.repo import find_repo_root
|
|
19
|
+
from atdd.coach.commands.inventory import RepositoryInventory
|
|
20
|
+
from atdd.coach.utils.train_spec_phase import (
|
|
21
|
+
TrainSpecPhase,
|
|
22
|
+
should_enforce,
|
|
23
|
+
emit_phase_warning
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Path constants
|
|
28
|
+
REPO_ROOT = find_repo_root()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(scope="module")
|
|
32
|
+
def train_inventory() -> Dict[str, Any]:
|
|
33
|
+
"""Generate train inventory with gap reporting."""
|
|
34
|
+
inventory = RepositoryInventory(REPO_ROOT)
|
|
35
|
+
return inventory.scan_trains()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.platform
|
|
39
|
+
def test_inventory_reports_train_gaps(train_inventory):
|
|
40
|
+
"""
|
|
41
|
+
SPEC-TRAIN-VAL-0037: Inventory reports train gaps
|
|
42
|
+
|
|
43
|
+
Given: Inventory scan of trains
|
|
44
|
+
When: Checking gap reporting fields
|
|
45
|
+
Then: Inventory includes missing_test_* and missing_code_* arrays
|
|
46
|
+
|
|
47
|
+
Section 14: Gap Reporting
|
|
48
|
+
"""
|
|
49
|
+
required_gap_fields = [
|
|
50
|
+
"missing_test_backend",
|
|
51
|
+
"missing_test_frontend",
|
|
52
|
+
"missing_test_frontend_python",
|
|
53
|
+
"missing_code_backend",
|
|
54
|
+
"missing_code_frontend",
|
|
55
|
+
"missing_code_frontend_python"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
missing_fields = []
|
|
59
|
+
for field in required_gap_fields:
|
|
60
|
+
if field not in train_inventory:
|
|
61
|
+
missing_fields.append(field)
|
|
62
|
+
|
|
63
|
+
if missing_fields:
|
|
64
|
+
pytest.fail(
|
|
65
|
+
f"Inventory missing gap reporting fields:\n " +
|
|
66
|
+
"\n ".join(missing_fields) +
|
|
67
|
+
"\n\nExpected fields for Train First-Class Spec v0.6 Section 14"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Verify all gap fields are lists
|
|
71
|
+
for field in required_gap_fields:
|
|
72
|
+
value = train_inventory.get(field)
|
|
73
|
+
assert isinstance(value, list), \
|
|
74
|
+
f"Gap field '{field}' should be a list, got {type(value).__name__}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.platform
|
|
78
|
+
def test_gap_report_format_validation(train_inventory):
|
|
79
|
+
"""
|
|
80
|
+
SPEC-TRAIN-VAL-0038: Gap report format validation
|
|
81
|
+
|
|
82
|
+
Given: Inventory with gap reporting
|
|
83
|
+
When: Checking gap report structure
|
|
84
|
+
Then: Gaps summary exists with correct format
|
|
85
|
+
|
|
86
|
+
Section 14: Gap Report Format
|
|
87
|
+
"""
|
|
88
|
+
# Check for gaps summary
|
|
89
|
+
assert "gaps" in train_inventory, \
|
|
90
|
+
"Inventory should include 'gaps' summary object"
|
|
91
|
+
|
|
92
|
+
gaps = train_inventory["gaps"]
|
|
93
|
+
|
|
94
|
+
# Validate structure
|
|
95
|
+
assert isinstance(gaps, dict), \
|
|
96
|
+
"gaps should be a dictionary"
|
|
97
|
+
|
|
98
|
+
assert "test" in gaps, \
|
|
99
|
+
"gaps should include 'test' category"
|
|
100
|
+
|
|
101
|
+
assert "code" in gaps, \
|
|
102
|
+
"gaps should include 'code' category"
|
|
103
|
+
|
|
104
|
+
# Validate test gaps structure
|
|
105
|
+
test_gaps = gaps["test"]
|
|
106
|
+
assert isinstance(test_gaps, dict), \
|
|
107
|
+
"gaps.test should be a dictionary"
|
|
108
|
+
|
|
109
|
+
required_platforms = ["backend", "frontend", "frontend_python"]
|
|
110
|
+
for platform in required_platforms:
|
|
111
|
+
assert platform in test_gaps, \
|
|
112
|
+
f"gaps.test should include '{platform}'"
|
|
113
|
+
assert isinstance(test_gaps[platform], int), \
|
|
114
|
+
f"gaps.test.{platform} should be an integer count"
|
|
115
|
+
|
|
116
|
+
# Validate code gaps structure
|
|
117
|
+
code_gaps = gaps["code"]
|
|
118
|
+
assert isinstance(code_gaps, dict), \
|
|
119
|
+
"gaps.code should be a dictionary"
|
|
120
|
+
|
|
121
|
+
for platform in required_platforms:
|
|
122
|
+
assert platform in code_gaps, \
|
|
123
|
+
f"gaps.code should include '{platform}'"
|
|
124
|
+
assert isinstance(code_gaps[platform], int), \
|
|
125
|
+
f"gaps.code.{platform} should be an integer count"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.platform
|
|
129
|
+
def test_gap_counts_match_arrays(train_inventory):
|
|
130
|
+
"""
|
|
131
|
+
Verify gap counts match corresponding array lengths.
|
|
132
|
+
|
|
133
|
+
Given: Inventory with gap reporting
|
|
134
|
+
When: Comparing counts to arrays
|
|
135
|
+
Then: Counts equal array lengths
|
|
136
|
+
"""
|
|
137
|
+
gaps = train_inventory.get("gaps", {})
|
|
138
|
+
|
|
139
|
+
# Test gaps
|
|
140
|
+
test_gaps = gaps.get("test", {})
|
|
141
|
+
assert test_gaps.get("backend", 0) == len(train_inventory.get("missing_test_backend", [])), \
|
|
142
|
+
"gaps.test.backend count mismatch"
|
|
143
|
+
assert test_gaps.get("frontend", 0) == len(train_inventory.get("missing_test_frontend", [])), \
|
|
144
|
+
"gaps.test.frontend count mismatch"
|
|
145
|
+
assert test_gaps.get("frontend_python", 0) == len(train_inventory.get("missing_test_frontend_python", [])), \
|
|
146
|
+
"gaps.test.frontend_python count mismatch"
|
|
147
|
+
|
|
148
|
+
# Code gaps
|
|
149
|
+
code_gaps = gaps.get("code", {})
|
|
150
|
+
assert code_gaps.get("backend", 0) == len(train_inventory.get("missing_code_backend", [])), \
|
|
151
|
+
"gaps.code.backend count mismatch"
|
|
152
|
+
assert code_gaps.get("frontend", 0) == len(train_inventory.get("missing_code_frontend", [])), \
|
|
153
|
+
"gaps.code.frontend count mismatch"
|
|
154
|
+
assert code_gaps.get("frontend_python", 0) == len(train_inventory.get("missing_code_frontend_python", [])), \
|
|
155
|
+
"gaps.code.frontend_python count mismatch"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.platform
|
|
159
|
+
def test_gap_train_ids_are_valid(train_inventory):
|
|
160
|
+
"""
|
|
161
|
+
Verify train IDs in gap arrays are valid.
|
|
162
|
+
|
|
163
|
+
Given: Inventory with gap arrays
|
|
164
|
+
When: Checking train IDs
|
|
165
|
+
Then: All train IDs in gaps are in train_ids list
|
|
166
|
+
"""
|
|
167
|
+
all_train_ids = set(train_inventory.get("train_ids", []))
|
|
168
|
+
|
|
169
|
+
if not all_train_ids:
|
|
170
|
+
pytest.skip("No trains found in inventory")
|
|
171
|
+
|
|
172
|
+
gap_fields = [
|
|
173
|
+
"missing_test_backend",
|
|
174
|
+
"missing_test_frontend",
|
|
175
|
+
"missing_test_frontend_python",
|
|
176
|
+
"missing_code_backend",
|
|
177
|
+
"missing_code_frontend",
|
|
178
|
+
"missing_code_frontend_python"
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
invalid_ids = []
|
|
182
|
+
for field in gap_fields:
|
|
183
|
+
gap_ids = train_inventory.get(field, [])
|
|
184
|
+
for train_id in gap_ids:
|
|
185
|
+
if train_id not in all_train_ids:
|
|
186
|
+
invalid_ids.append(f"{field}: {train_id}")
|
|
187
|
+
|
|
188
|
+
assert not invalid_ids, \
|
|
189
|
+
f"Gap arrays contain invalid train IDs:\n " + "\n ".join(invalid_ids)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Test train infrastructure validation (SESSION-12).
|
|
2
|
+
Test train infrastructure validation (SESSION-12 + Train First-Class Spec v0.6).
|
|
3
3
|
|
|
4
4
|
Validates conventions from:
|
|
5
5
|
- atdd/coder/conventions/train.convention.yaml
|
|
@@ -13,6 +13,11 @@ Enforces:
|
|
|
13
13
|
- E2E tests use production TrainRunner
|
|
14
14
|
- Station Master pattern in game.py
|
|
15
15
|
|
|
16
|
+
Train First-Class Spec v0.6 additions:
|
|
17
|
+
- SPEC-TRAIN-VAL-0031: Backend runner paths
|
|
18
|
+
- SPEC-TRAIN-VAL-0032: Frontend code allowed roots
|
|
19
|
+
- SPEC-TRAIN-VAL-0033: FastAPI template enforcement
|
|
20
|
+
|
|
16
21
|
Rationale:
|
|
17
22
|
Trains are production orchestration, not test infrastructure (SESSION-12).
|
|
18
23
|
These audits ensure the train composition root pattern is correctly implemented.
|
|
@@ -21,11 +26,18 @@ These audits ensure the train composition root pattern is correctly implemented.
|
|
|
21
26
|
import pytest
|
|
22
27
|
import ast
|
|
23
28
|
import re
|
|
29
|
+
import yaml
|
|
24
30
|
from pathlib import Path
|
|
25
|
-
from typing import List, Dict, Set, Tuple
|
|
31
|
+
from typing import List, Dict, Set, Tuple, Any
|
|
26
32
|
|
|
27
33
|
import atdd
|
|
28
34
|
from atdd.coach.utils.repo import find_repo_root
|
|
35
|
+
from atdd.coach.utils.train_spec_phase import (
|
|
36
|
+
TrainSpecPhase,
|
|
37
|
+
should_enforce,
|
|
38
|
+
emit_phase_warning
|
|
39
|
+
)
|
|
40
|
+
from atdd.coach.utils.config import get_train_config
|
|
29
41
|
|
|
30
42
|
|
|
31
43
|
# Path constants
|
|
@@ -459,3 +471,225 @@ def test_no_wagon_to_wagon_imports():
|
|
|
459
471
|
"\n\nWagons must communicate via contracts only, not direct imports\n"
|
|
460
472
|
"See: atdd/coder/conventions/boundaries.convention.yaml"
|
|
461
473
|
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ============================================================================
|
|
477
|
+
# TRAIN FIRST-CLASS SPEC v0.6 VALIDATORS
|
|
478
|
+
# ============================================================================
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _get_all_train_ids() -> List[str]:
|
|
482
|
+
"""Get all train IDs from registry."""
|
|
483
|
+
train_ids = []
|
|
484
|
+
trains_file = REPO_ROOT / "plan" / "_trains.yaml"
|
|
485
|
+
|
|
486
|
+
if trains_file.exists():
|
|
487
|
+
with open(trains_file) as f:
|
|
488
|
+
data = yaml.safe_load(f)
|
|
489
|
+
|
|
490
|
+
for theme_key, categories in data.get("trains", {}).items():
|
|
491
|
+
if isinstance(categories, dict):
|
|
492
|
+
for category_key, trains_list in categories.items():
|
|
493
|
+
if isinstance(trains_list, list):
|
|
494
|
+
for train in trains_list:
|
|
495
|
+
train_id = train.get("train_id")
|
|
496
|
+
if train_id:
|
|
497
|
+
train_ids.append(train_id)
|
|
498
|
+
|
|
499
|
+
return train_ids
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_backend_runner_paths():
|
|
503
|
+
"""
|
|
504
|
+
SPEC-TRAIN-VAL-0031: Backend runner paths validation.
|
|
505
|
+
|
|
506
|
+
Given: Train infrastructure
|
|
507
|
+
When: Checking runner file locations
|
|
508
|
+
Then: Runners exist at python/trains/runner.py or python/trains/<train_id>/runner.py
|
|
509
|
+
|
|
510
|
+
Section 9: Backend Runner Paths
|
|
511
|
+
"""
|
|
512
|
+
train_config = get_train_config(REPO_ROOT)
|
|
513
|
+
allowed_paths = train_config.get("backend_runner_paths", [
|
|
514
|
+
"python/trains/runner.py",
|
|
515
|
+
"python/trains/{train_id}/runner.py"
|
|
516
|
+
])
|
|
517
|
+
|
|
518
|
+
# Check for main runner
|
|
519
|
+
main_runner = TRAINS_DIR / "runner.py"
|
|
520
|
+
if not main_runner.exists():
|
|
521
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
522
|
+
pytest.fail(
|
|
523
|
+
f"Main TrainRunner not found at {main_runner}\n"
|
|
524
|
+
"Expected: python/trains/runner.py"
|
|
525
|
+
)
|
|
526
|
+
else:
|
|
527
|
+
emit_phase_warning(
|
|
528
|
+
"SPEC-TRAIN-VAL-0031",
|
|
529
|
+
f"Main TrainRunner not found at {main_runner}",
|
|
530
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
531
|
+
)
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
# Check for train-specific runners if trains have custom runners
|
|
535
|
+
train_ids = _get_all_train_ids()
|
|
536
|
+
custom_runners = []
|
|
537
|
+
|
|
538
|
+
for train_id in train_ids:
|
|
539
|
+
custom_runner = TRAINS_DIR / train_id / "runner.py"
|
|
540
|
+
if custom_runner.exists():
|
|
541
|
+
custom_runners.append((train_id, custom_runner))
|
|
542
|
+
|
|
543
|
+
# Validate custom runners extend base TrainRunner
|
|
544
|
+
for train_id, runner_path in custom_runners:
|
|
545
|
+
with open(runner_path, 'r', encoding='utf-8') as f:
|
|
546
|
+
content = f.read()
|
|
547
|
+
|
|
548
|
+
if "TrainRunner" not in content:
|
|
549
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
550
|
+
pytest.fail(
|
|
551
|
+
f"Custom runner at {runner_path} does not reference TrainRunner\n"
|
|
552
|
+
"Custom runners should extend or use the base TrainRunner"
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
emit_phase_warning(
|
|
556
|
+
"SPEC-TRAIN-VAL-0031",
|
|
557
|
+
f"Custom runner {train_id}/runner.py should reference TrainRunner",
|
|
558
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def test_frontend_code_allowed_roots():
|
|
563
|
+
"""
|
|
564
|
+
SPEC-TRAIN-VAL-0032: Frontend code in allowed root directories.
|
|
565
|
+
|
|
566
|
+
Given: Frontend (web) code files
|
|
567
|
+
When: Checking file locations
|
|
568
|
+
Then: Code is in allowed roots (web/src/, web/components/, web/pages/)
|
|
569
|
+
|
|
570
|
+
Section 10: Frontend Code Allowed Roots
|
|
571
|
+
"""
|
|
572
|
+
train_config = get_train_config(REPO_ROOT)
|
|
573
|
+
allowed_roots = train_config.get("frontend_allowed_roots", [
|
|
574
|
+
"web/src/",
|
|
575
|
+
"web/components/",
|
|
576
|
+
"web/pages/"
|
|
577
|
+
])
|
|
578
|
+
|
|
579
|
+
web_dir = REPO_ROOT / "web"
|
|
580
|
+
if not web_dir.exists():
|
|
581
|
+
pytest.skip("No web/ directory found")
|
|
582
|
+
|
|
583
|
+
# Find all TypeScript/JavaScript files in web/
|
|
584
|
+
code_files = []
|
|
585
|
+
for pattern in ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]:
|
|
586
|
+
code_files.extend(web_dir.glob(pattern))
|
|
587
|
+
|
|
588
|
+
# Exclude test files, node_modules, and build directories
|
|
589
|
+
code_files = [
|
|
590
|
+
f for f in code_files
|
|
591
|
+
if "node_modules" not in str(f)
|
|
592
|
+
and ".next" not in str(f)
|
|
593
|
+
and "dist" not in str(f)
|
|
594
|
+
and not f.name.endswith(".test.ts")
|
|
595
|
+
and not f.name.endswith(".test.tsx")
|
|
596
|
+
and not f.name.endswith(".spec.ts")
|
|
597
|
+
]
|
|
598
|
+
|
|
599
|
+
violations = []
|
|
600
|
+
for code_file in code_files:
|
|
601
|
+
rel_path = code_file.relative_to(REPO_ROOT)
|
|
602
|
+
in_allowed_root = any(str(rel_path).startswith(root) for root in allowed_roots)
|
|
603
|
+
|
|
604
|
+
# Also allow e2e/ directory for tests (already excluded above but be explicit)
|
|
605
|
+
in_e2e = str(rel_path).startswith("web/e2e/")
|
|
606
|
+
|
|
607
|
+
if not in_allowed_root and not in_e2e:
|
|
608
|
+
# Check if it's a config file at root level (allow those)
|
|
609
|
+
is_config = code_file.parent == web_dir and code_file.suffix in [".js", ".ts"]
|
|
610
|
+
if not is_config:
|
|
611
|
+
violations.append(str(rel_path))
|
|
612
|
+
|
|
613
|
+
if violations and len(violations) > 10:
|
|
614
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
615
|
+
pytest.fail(
|
|
616
|
+
f"Frontend code outside allowed roots ({len(violations)} files):\n " +
|
|
617
|
+
"\n ".join(violations[:10]) +
|
|
618
|
+
f"\n ... and {len(violations) - 10} more" +
|
|
619
|
+
f"\n\nAllowed roots: {allowed_roots}"
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
emit_phase_warning(
|
|
623
|
+
"SPEC-TRAIN-VAL-0032",
|
|
624
|
+
f"{len(violations)} frontend files outside allowed roots",
|
|
625
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_fastapi_template_enforcement():
|
|
630
|
+
"""
|
|
631
|
+
SPEC-TRAIN-VAL-0033: FastAPI template enforcement when configured.
|
|
632
|
+
|
|
633
|
+
Given: .atdd/config.yaml with enforce_fastapi_template=true
|
|
634
|
+
When: Checking API endpoint files
|
|
635
|
+
Then: Endpoints follow FastAPI template conventions
|
|
636
|
+
|
|
637
|
+
Section 11: FastAPI Template Enforcement
|
|
638
|
+
"""
|
|
639
|
+
train_config = get_train_config(REPO_ROOT)
|
|
640
|
+
|
|
641
|
+
if not train_config.get("enforce_fastapi_template", False):
|
|
642
|
+
pytest.skip("FastAPI template enforcement not enabled in config")
|
|
643
|
+
|
|
644
|
+
# Look for FastAPI app files
|
|
645
|
+
python_dir = REPO_ROOT / "python"
|
|
646
|
+
if not python_dir.exists():
|
|
647
|
+
pytest.skip("No python/ directory found")
|
|
648
|
+
|
|
649
|
+
# Find files that define FastAPI apps
|
|
650
|
+
fastapi_files = []
|
|
651
|
+
for py_file in python_dir.rglob("*.py"):
|
|
652
|
+
if "__pycache__" in str(py_file):
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
with open(py_file, 'r', encoding='utf-8') as f:
|
|
657
|
+
content = f.read()
|
|
658
|
+
|
|
659
|
+
if "FastAPI" in content and ("app = FastAPI" in content or "router = APIRouter" in content):
|
|
660
|
+
fastapi_files.append(py_file)
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
if not fastapi_files:
|
|
665
|
+
pytest.skip("No FastAPI app files found")
|
|
666
|
+
|
|
667
|
+
# Check template conventions
|
|
668
|
+
violations = []
|
|
669
|
+
for api_file in fastapi_files:
|
|
670
|
+
with open(api_file, 'r', encoding='utf-8') as f:
|
|
671
|
+
content = f.read()
|
|
672
|
+
|
|
673
|
+
# Check for required template elements
|
|
674
|
+
required_elements = [
|
|
675
|
+
("response_model", "Endpoints should use response_model parameter"),
|
|
676
|
+
("HTTPException", "Endpoints should use HTTPException for errors"),
|
|
677
|
+
]
|
|
678
|
+
|
|
679
|
+
for element, description in required_elements:
|
|
680
|
+
if element not in content:
|
|
681
|
+
violations.append(f"{api_file.name}: {description}")
|
|
682
|
+
|
|
683
|
+
if violations:
|
|
684
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
685
|
+
pytest.fail(
|
|
686
|
+
f"FastAPI template violations:\n " + "\n ".join(violations) +
|
|
687
|
+
"\n\nSee: train.convention.yaml for FastAPI template requirements"
|
|
688
|
+
)
|
|
689
|
+
else:
|
|
690
|
+
for violation in violations:
|
|
691
|
+
emit_phase_warning(
|
|
692
|
+
"SPEC-TRAIN-VAL-0033",
|
|
693
|
+
violation,
|
|
694
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
695
|
+
)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"properties": {
|
|
17
17
|
"train_id": {
|
|
18
18
|
"type": "string",
|
|
19
|
-
"pattern": "
|
|
19
|
+
"pattern": "^\\d{4}-[a-z0-9-]+$",
|
|
20
20
|
"description": "Train identifier with hierarchical numbering: {theme}{category}{variation}-{kebab-case-name} (e.g., 0001-auth-session-standard, 2301-empty-input-file)"
|
|
21
21
|
},
|
|
22
22
|
"title": {
|
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
"minLength": 10,
|
|
30
30
|
"description": "Concise description of the train's purpose and workflow"
|
|
31
31
|
},
|
|
32
|
+
"path": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Canonical path to the train YAML file relative to repo root"
|
|
35
|
+
},
|
|
36
|
+
"file": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "DEPRECATED: Use 'path' instead. Alias for backward compatibility",
|
|
39
|
+
"deprecated": true
|
|
40
|
+
},
|
|
32
41
|
"themes": {
|
|
33
42
|
"type": "array",
|
|
34
43
|
"description": "Theme categories this train belongs to",
|
|
@@ -49,12 +58,17 @@
|
|
|
49
58
|
]
|
|
50
59
|
}
|
|
51
60
|
},
|
|
61
|
+
"primary_wagon": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
64
|
+
"description": "The primary wagon responsible for this train's core functionality"
|
|
65
|
+
},
|
|
52
66
|
"dependencies": {
|
|
53
67
|
"type": "array",
|
|
54
68
|
"description": "Other trains that must run before this train",
|
|
55
69
|
"items": {
|
|
56
70
|
"type": "string",
|
|
57
|
-
"pattern": "^train
|
|
71
|
+
"pattern": "^train:\\d{4}-[a-z0-9-]+$",
|
|
58
72
|
"description": "Train reference in format: train:{train_id}"
|
|
59
73
|
}
|
|
60
74
|
},
|
|
@@ -68,6 +82,115 @@
|
|
|
68
82
|
"description": "Participant reference: wagon:{wagon-id}, user:{role}, system:{source}"
|
|
69
83
|
}
|
|
70
84
|
},
|
|
85
|
+
"test": {
|
|
86
|
+
"oneOf": [
|
|
87
|
+
{
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "Single backend test file path"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"type": "array",
|
|
93
|
+
"items": { "type": "string" },
|
|
94
|
+
"description": "List of backend test file paths"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"backend": {
|
|
100
|
+
"oneOf": [
|
|
101
|
+
{ "type": "string" },
|
|
102
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
103
|
+
],
|
|
104
|
+
"description": "Backend test file path(s)"
|
|
105
|
+
},
|
|
106
|
+
"frontend": {
|
|
107
|
+
"oneOf": [
|
|
108
|
+
{ "type": "string" },
|
|
109
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
110
|
+
],
|
|
111
|
+
"description": "Frontend (web) test file path(s)"
|
|
112
|
+
},
|
|
113
|
+
"frontend_python": {
|
|
114
|
+
"oneOf": [
|
|
115
|
+
{ "type": "string" },
|
|
116
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
117
|
+
],
|
|
118
|
+
"description": "Frontend Python (Streamlit) test file path(s)"
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"additionalProperties": false,
|
|
122
|
+
"description": "Test files organized by platform"
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
"description": "Test file references for this train (string, array, or object with backend/frontend/frontend_python)"
|
|
126
|
+
},
|
|
127
|
+
"code": {
|
|
128
|
+
"oneOf": [
|
|
129
|
+
{
|
|
130
|
+
"type": "string",
|
|
131
|
+
"description": "Single backend implementation file path"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"type": "array",
|
|
135
|
+
"items": { "type": "string" },
|
|
136
|
+
"description": "List of backend implementation file paths"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"backend": {
|
|
142
|
+
"oneOf": [
|
|
143
|
+
{ "type": "string" },
|
|
144
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
145
|
+
],
|
|
146
|
+
"description": "Backend implementation file path(s)"
|
|
147
|
+
},
|
|
148
|
+
"frontend": {
|
|
149
|
+
"oneOf": [
|
|
150
|
+
{ "type": "string" },
|
|
151
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
152
|
+
],
|
|
153
|
+
"description": "Frontend (web) implementation file path(s)"
|
|
154
|
+
},
|
|
155
|
+
"frontend_python": {
|
|
156
|
+
"oneOf": [
|
|
157
|
+
{ "type": "string" },
|
|
158
|
+
{ "type": "array", "items": { "type": "string" } }
|
|
159
|
+
],
|
|
160
|
+
"description": "Frontend Python (Streamlit) implementation file path(s)"
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"additionalProperties": false,
|
|
164
|
+
"description": "Implementation files organized by platform"
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"description": "Implementation file references for this train (string, array, or object with backend/frontend/frontend_python)"
|
|
168
|
+
},
|
|
169
|
+
"expectations": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {
|
|
172
|
+
"backend": {
|
|
173
|
+
"type": "boolean",
|
|
174
|
+
"description": "Whether backend implementation is expected"
|
|
175
|
+
},
|
|
176
|
+
"frontend": {
|
|
177
|
+
"type": "boolean",
|
|
178
|
+
"description": "Whether frontend (web) implementation is expected"
|
|
179
|
+
},
|
|
180
|
+
"frontend_python": {
|
|
181
|
+
"type": "boolean",
|
|
182
|
+
"description": "Whether frontend Python (Streamlit) implementation is expected"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
"additionalProperties": false,
|
|
186
|
+
"description": "Explicit expectations for which platforms this train should be implemented on"
|
|
187
|
+
},
|
|
188
|
+
"status": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"enum": ["planned", "tested", "implemented"],
|
|
191
|
+
"default": "planned",
|
|
192
|
+
"description": "Current implementation status of the train"
|
|
193
|
+
},
|
|
71
194
|
"sequence": {
|
|
72
195
|
"type": "array",
|
|
73
196
|
"description": "Ordered steps, loops, and routes defining the train workflow",
|