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.
@@ -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": "^[0-9]{4}-[a-z][a-z0-9-]*$",
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:[0-9]{4}-[a-z][a-z0-9-]*$",
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",