atdd 0.4.5__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.
@@ -160,6 +160,74 @@ class RegistryBuilder:
160
160
  self.python_dir = repo_root / "python"
161
161
  self.supabase_dir = repo_root / "supabase"
162
162
 
163
+ # ========================================================================
164
+ # MODE HANDLING - Unified confirmation and apply logic
165
+ # ========================================================================
166
+ # Handles interactive, apply, and check modes for all registries
167
+ # ========================================================================
168
+
169
+ def _confirm_and_apply(
170
+ self,
171
+ mode: str,
172
+ registry_name: str,
173
+ registry_path: Path,
174
+ output_data: Dict[str, Any],
175
+ stats: Dict[str, Any],
176
+ preview_msg: str = ""
177
+ ) -> Dict[str, Any]:
178
+ """
179
+ Handle confirmation and apply based on mode.
180
+
181
+ Args:
182
+ mode: "interactive", "apply", or "check"
183
+ registry_name: Human-readable name for messages (e.g., "wagon", "contract")
184
+ registry_path: Path to the registry file
185
+ output_data: Data to write to the registry
186
+ stats: Statistics dict to update with results
187
+ preview_msg: Optional custom preview message
188
+
189
+ Returns:
190
+ Updated stats dict with has_changes flag
191
+ """
192
+ has_changes = stats.get("new", 0) > 0 or len(stats.get("changes", [])) > 0
193
+ stats["has_changes"] = has_changes
194
+
195
+ if mode == "check":
196
+ if has_changes:
197
+ print(f"\n⚠️ Drift detected in {registry_name} registry")
198
+ else:
199
+ print(f"\n✅ {registry_name.capitalize()} registry is in sync")
200
+ return stats
201
+
202
+ if mode == "apply":
203
+ # Write without prompting
204
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
205
+ with open(registry_path, "w") as f:
206
+ yaml.dump(output_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
207
+
208
+ print(f"\n✅ {registry_name.capitalize()} registry updated successfully!")
209
+ print(f" 📝 Registry: {registry_path}")
210
+ return stats
211
+
212
+ # Interactive mode - ask for confirmation
213
+ print(f"\n❓ Do you want to apply these changes to the {registry_name} registry?")
214
+ print(" Type 'yes' to confirm, or anything else to cancel:")
215
+ response = input(" > ").strip().lower()
216
+
217
+ if response != "yes":
218
+ print("\n❌ Update cancelled by user")
219
+ stats["cancelled"] = True
220
+ return stats
221
+
222
+ # Write registry
223
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
224
+ with open(registry_path, "w") as f:
225
+ yaml.dump(output_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
226
+
227
+ print(f"\n✅ {registry_name.capitalize()} registry updated successfully!")
228
+ print(f" 📝 Registry: {registry_path}")
229
+ return stats
230
+
163
231
  # ========================================================================
164
232
  # DOMAIN LAYER - Pure Business Logic (Change Detection)
165
233
  # ========================================================================
@@ -469,16 +537,20 @@ class RegistryBuilder:
469
537
  # Reads/writes YAML files, scans directories for source files
470
538
  # ========================================================================
471
539
 
472
- def update_wagon_registry(self, preview_only: bool = False) -> Dict[str, Any]:
540
+ def update_wagon_registry(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
473
541
  """
474
542
  Update plan/_wagons.yaml from wagon manifest files.
475
543
 
476
544
  Args:
477
- preview_only: If True, only show what would change without applying
545
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
546
+ preview_only: Deprecated - use mode="check" instead
478
547
 
479
548
  Returns:
480
- Statistics about the update
549
+ Statistics about the update (includes has_changes flag for check mode)
481
550
  """
551
+ # Backwards compatibility
552
+ if preview_only is not None:
553
+ mode = "check" if preview_only else "interactive"
482
554
  print("📊 Analyzing wagon registry from manifest files...")
483
555
 
484
556
  # Load existing registry
@@ -500,7 +572,7 @@ class RegistryBuilder:
500
572
  "updated": 0,
501
573
  "new": 0,
502
574
  "preserved_drafts": 0,
503
- "changes": [] # Track detailed changes
575
+ "changes": []
504
576
  }
505
577
 
506
578
  for manifest_path in sorted(manifest_files):
@@ -539,7 +611,6 @@ class RegistryBuilder:
539
611
  # Check if updating or new
540
612
  if slug in existing_wagons:
541
613
  stats["updated"] += 1
542
- # Track field-level changes
543
614
  changes = self._detect_changes(slug, existing_wagons[slug], entry)
544
615
  if changes:
545
616
  stats["changes"].append({
@@ -560,13 +631,17 @@ class RegistryBuilder:
560
631
  except Exception as e:
561
632
  print(f" ❌ Error processing {manifest_path}: {e}")
562
633
 
563
- # Preserve draft wagons (those without manifests)
634
+ # Preserve draft wagons (those without manifests or with draft: true)
564
635
  preserved_drafts = []
565
636
  for slug, wagon in existing_wagons.items():
566
- if not wagon.get("manifest") and not wagon.get("path"):
567
- updated_wagons.append(wagon)
568
- preserved_drafts.append(slug)
569
- stats["preserved_drafts"] += 1
637
+ is_draft = wagon.get("draft", False)
638
+ has_no_manifest = not wagon.get("manifest") and not wagon.get("path")
639
+ if is_draft or has_no_manifest:
640
+ # Check if already added from manifest scan
641
+ if slug not in [w.get("wagon") for w in updated_wagons]:
642
+ updated_wagons.append(wagon)
643
+ preserved_drafts.append(slug)
644
+ stats["preserved_drafts"] += 1
570
645
 
571
646
  # Sort by wagon slug
572
647
  updated_wagons.sort(key=lambda w: w.get("wagon", ""))
@@ -580,44 +655,24 @@ class RegistryBuilder:
580
655
  # Print detailed change report
581
656
  self._print_change_report(stats["changes"], preserved_drafts)
582
657
 
583
- # If preview only, return early
584
- if preview_only:
585
- print("\n⚠️ Preview mode - no changes applied")
586
- return stats
587
-
588
- # Ask for user approval
589
- print("\n❓ Do you want to apply these changes to the registry?")
590
- print(" Type 'yes' to confirm, or anything else to cancel:")
591
- response = input(" > ").strip().lower()
592
-
593
- if response != "yes":
594
- print("\n❌ Update cancelled by user")
595
- stats["cancelled"] = True
596
- return stats
597
-
598
- # Write updated registry
658
+ # Use helper for confirm/apply
599
659
  output = {"wagons": updated_wagons}
600
- with open(registry_path, "w") as f:
601
- yaml.dump(output, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
602
-
603
- print(f"\n✅ Registry updated successfully!")
604
- print(f" • Updated {stats['updated']} wagons")
605
- print(f" • Added {stats['new']} new wagons")
606
- print(f" • Preserved {stats['preserved_drafts']} draft wagons")
607
- print(f" 📝 Registry: {registry_path}")
608
-
609
- return stats
660
+ return self._confirm_and_apply(mode, "wagon", registry_path, output, stats)
610
661
 
611
- def update_contract_registry(self, preview_only: bool = False) -> Dict[str, Any]:
662
+ def update_contract_registry(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
612
663
  """
613
664
  Update contracts/_artifacts.yaml from contract schema files.
614
665
 
615
666
  Args:
616
- preview_only: If True, only show what would change without applying
667
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
668
+ preview_only: Deprecated - use mode="check" instead
617
669
 
618
670
  Returns:
619
- Statistics about the update
671
+ Statistics about the update (includes has_changes flag for check mode)
620
672
  """
673
+ # Backwards compatibility
674
+ if preview_only is not None:
675
+ mode = "check" if preview_only else "interactive"
621
676
  print("\n📊 Analyzing contract registry from schema files...")
622
677
 
623
678
  # Load existing registry
@@ -635,6 +690,7 @@ class RegistryBuilder:
635
690
  "updated": 0,
636
691
  "new": 0,
637
692
  "errors": 0,
693
+ "preserved_drafts": 0,
638
694
  "changes": []
639
695
  }
640
696
 
@@ -657,7 +713,7 @@ class RegistryBuilder:
657
713
  # Build artifact entry
658
714
  rel_path = str(schema_path.relative_to(self.repo_root))
659
715
 
660
- artifact_id = schema_id # No :v1 suffix - version tracked separately
716
+ artifact_id = schema_id
661
717
  artifact = {
662
718
  "id": artifact_id,
663
719
  "urn": f"contract:{schema_id}",
@@ -694,55 +750,44 @@ class RegistryBuilder:
694
750
  print(f" ⚠️ Error processing {schema_path}: {e}")
695
751
  stats["errors"] += 1
696
752
 
753
+ # Preserve draft artifacts (path doesn't exist or draft: true)
754
+ for artifact_id, artifact in existing_artifacts.items():
755
+ is_draft = artifact.get("draft", False)
756
+ path_exists = artifact.get("path") and (self.repo_root / artifact.get("path")).exists()
757
+ if is_draft or not path_exists:
758
+ if artifact_id not in [a.get("id") for a in artifacts]:
759
+ artifacts.append(artifact)
760
+ stats["preserved_drafts"] += 1
761
+
697
762
  # Show preview
698
763
  print(f"\n📋 PREVIEW:")
699
764
  print(f" • {stats['updated']} artifacts will be updated")
700
765
  print(f" • {stats['new']} new artifacts will be added")
766
+ print(f" • {stats['preserved_drafts']} draft artifacts will be preserved")
701
767
  if stats["errors"] > 0:
702
768
  print(f" ⚠️ {stats['errors']} errors encountered")
703
769
 
704
770
  # Print detailed change report
705
771
  self._print_contract_change_report(stats["changes"])
706
772
 
707
- # If preview only, return early
708
- if preview_only:
709
- print("\n⚠️ Preview mode - no changes applied")
710
- return stats
711
-
712
- # Ask for user approval
713
- print("\n❓ Do you want to apply these changes to the contract registry?")
714
- print(" Type 'yes' to confirm, or anything else to cancel:")
715
- response = input(" > ").strip().lower()
716
-
717
- if response != "yes":
718
- print("\n❌ Update cancelled by user")
719
- stats["cancelled"] = True
720
- return stats
721
-
722
- # Write registry
723
- registry_path = self.contracts_dir / "_artifacts.yaml"
773
+ # Use helper for confirm/apply
724
774
  output = {"artifacts": artifacts}
775
+ return self._confirm_and_apply(mode, "contract", registry_path, output, stats)
725
776
 
726
- with open(registry_path, "w") as f:
727
- yaml.dump(output, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
728
-
729
- print(f"\n✅ Contract registry updated successfully!")
730
- print(f" • Updated {stats['updated']} artifacts")
731
- print(f" • Added {stats['new']} new artifacts")
732
- print(f" 📝 Registry: {registry_path}")
733
-
734
- return stats
735
-
736
- def update_telemetry_registry(self, preview_only: bool = False) -> Dict[str, Any]:
777
+ def update_telemetry_registry(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
737
778
  """
738
779
  Update telemetry/_signals.yaml from telemetry signal files.
739
780
 
740
781
  Args:
741
- preview_only: If True, only show what would change without applying
782
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
783
+ preview_only: Deprecated - use mode="check" instead
742
784
 
743
785
  Returns:
744
- Statistics about the update
786
+ Statistics about the update (includes has_changes flag for check mode)
745
787
  """
788
+ # Backwards compatibility
789
+ if preview_only is not None:
790
+ mode = "check" if preview_only else "interactive"
746
791
  print("\n📊 Analyzing telemetry registry from signal files...")
747
792
 
748
793
  # Load existing registry
@@ -760,6 +805,7 @@ class RegistryBuilder:
760
805
  "updated": 0,
761
806
  "new": 0,
762
807
  "errors": 0,
808
+ "preserved_drafts": 0,
763
809
  "changes": []
764
810
  }
765
811
 
@@ -820,63 +866,320 @@ class RegistryBuilder:
820
866
  print(f" ⚠️ Error processing {signal_path}: {e}")
821
867
  stats["errors"] += 1
822
868
 
869
+ # Preserve draft signals (path doesn't exist or draft: true)
870
+ for signal_id, signal in existing_signals.items():
871
+ is_draft = signal.get("draft", False)
872
+ path_exists = signal.get("path") and (self.repo_root / signal.get("path")).exists()
873
+ if is_draft or not path_exists:
874
+ if signal_id not in [s.get("id") for s in signals]:
875
+ signals.append(signal)
876
+ stats["preserved_drafts"] += 1
877
+
823
878
  # Show preview
824
879
  print(f"\n📋 PREVIEW:")
825
880
  print(f" • {stats['updated']} signals will be updated")
826
881
  print(f" • {stats['new']} new signals will be added")
882
+ print(f" • {stats['preserved_drafts']} draft signals will be preserved")
827
883
  if stats["errors"] > 0:
828
884
  print(f" ⚠️ {stats['errors']} errors encountered")
829
885
 
830
886
  # Print detailed change report
831
887
  self._print_telemetry_change_report(stats["changes"])
832
888
 
833
- # If preview only, return early
834
- if preview_only:
835
- print("\n⚠️ Preview mode - no changes applied")
836
- return stats
889
+ # Use helper for confirm/apply
890
+ output = {"signals": signals}
891
+ return self._confirm_and_apply(mode, "telemetry", registry_path, output, stats)
837
892
 
838
- # Ask for user approval
839
- print("\n❓ Do you want to apply these changes to the telemetry registry?")
840
- print(" Type 'yes' to confirm, or anything else to cancel:")
841
- response = input(" > ").strip().lower()
893
+ # Alias methods for unified API
894
+ def build_planner(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
895
+ """Build planner registry (alias for update_wagon_registry)."""
896
+ # Backwards compatibility: preview_only=True maps to mode="check"
897
+ if preview_only is not None:
898
+ mode = "check" if preview_only else "interactive"
899
+ return self.update_wagon_registry(mode)
842
900
 
843
- if response != "yes":
844
- print("\n❌ Update cancelled by user")
845
- stats["cancelled"] = True
901
+ def build_contracts(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
902
+ """Build contracts registry (alias for update_contract_registry)."""
903
+ if preview_only is not None:
904
+ mode = "check" if preview_only else "interactive"
905
+ return self.update_contract_registry(mode)
906
+
907
+ def build_telemetry(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
908
+ """Build telemetry registry (alias for update_telemetry_registry)."""
909
+ if preview_only is not None:
910
+ mode = "check" if preview_only else "interactive"
911
+ return self.update_telemetry_registry(mode)
912
+
913
+ def _normalize_test_code_field(self, field_value: Any) -> Dict[str, List[str]]:
914
+ """
915
+ Normalize test/code field to canonical structure.
916
+
917
+ Train First-Class Spec v0.6 Section 5: Test/Code Field Typing Normalization
918
+ - string -> {"backend": [string]}
919
+ - list -> {"backend": list}
920
+ - dict -> normalize each sub-field to list
921
+ """
922
+ if field_value is None:
923
+ return {}
924
+
925
+ if isinstance(field_value, str):
926
+ return {"backend": [field_value]}
927
+ elif isinstance(field_value, list):
928
+ return {"backend": field_value}
929
+ elif isinstance(field_value, dict):
930
+ result = {}
931
+ for key in ["backend", "frontend", "frontend_python"]:
932
+ if key in field_value:
933
+ val = field_value[key]
934
+ result[key] = [val] if isinstance(val, str) else (val or [])
935
+ return result
936
+ return {}
937
+
938
+ def _extract_wagons_from_participants(self, participants: List[str]) -> List[str]:
939
+ """
940
+ Extract wagon names from participants list.
941
+
942
+ Train First-Class Spec v0.6 Section 4: Participants is Canonical Wagon Source
943
+ """
944
+ wagons = []
945
+ for participant in participants:
946
+ if isinstance(participant, str) and participant.startswith("wagon:"):
947
+ wagon_name = participant.replace("wagon:", "")
948
+ wagons.append(wagon_name)
949
+ return wagons
950
+
951
+ def build_trains(self, mode: str = "interactive") -> Dict[str, Any]:
952
+ """
953
+ Build trains registry from train manifest files.
954
+ Scans plan/_trains/*.yaml files and builds plan/_trains.yaml.
955
+
956
+ Train ID convention: NN-XX-name where:
957
+ - NN = theme prefix (first 2 digits for grouping)
958
+ - XX = category within theme
959
+ - name = train slug
960
+
961
+ Train First-Class Spec v0.6 Normalization:
962
+ - Section 1: Normalize file→path (deprecation)
963
+ - Section 4: Extract wagons from participants
964
+ - Section 5: Normalize test/code fields to {backend/frontend/frontend_python: []}
965
+
966
+ Args:
967
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
968
+
969
+ Returns:
970
+ Statistics about the update (includes has_changes flag for check mode)
971
+ """
972
+ import warnings
973
+
974
+ print("\n📊 Analyzing trains registry from manifest files...")
975
+
976
+ # Set up paths
977
+ trains_dir = self.plan_dir / "_trains"
978
+ registry_path = self.plan_dir / "_trains.yaml"
979
+
980
+ # Load existing registry
981
+ existing_trains = {}
982
+ if registry_path.exists():
983
+ with open(registry_path) as f:
984
+ registry_data = yaml.safe_load(f)
985
+ existing_trains = {t.get("train_id"): t for t in registry_data.get("trains", [])}
986
+
987
+ trains = []
988
+ stats = {
989
+ "total_manifests": 0,
990
+ "processed": 0,
991
+ "updated": 0,
992
+ "new": 0,
993
+ "errors": 0,
994
+ "preserved_drafts": 0,
995
+ "file_to_path_migrations": 0,
996
+ "changes": []
997
+ }
998
+
999
+ # Check if trains directory exists
1000
+ if not trains_dir.exists():
1001
+ print(f" ⚠️ No _trains/ directory found at {trains_dir}")
1002
+ # Still preserve existing drafts
1003
+ for train_id, train in existing_trains.items():
1004
+ if train.get("draft", False):
1005
+ trains.append(train)
1006
+ stats["preserved_drafts"] += 1
1007
+
1008
+ if stats["preserved_drafts"] > 0:
1009
+ print(f"\n📋 PREVIEW:")
1010
+ print(f" • {stats['preserved_drafts']} draft trains will be preserved")
1011
+ output = {"trains": trains}
1012
+ return self._confirm_and_apply(mode, "trains", registry_path, output, stats)
1013
+
1014
+ stats["has_changes"] = False
846
1015
  return stats
847
1016
 
848
- # Write registry
849
- registry_path = self.telemetry_dir / "_signals.yaml"
850
- output = {"signals": signals}
1017
+ # Scan for train manifests
1018
+ manifest_files = list(trains_dir.glob("*.yaml"))
1019
+ manifest_files = [f for f in manifest_files if not f.name.startswith("_")]
1020
+ stats["total_manifests"] = len(manifest_files)
851
1021
 
852
- with open(registry_path, "w") as f:
853
- yaml.dump(output, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
1022
+ for manifest_path in sorted(manifest_files):
1023
+ try:
1024
+ with open(manifest_path) as f:
1025
+ manifest = yaml.safe_load(f)
854
1026
 
855
- print(f"\n✅ Telemetry registry updated successfully!")
856
- print(f" Updated {stats['updated']} signals")
857
- print(f" • Added {stats['new']} new signals")
858
- print(f" 📝 Registry: {registry_path}")
1027
+ if not manifest:
1028
+ print(f" ⚠️ Skipping empty manifest: {manifest_path}")
1029
+ continue
859
1030
 
860
- return stats
1031
+ train_id = manifest.get("train_id", manifest.get("train", ""))
1032
+ if not train_id:
1033
+ # Try to infer from filename (e.g., 01-01-setup.yaml -> 01-01-setup)
1034
+ train_id = manifest_path.stem
1035
+
1036
+ # Parse theme from train_id (first digit maps to theme name)
1037
+ theme = ""
1038
+ theme_map = {
1039
+ "0": "commons", "1": "mechanic", "2": "scenario", "3": "match",
1040
+ "4": "sensory", "5": "player", "6": "league", "7": "audience",
1041
+ "8": "monetization", "9": "partnership"
1042
+ }
1043
+ if train_id and train_id[0].isdigit():
1044
+ theme = theme_map.get(train_id[0], "")
861
1045
 
862
- # Alias methods for unified API
863
- def build_planner(self, preview_only: bool = False) -> Dict[str, Any]:
864
- """Build planner registry (alias for update_wagon_registry)."""
865
- return self.update_wagon_registry(preview_only)
1046
+ # Build train entry
1047
+ rel_manifest = str(manifest_path.relative_to(self.repo_root))
866
1048
 
867
- def build_contracts(self, preview_only: bool = False) -> Dict[str, Any]:
868
- """Build contracts registry (alias for update_contract_registry)."""
869
- return self.update_contract_registry(preview_only)
1049
+ # Section 1: Normalize file→path
1050
+ path_value = manifest.get("path")
1051
+ file_value = manifest.get("file")
1052
+ if file_value and not path_value:
1053
+ # Migrate file to path
1054
+ path_value = file_value
1055
+ stats["file_to_path_migrations"] += 1
1056
+ warnings.warn(
1057
+ f"Train {train_id}: 'file' field is deprecated, migrating to 'path'",
1058
+ DeprecationWarning,
1059
+ stacklevel=2
1060
+ )
1061
+
1062
+ # Section 4: Extract wagons from participants
1063
+ participants = manifest.get("participants", [])
1064
+ wagons = self._extract_wagons_from_participants(participants)
1065
+
1066
+ # Also include explicitly listed wagons
1067
+ explicit_wagons = manifest.get("wagons", [])
1068
+ if explicit_wagons:
1069
+ # Validate subset relationship
1070
+ explicit_set = set(explicit_wagons)
1071
+ participant_set = set(wagons)
1072
+ if not explicit_set.issubset(participant_set) and participant_set:
1073
+ extra = explicit_set - participant_set
1074
+ warnings.warn(
1075
+ f"Train {train_id}: registry wagons {extra} not in YAML participants",
1076
+ UserWarning,
1077
+ stacklevel=2
1078
+ )
1079
+ wagons = explicit_wagons # Use explicit if provided
1080
+
1081
+ # Section 5: Normalize test/code fields
1082
+ test_normalized = self._normalize_test_code_field(manifest.get("test"))
1083
+ code_normalized = self._normalize_test_code_field(manifest.get("code"))
870
1084
 
871
- def build_telemetry(self, preview_only: bool = False) -> Dict[str, Any]:
872
- """Build telemetry registry (alias for update_telemetry_registry)."""
873
- return self.update_telemetry_registry(preview_only)
1085
+ entry = {
1086
+ "train_id": train_id,
1087
+ "theme": theme,
1088
+ "title": manifest.get("title", manifest.get("description", "")),
1089
+ "description": manifest.get("description", ""),
1090
+ "wagons": wagons,
1091
+ "status": manifest.get("status", "planned"),
1092
+ "manifest": rel_manifest
1093
+ }
1094
+
1095
+ # Add path if present
1096
+ if path_value:
1097
+ entry["path"] = path_value
1098
+
1099
+ # Add primary_wagon if present
1100
+ primary_wagon = manifest.get("primary_wagon")
1101
+ if primary_wagon:
1102
+ entry["primary_wagon"] = primary_wagon
1103
+
1104
+ # Add normalized test/code if present
1105
+ if test_normalized:
1106
+ entry["test"] = test_normalized
1107
+ if code_normalized:
1108
+ entry["code"] = code_normalized
1109
+
1110
+ # Add expectations if present
1111
+ expectations = manifest.get("expectations")
1112
+ if expectations:
1113
+ entry["expectations"] = expectations
1114
+
1115
+ # Check if updating or new
1116
+ if train_id in existing_trains:
1117
+ stats["updated"] += 1
1118
+ # Check for field changes
1119
+ old = existing_trains[train_id]
1120
+ changed_fields = []
1121
+ for field in ["title", "description", "wagons", "status", "theme", "path", "test", "code", "expectations"]:
1122
+ if old.get(field) != entry.get(field):
1123
+ changed_fields.append(field)
1124
+ if changed_fields:
1125
+ stats["changes"].append({
1126
+ "train": train_id,
1127
+ "type": "updated",
1128
+ "fields": changed_fields
1129
+ })
1130
+ else:
1131
+ stats["new"] += 1
1132
+ stats["changes"].append({
1133
+ "train": train_id,
1134
+ "type": "new",
1135
+ "fields": ["all fields (new train)"]
1136
+ })
1137
+
1138
+ trains.append(entry)
1139
+ stats["processed"] += 1
1140
+
1141
+ except Exception as e:
1142
+ print(f" ❌ Error processing {manifest_path}: {e}")
1143
+ stats["errors"] += 1
1144
+
1145
+ # Preserve draft trains (those without manifests or with draft: true)
1146
+ for train_id, train in existing_trains.items():
1147
+ is_draft = train.get("draft", False)
1148
+ has_no_manifest = not train.get("manifest")
1149
+ if is_draft or has_no_manifest:
1150
+ if train_id not in [t.get("train_id") for t in trains]:
1151
+ trains.append(train)
1152
+ stats["preserved_drafts"] += 1
1153
+
1154
+ # Sort by train_id
1155
+ trains.sort(key=lambda t: t.get("train_id", ""))
1156
+
1157
+ # Show preview
1158
+ print(f"\n📋 PREVIEW:")
1159
+ print(f" • {stats['updated']} trains will be updated")
1160
+ print(f" • {stats['new']} new trains will be added")
1161
+ print(f" • {stats['preserved_drafts']} draft trains will be preserved")
1162
+ if stats["file_to_path_migrations"] > 0:
1163
+ print(f" ⚠️ {stats['file_to_path_migrations']} file→path migrations (deprecation)")
1164
+ if stats["errors"] > 0:
1165
+ print(f" ⚠️ {stats['errors']} errors encountered")
1166
+
1167
+ # Use helper for confirm/apply
1168
+ output = {"trains": trains}
1169
+ return self._confirm_and_apply(mode, "trains", registry_path, output, stats)
874
1170
 
875
- def build_tester(self, preview_only: bool = False) -> Dict[str, Any]:
1171
+ def build_tester(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
876
1172
  """
877
1173
  Build tester registry from test files.
878
1174
  Scans atdd/tester/**/*_test.py files for URNs and metadata.
1175
+
1176
+ Args:
1177
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
1178
+ preview_only: Deprecated - use mode="check" instead
879
1179
  """
1180
+ # Backwards compatibility
1181
+ if preview_only is not None:
1182
+ mode = "check" if preview_only else "interactive"
880
1183
  print("\n📊 Analyzing tester registry from test files...")
881
1184
 
882
1185
  # Load existing registry
@@ -894,12 +1197,12 @@ class RegistryBuilder:
894
1197
  "updated": 0,
895
1198
  "new": 0,
896
1199
  "errors": 0,
1200
+ "preserved_drafts": 0,
897
1201
  "changes": []
898
1202
  }
899
1203
 
900
1204
  # Scan for test files
901
1205
  if self.tester_dir.exists():
902
- # Look for both test_*.py and *_test.py patterns
903
1206
  test_files = list(self.tester_dir.glob("**/*_test.py"))
904
1207
  test_files.extend(list(self.tester_dir.glob("**/test_*.py")))
905
1208
  test_files = [f for f in test_files if not f.name.startswith("_")]
@@ -910,16 +1213,13 @@ class RegistryBuilder:
910
1213
  with open(test_file) as f:
911
1214
  content = f.read()
912
1215
 
913
- # Extract URN markers from docstring or comments
914
1216
  urns = re.findall(r'URN:\s*(\S+)', content)
915
1217
  spec_urns = re.findall(r'Spec:\s*(\S+)', content)
916
1218
  acceptance_urns = re.findall(r'Acceptance:\s*(\S+)', content)
917
1219
 
918
- # Extract wagon from path
919
1220
  rel_path = test_file.relative_to(self.tester_dir)
920
1221
  wagon = rel_path.parts[0] if len(rel_path.parts) > 1 else "unknown"
921
1222
 
922
- # Build test entry
923
1223
  for urn in urns:
924
1224
  test_entry = {
925
1225
  "urn": urn,
@@ -932,7 +1232,6 @@ class RegistryBuilder:
932
1232
  if acceptance_urns:
933
1233
  test_entry["acceptance_urn"] = acceptance_urns[0]
934
1234
 
935
- # Track changes
936
1235
  if urn in existing_tests:
937
1236
  stats["updated"] += 1
938
1237
  else:
@@ -950,45 +1249,39 @@ class RegistryBuilder:
950
1249
  print(f" ⚠️ Error processing {test_file}: {e}")
951
1250
  stats["errors"] += 1
952
1251
 
1252
+ # Preserve draft tests (file doesn't exist or draft: true)
1253
+ for urn, test in existing_tests.items():
1254
+ is_draft = test.get("draft", False)
1255
+ file_exists = test.get("file") and (self.repo_root / test.get("file")).exists()
1256
+ if is_draft or not file_exists:
1257
+ if urn not in [t.get("urn") for t in tests]:
1258
+ tests.append(test)
1259
+ stats["preserved_drafts"] += 1
1260
+
953
1261
  # Show preview
954
1262
  print(f"\n📋 PREVIEW:")
955
1263
  print(f" • {stats['updated']} tests will be updated")
956
1264
  print(f" • {stats['new']} new tests will be added")
1265
+ print(f" • {stats['preserved_drafts']} draft tests will be preserved")
957
1266
  if stats["errors"] > 0:
958
1267
  print(f" ⚠️ {stats['errors']} errors encountered")
959
1268
 
960
- if preview_only:
961
- print("\n⚠️ Preview mode - no changes applied")
962
- return stats
963
-
964
- # Ask for confirmation
965
- print("\n❓ Do you want to apply these changes to the tester registry?")
966
- print(" Type 'yes' to confirm, or anything else to cancel:")
967
- response = input(" > ").strip().lower()
968
-
969
- if response != "yes":
970
- print("\n❌ Update cancelled by user")
971
- stats["cancelled"] = True
972
- return stats
973
-
974
- # Write registry
1269
+ # Use helper for confirm/apply
975
1270
  output = {"tests": tests}
976
- registry_path.parent.mkdir(parents=True, exist_ok=True)
977
- with open(registry_path, "w") as f:
978
- yaml.dump(output, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
979
-
980
- print(f"\n✅ Tester registry updated successfully!")
981
- print(f" • Updated {stats['updated']} tests")
982
- print(f" • Added {stats['new']} new tests")
983
- print(f" 📝 Registry: {registry_path}")
984
-
985
- return stats
1271
+ return self._confirm_and_apply(mode, "tester", registry_path, output, stats)
986
1272
 
987
- def build_coder(self, preview_only: bool = False) -> Dict[str, Any]:
1273
+ def build_coder(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
988
1274
  """
989
1275
  Build coder implementation registry from Python files.
990
1276
  Scans python/**/*.py files for implementations.
1277
+
1278
+ Args:
1279
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
1280
+ preview_only: Deprecated - use mode="check" instead
991
1281
  """
1282
+ # Backwards compatibility
1283
+ if preview_only is not None:
1284
+ mode = "check" if preview_only else "interactive"
992
1285
  print("\n📊 Analyzing coder registry from Python files...")
993
1286
 
994
1287
  # Load existing registry
@@ -1006,13 +1299,13 @@ class RegistryBuilder:
1006
1299
  "updated": 0,
1007
1300
  "new": 0,
1008
1301
  "errors": 0,
1302
+ "preserved_drafts": 0,
1009
1303
  "changes": []
1010
1304
  }
1011
1305
 
1012
1306
  # Scan for Python implementation files
1013
1307
  if self.python_dir.exists():
1014
1308
  py_files = list(self.python_dir.glob("**/*.py"))
1015
- # Filter out __init__, __pycache__, and files in specific test directories
1016
1309
  py_files = [
1017
1310
  f for f in py_files
1018
1311
  if not f.name.startswith("_")
@@ -1029,18 +1322,15 @@ class RegistryBuilder:
1029
1322
  with open(py_file) as f:
1030
1323
  content = f.read()
1031
1324
 
1032
- # Extract metadata from docstring
1033
1325
  spec_urns = re.findall(r'Spec:\s*(\S+)', content)
1034
1326
  test_urns = re.findall(r'Test:\s*(\S+)', content)
1035
1327
 
1036
- # Extract wagon and layer from path
1037
1328
  rel_path = py_file.relative_to(self.python_dir)
1038
1329
  parts = rel_path.parts
1039
1330
 
1040
1331
  wagon = parts[0] if len(parts) > 0 else "unknown"
1041
1332
  layer = "unknown"
1042
1333
 
1043
- # Try to detect layer from path
1044
1334
  if "domain" in str(py_file):
1045
1335
  layer = "domain"
1046
1336
  elif "application" in str(py_file):
@@ -1050,17 +1340,15 @@ class RegistryBuilder:
1050
1340
  elif "presentation" in str(py_file):
1051
1341
  layer = "presentation"
1052
1342
 
1053
- # Generate URN
1054
1343
  component = py_file.stem
1055
1344
  impl_urn = f"impl:{wagon}:{layer}:{component}:python"
1056
1345
 
1057
- # Build implementation entry
1058
1346
  impl_entry = {
1059
1347
  "urn": impl_urn,
1060
1348
  "file": str(py_file.relative_to(self.repo_root)),
1061
1349
  "wagon": wagon,
1062
1350
  "layer": layer,
1063
- "component_type": "entity", # Default
1351
+ "component_type": "entity",
1064
1352
  "language": "python"
1065
1353
  }
1066
1354
 
@@ -1069,7 +1357,6 @@ class RegistryBuilder:
1069
1357
  if test_urns:
1070
1358
  impl_entry["test_urn"] = test_urns[0]
1071
1359
 
1072
- # Track changes
1073
1360
  if impl_urn in existing_impls:
1074
1361
  stats["updated"] += 1
1075
1362
  else:
@@ -1087,45 +1374,39 @@ class RegistryBuilder:
1087
1374
  print(f" ⚠️ Error processing {py_file}: {e}")
1088
1375
  stats["errors"] += 1
1089
1376
 
1377
+ # Preserve draft implementations (file doesn't exist or draft: true)
1378
+ for urn, impl in existing_impls.items():
1379
+ is_draft = impl.get("draft", False)
1380
+ file_exists = impl.get("file") and (self.repo_root / impl.get("file")).exists()
1381
+ if is_draft or not file_exists:
1382
+ if urn not in [i.get("urn") for i in implementations]:
1383
+ implementations.append(impl)
1384
+ stats["preserved_drafts"] += 1
1385
+
1090
1386
  # Show preview
1091
1387
  print(f"\n📋 PREVIEW:")
1092
1388
  print(f" • {stats['updated']} implementations will be updated")
1093
1389
  print(f" • {stats['new']} new implementations will be added")
1390
+ print(f" • {stats['preserved_drafts']} draft implementations will be preserved")
1094
1391
  if stats["errors"] > 0:
1095
1392
  print(f" ⚠️ {stats['errors']} errors encountered")
1096
1393
 
1097
- if preview_only:
1098
- print("\n⚠️ Preview mode - no changes applied")
1099
- return stats
1100
-
1101
- # Ask for confirmation
1102
- print("\n❓ Do you want to apply these changes to the coder registry?")
1103
- print(" Type 'yes' to confirm, or anything else to cancel:")
1104
- response = input(" > ").strip().lower()
1105
-
1106
- if response != "yes":
1107
- print("\n❌ Update cancelled by user")
1108
- stats["cancelled"] = True
1109
- return stats
1110
-
1111
- # Write registry
1394
+ # Use helper for confirm/apply
1112
1395
  output = {"implementations": implementations}
1113
- registry_path.parent.mkdir(parents=True, exist_ok=True)
1114
- with open(registry_path, "w") as f:
1115
- yaml.dump(output, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
1396
+ return self._confirm_and_apply(mode, "coder", registry_path, output, stats)
1116
1397
 
1117
- print(f"\n✅ Coder registry updated successfully!")
1118
- print(f" • Updated {stats['updated']} implementations")
1119
- print(f" • Added {stats['new']} new implementations")
1120
- print(f" 📝 Registry: {registry_path}")
1121
-
1122
- return stats
1123
-
1124
- def build_supabase(self, preview_only: bool = False) -> Dict[str, Any]:
1398
+ def build_supabase(self, mode: str = "interactive", preview_only: bool = None) -> Dict[str, Any]:
1125
1399
  """
1126
1400
  Build supabase functions registry.
1127
1401
  Scans supabase/functions/**/ for function directories.
1402
+
1403
+ Args:
1404
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
1405
+ preview_only: Deprecated - use mode="check" instead
1128
1406
  """
1407
+ # Backwards compatibility
1408
+ if preview_only is not None:
1409
+ mode = "check" if preview_only else "interactive"
1129
1410
  print("\n📊 Analyzing supabase registry from function files...")
1130
1411
 
1131
1412
  # Load existing registry
@@ -1134,7 +1415,7 @@ class RegistryBuilder:
1134
1415
  if registry_path.exists():
1135
1416
  with open(registry_path) as f:
1136
1417
  registry_data = yaml.safe_load(f)
1137
- existing_funcs = {f.get("id"): f for f in registry_data.get("functions", [])}
1418
+ existing_funcs = {fn.get("id"): fn for fn in registry_data.get("functions", [])}
1138
1419
 
1139
1420
  functions = []
1140
1421
  stats = {
@@ -1143,6 +1424,7 @@ class RegistryBuilder:
1143
1424
  "updated": 0,
1144
1425
  "new": 0,
1145
1426
  "errors": 0,
1427
+ "preserved_drafts": 0,
1146
1428
  "changes": []
1147
1429
  }
1148
1430
 
@@ -1168,7 +1450,6 @@ class RegistryBuilder:
1168
1450
  "description": f"Supabase function: {func_id}"
1169
1451
  }
1170
1452
 
1171
- # Track changes
1172
1453
  if func_id in existing_funcs:
1173
1454
  stats["updated"] += 1
1174
1455
  else:
@@ -1186,37 +1467,24 @@ class RegistryBuilder:
1186
1467
  print(f" ⚠️ Error processing {func_dir}: {e}")
1187
1468
  stats["errors"] += 1
1188
1469
 
1470
+ # Preserve draft functions (path doesn't exist or draft: true)
1471
+ for func_id, func in existing_funcs.items():
1472
+ is_draft = func.get("draft", False)
1473
+ path_exists = func.get("path") and (self.repo_root / func.get("path")).exists()
1474
+ if is_draft or not path_exists:
1475
+ if func_id not in [fn.get("id") for fn in functions]:
1476
+ functions.append(func)
1477
+ stats["preserved_drafts"] += 1
1478
+
1189
1479
  # Show preview
1190
1480
  print(f"\n📋 PREVIEW:")
1191
1481
  print(f" • {stats['updated']} functions will be updated")
1192
1482
  print(f" • {stats['new']} new functions will be added")
1483
+ print(f" • {stats['preserved_drafts']} draft functions will be preserved")
1193
1484
 
1194
- if preview_only:
1195
- print("\n⚠️ Preview mode - no changes applied")
1196
- return stats
1197
-
1198
- # Ask for confirmation
1199
- print("\n❓ Do you want to apply these changes to the supabase registry?")
1200
- print(" Type 'yes' to confirm, or anything else to cancel:")
1201
- response = input(" > ").strip().lower()
1202
-
1203
- if response != "yes":
1204
- print("\n❌ Update cancelled by user")
1205
- stats["cancelled"] = True
1206
- return stats
1207
-
1208
- # Write registry
1485
+ # Use helper for confirm/apply
1209
1486
  output = {"functions": functions}
1210
- registry_path.parent.mkdir(parents=True, exist_ok=True)
1211
- with open(registry_path, "w") as f:
1212
- yaml.dump(output, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
1213
-
1214
- print(f"\n✅ Supabase registry updated successfully!")
1215
- print(f" • Updated {stats['updated']} functions")
1216
- print(f" • Added {stats['new']} new functions")
1217
- print(f" 📝 Registry: {registry_path}")
1218
-
1219
- return stats
1487
+ return self._confirm_and_apply(mode, "supabase", registry_path, output, stats)
1220
1488
 
1221
1489
  def build_python_manifest(self, preview_only: bool = False) -> Dict[str, Any]:
1222
1490
  """
@@ -1317,19 +1585,24 @@ class RegistryBuilder:
1317
1585
 
1318
1586
  return stats
1319
1587
 
1320
- def build_all(self) -> Dict[str, Any]:
1321
- """Build all registries."""
1588
+ def build_all(self, mode: str = "interactive") -> Dict[str, Any]:
1589
+ """Build all registries.
1590
+
1591
+ Args:
1592
+ mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
1593
+ """
1322
1594
  print("=" * 60)
1323
1595
  print("Unified Registry Builder - Synchronizing from source files")
1324
1596
  print("=" * 60)
1325
1597
 
1326
1598
  results = {
1327
- "plan": self.build_planner(),
1328
- "contracts": self.build_contracts(),
1329
- "telemetry": self.build_telemetry(),
1330
- "tester": self.build_tester(),
1331
- "coder": self.build_coder(),
1332
- "supabase": self.build_supabase()
1599
+ "plan": self.build_planner(mode),
1600
+ "trains": self.build_trains(mode),
1601
+ "contracts": self.build_contracts(mode),
1602
+ "telemetry": self.build_telemetry(mode),
1603
+ "tester": self.build_tester(mode),
1604
+ "coder": self.build_coder(mode),
1605
+ "supabase": self.build_supabase(mode)
1333
1606
  }
1334
1607
 
1335
1608
  print("\n" + "=" * 60)