atdd 0.7.2__py3-none-any.whl → 0.7.4__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,387 @@
1
+ """
2
+ Session-manifest alignment validation.
3
+
4
+ Ensures session files and manifest entries are synchronized:
5
+ 1. Every manifest entry has a corresponding session file
6
+ 2. Every session file has a manifest entry
7
+ 3. Status in session frontmatter matches manifest status
8
+
9
+ Convention: src/atdd/coach/conventions/session.convention.yaml
10
+ Schema: src/atdd/coach/schemas/manifest.schema.json
11
+
12
+ Run: atdd validate coach
13
+ """
14
+ import pytest
15
+ import yaml
16
+ from pathlib import Path
17
+ from typing import Dict, List, Any, Optional, Tuple
18
+
19
+ from atdd.coach.utils.repo import find_repo_root
20
+
21
+
22
+ # ============================================================================
23
+ # Configuration
24
+ # ============================================================================
25
+
26
+ REPO_ROOT = find_repo_root()
27
+ SESSIONS_DIR = REPO_ROOT / "atdd-sessions"
28
+ ARCHIVE_DIR = SESSIONS_DIR / "archive"
29
+ MANIFEST_FILE = REPO_ROOT / ".atdd" / "manifest.yaml"
30
+
31
+ # Valid statuses from convention
32
+ VALID_STATUSES = {"INIT", "PLANNED", "ACTIVE", "BLOCKED", "COMPLETE", "OBSOLETE", "UNKNOWN"}
33
+
34
+
35
+ # ============================================================================
36
+ # Fixtures
37
+ # ============================================================================
38
+
39
+ @pytest.fixture
40
+ def manifest() -> Optional[Dict[str, Any]]:
41
+ """Load session manifest from .atdd/manifest.yaml."""
42
+ if not MANIFEST_FILE.exists():
43
+ return None
44
+
45
+ with open(MANIFEST_FILE) as f:
46
+ return yaml.safe_load(f)
47
+
48
+
49
+ @pytest.fixture
50
+ def session_files() -> List[Path]:
51
+ """Get all session files (excluding template, including archive)."""
52
+ if not SESSIONS_DIR.exists():
53
+ return []
54
+
55
+ files = []
56
+
57
+ # Main sessions directory
58
+ for f in SESSIONS_DIR.glob("SESSION-*.md"):
59
+ if f.name != "SESSION-TEMPLATE.md":
60
+ files.append(f)
61
+
62
+ # Archive directory
63
+ if ARCHIVE_DIR.exists():
64
+ for f in ARCHIVE_DIR.glob("SESSION-*.md"):
65
+ files.append(f)
66
+
67
+ return sorted(files)
68
+
69
+
70
+ def parse_session_frontmatter(path: Path) -> Optional[Dict[str, Any]]:
71
+ """Parse YAML frontmatter from session file."""
72
+ content = path.read_text()
73
+
74
+ if not content.startswith("---"):
75
+ return None
76
+
77
+ parts = content.split("---", 2)
78
+ if len(parts) < 3:
79
+ return None
80
+
81
+ try:
82
+ return yaml.safe_load(parts[1])
83
+ except yaml.YAMLError:
84
+ return None
85
+
86
+
87
+ def get_session_id_from_filename(filename: str) -> Optional[str]:
88
+ """Extract session ID from filename (e.g., SESSION-01-foo.md -> 01)."""
89
+ import re
90
+ match = re.match(r"SESSION-(\d{2})-", filename)
91
+ return match.group(1) if match else None
92
+
93
+
94
+ # ============================================================================
95
+ # Manifest Entry Validation Tests
96
+ # ============================================================================
97
+
98
+ @pytest.mark.coach
99
+ def test_manifest_exists():
100
+ """
101
+ SPEC-COACH-MANIFEST-001: Manifest file exists
102
+
103
+ Given: Initialized ATDD project
104
+ When: Checking for manifest file
105
+ Then: .atdd/manifest.yaml exists
106
+ """
107
+ if not (REPO_ROOT / ".atdd").exists():
108
+ pytest.skip("ATDD not initialized (no .atdd/ directory)")
109
+
110
+ assert MANIFEST_FILE.exists(), \
111
+ f"Manifest file not found at {MANIFEST_FILE}. Run 'atdd init' first."
112
+
113
+
114
+ @pytest.mark.coach
115
+ def test_manifest_entries_have_session_files(manifest: Optional[Dict], session_files: List[Path]):
116
+ """
117
+ SPEC-COACH-MANIFEST-002: Every manifest entry has a corresponding session file
118
+
119
+ Given: Session manifest with entries
120
+ When: Checking for session files
121
+ Then: Each manifest entry has a matching file on disk
122
+ """
123
+ if manifest is None:
124
+ pytest.skip("No manifest file found")
125
+
126
+ sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
127
+ missing = []
128
+
129
+ for entry in manifest.get("sessions", []):
130
+ file_path = entry.get("file", "")
131
+ session_id = entry.get("id", "")
132
+
133
+ # Resolve full path
134
+ full_path = REPO_ROOT / sessions_dir / file_path
135
+
136
+ if not full_path.exists():
137
+ missing.append(f"id={session_id}: {file_path}")
138
+
139
+ if missing:
140
+ pytest.fail(
141
+ f"Found {len(missing)} manifest entries without session files:\n" +
142
+ "\n".join(f" - {m}" for m in missing)
143
+ )
144
+
145
+
146
+ @pytest.mark.coach
147
+ def test_session_files_have_manifest_entries(manifest: Optional[Dict], session_files: List[Path]):
148
+ """
149
+ SPEC-COACH-MANIFEST-003: Every session file has a manifest entry
150
+
151
+ Given: Session files in atdd-sessions/
152
+ When: Checking manifest entries
153
+ Then: Each session file has a corresponding manifest entry
154
+ """
155
+ if manifest is None:
156
+ pytest.skip("No manifest file found")
157
+
158
+ if not session_files:
159
+ pytest.skip("No session files found")
160
+
161
+ # Build set of manifest file paths
162
+ sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
163
+ manifest_files = set()
164
+
165
+ for entry in manifest.get("sessions", []):
166
+ file_path = entry.get("file", "")
167
+ manifest_files.add(file_path)
168
+
169
+ # Check each session file
170
+ orphaned = []
171
+
172
+ for session_path in session_files:
173
+ # Compute relative path from sessions_dir
174
+ try:
175
+ rel_path = session_path.relative_to(REPO_ROOT / sessions_dir)
176
+ rel_str = str(rel_path)
177
+ except ValueError:
178
+ rel_str = session_path.name
179
+
180
+ if rel_str not in manifest_files:
181
+ orphaned.append(session_path.name)
182
+
183
+ if orphaned:
184
+ pytest.fail(
185
+ f"Found {len(orphaned)} session files without manifest entries:\n" +
186
+ "\n".join(f" - {o}" for o in orphaned) +
187
+ "\n\nRun 'atdd session sync' to update manifest."
188
+ )
189
+
190
+
191
+ @pytest.mark.coach
192
+ def test_manifest_status_matches_session_frontmatter(manifest: Optional[Dict]):
193
+ """
194
+ SPEC-COACH-MANIFEST-004: Manifest status matches session frontmatter status
195
+
196
+ Given: Session manifest with status fields
197
+ When: Comparing to session file frontmatter
198
+ Then: Status values match (or warning on mismatch)
199
+ """
200
+ if manifest is None:
201
+ pytest.skip("No manifest file found")
202
+
203
+ sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
204
+ mismatches = []
205
+
206
+ for entry in manifest.get("sessions", []):
207
+ file_path = entry.get("file", "")
208
+ session_id = entry.get("id", "")
209
+ manifest_status = entry.get("status", "UNKNOWN").upper()
210
+
211
+ # Load session file
212
+ full_path = REPO_ROOT / sessions_dir / file_path
213
+ if not full_path.exists():
214
+ continue
215
+
216
+ frontmatter = parse_session_frontmatter(full_path)
217
+ if frontmatter is None:
218
+ continue
219
+
220
+ # Compare status
221
+ file_status = str(frontmatter.get("status", "UNKNOWN")).upper()
222
+ # Handle status with extra info (e.g., "ACTIVE - working on X")
223
+ file_status_word = file_status.split()[0] if file_status else "UNKNOWN"
224
+
225
+ if file_status_word != manifest_status:
226
+ mismatches.append(
227
+ f"SESSION-{session_id}: manifest={manifest_status}, file={file_status_word}"
228
+ )
229
+
230
+ if mismatches:
231
+ # Warn but don't fail - mismatches may be intentional during transitions
232
+ print(
233
+ f"\n⚠️ Found {len(mismatches)} status mismatches between manifest and files:\n" +
234
+ "\n".join(f" - {m}" for m in mismatches) +
235
+ "\n\nRun 'atdd session sync' to reconcile."
236
+ )
237
+
238
+
239
+ @pytest.mark.coach
240
+ def test_manifest_type_matches_session_frontmatter(manifest: Optional[Dict]):
241
+ """
242
+ SPEC-COACH-MANIFEST-005: Manifest type matches session frontmatter type
243
+
244
+ Given: Session manifest with type fields
245
+ When: Comparing to session file frontmatter
246
+ Then: Type values match
247
+ """
248
+ if manifest is None:
249
+ pytest.skip("No manifest file found")
250
+
251
+ sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
252
+ mismatches = []
253
+
254
+ for entry in manifest.get("sessions", []):
255
+ file_path = entry.get("file", "")
256
+ session_id = entry.get("id", "")
257
+ manifest_type = entry.get("type", "unknown").lower()
258
+
259
+ # Load session file
260
+ full_path = REPO_ROOT / sessions_dir / file_path
261
+ if not full_path.exists():
262
+ continue
263
+
264
+ frontmatter = parse_session_frontmatter(full_path)
265
+ if frontmatter is None:
266
+ continue
267
+
268
+ # Compare type
269
+ file_type = str(frontmatter.get("type", "unknown")).lower()
270
+
271
+ if file_type != manifest_type:
272
+ mismatches.append(
273
+ f"SESSION-{session_id}: manifest={manifest_type}, file={file_type}"
274
+ )
275
+
276
+ if mismatches:
277
+ pytest.fail(
278
+ f"Found {len(mismatches)} type mismatches between manifest and files:\n" +
279
+ "\n".join(f" - {m}" for m in mismatches)
280
+ )
281
+
282
+
283
+ @pytest.mark.coach
284
+ def test_manifest_ids_are_unique(manifest: Optional[Dict]):
285
+ """
286
+ SPEC-COACH-MANIFEST-006: Manifest session IDs are unique
287
+
288
+ Given: Session manifest
289
+ When: Checking session IDs
290
+ Then: No duplicate IDs exist
291
+ """
292
+ if manifest is None:
293
+ pytest.skip("No manifest file found")
294
+
295
+ ids = {}
296
+ duplicates = []
297
+
298
+ for entry in manifest.get("sessions", []):
299
+ session_id = entry.get("id", "")
300
+ file_path = entry.get("file", "")
301
+
302
+ if session_id in ids:
303
+ duplicates.append(f"id={session_id}: {ids[session_id]} AND {file_path}")
304
+ else:
305
+ ids[session_id] = file_path
306
+
307
+ if duplicates:
308
+ pytest.fail(
309
+ f"Found duplicate session IDs in manifest:\n" +
310
+ "\n".join(f" - {d}" for d in duplicates)
311
+ )
312
+
313
+
314
+ @pytest.mark.coach
315
+ def test_manifest_file_ids_match_filename(manifest: Optional[Dict]):
316
+ """
317
+ SPEC-COACH-MANIFEST-007: Manifest entry ID matches filename
318
+
319
+ Given: Session manifest entries
320
+ When: Comparing ID to filename pattern
321
+ Then: Entry ID matches SESSION-{ID}-* in filename
322
+ """
323
+ if manifest is None:
324
+ pytest.skip("No manifest file found")
325
+
326
+ mismatches = []
327
+
328
+ for entry in manifest.get("sessions", []):
329
+ session_id = entry.get("id", "")
330
+ file_path = entry.get("file", "")
331
+
332
+ # Extract ID from filename
333
+ filename = Path(file_path).name
334
+ file_id = get_session_id_from_filename(filename)
335
+
336
+ if file_id and file_id != session_id:
337
+ mismatches.append(
338
+ f"id={session_id} but filename has id={file_id}: {file_path}"
339
+ )
340
+
341
+ if mismatches:
342
+ pytest.fail(
343
+ f"Found {len(mismatches)} ID mismatches between manifest and filenames:\n" +
344
+ "\n".join(f" - {m}" for m in mismatches)
345
+ )
346
+
347
+
348
+ # ============================================================================
349
+ # Summary
350
+ # ============================================================================
351
+
352
+ @pytest.mark.coach
353
+ def test_manifest_alignment_summary(manifest: Optional[Dict], session_files: List[Path]):
354
+ """
355
+ Generate a summary of manifest alignment.
356
+
357
+ This test always passes but prints a summary.
358
+ """
359
+ if manifest is None:
360
+ print("\n⚠️ No manifest file found. Run 'atdd init' to create one.")
361
+ return
362
+
363
+ manifest_count = len(manifest.get("sessions", []))
364
+ file_count = len(session_files)
365
+
366
+ print("\n" + "=" * 60)
367
+ print("SESSION MANIFEST ALIGNMENT SUMMARY")
368
+ print("=" * 60)
369
+ print(f"\nManifest entries: {manifest_count}")
370
+ print(f"Session files: {file_count}")
371
+
372
+ if manifest_count == file_count:
373
+ print("\n✅ Manifest and files are aligned")
374
+ else:
375
+ print(f"\n⚠️ Difference: {abs(manifest_count - file_count)} entries")
376
+
377
+ # Status breakdown
378
+ statuses = {}
379
+ for entry in manifest.get("sessions", []):
380
+ status = entry.get("status", "UNKNOWN")
381
+ statuses[status] = statuses.get(status, 0) + 1
382
+
383
+ print("\nBy Status:")
384
+ for status, count in sorted(statuses.items()):
385
+ print(f" {status}: {count}")
386
+
387
+ print("\n" + "=" * 60)
@@ -21,6 +21,7 @@ from typing import List, Dict, Optional, Set, Tuple, Any
21
21
  import yaml
22
22
 
23
23
  from atdd.coach.utils.repo import find_repo_root
24
+ from atdd.coach.utils.config import load_atdd_config
24
25
 
25
26
  # ============================================================================
26
27
  # Configuration
@@ -498,6 +499,166 @@ def test_session_has_gate_commands(active_session_files: List[Path]):
498
499
  pytest.fail(f"Missing gate commands:\n" + "\n".join(f" - {m}" for m in missing))
499
500
 
500
501
 
502
+ # ============================================================================
503
+ # Gate Command Prefix Validation
504
+ # ============================================================================
505
+
506
+ # Default allowed command prefixes
507
+ DEFAULT_GATE_COMMAND_PREFIXES = [
508
+ "atdd",
509
+ "pytest",
510
+ "python",
511
+ "npm",
512
+ "supabase",
513
+ "cd", # Allow cd for directory changes before commands
514
+ ]
515
+
516
+
517
+ @pytest.fixture
518
+ def coach_config() -> Dict[str, Any]:
519
+ """Load coach configuration from .atdd/config.yaml."""
520
+ config = load_atdd_config(REPO_ROOT)
521
+ return config.get("coach", {})
522
+
523
+
524
+ @pytest.fixture
525
+ def gate_command_prefixes(coach_config: Dict[str, Any]) -> List[str]:
526
+ """Get allowed gate command prefixes from config."""
527
+ return coach_config.get(
528
+ "session_gate_command_prefixes",
529
+ DEFAULT_GATE_COMMAND_PREFIXES
530
+ )
531
+
532
+
533
+ @pytest.fixture
534
+ def gate_command_exceptions(coach_config: Dict[str, Any]) -> List[str]:
535
+ """Get session files exempt from gate command validation."""
536
+ return coach_config.get("session_gate_command_exceptions", [])
537
+
538
+
539
+ def extract_gate_commands(body: str) -> List[str]:
540
+ """
541
+ Extract commands from code blocks in session body.
542
+
543
+ Returns list of command strings found in ```bash or ```shell blocks.
544
+ """
545
+ import re
546
+
547
+ commands = []
548
+
549
+ # Match code blocks with bash/shell language hint
550
+ code_block_pattern = re.compile(
551
+ r"```(?:bash|shell|sh)?\n(.*?)```",
552
+ re.DOTALL
553
+ )
554
+
555
+ for match in code_block_pattern.finditer(body):
556
+ block_content = match.group(1)
557
+ # Extract individual commands (non-empty lines)
558
+ for line in block_content.strip().split('\n'):
559
+ line = line.strip()
560
+ # Skip comments and empty lines
561
+ if line and not line.startswith('#'):
562
+ commands.append(line)
563
+
564
+ return commands
565
+
566
+
567
+ def command_matches_prefix(command: str, prefixes: List[str]) -> bool:
568
+ """
569
+ Check if command starts with an allowed prefix.
570
+
571
+ Handles:
572
+ - Direct prefix match: "pytest tests/"
573
+ - Path prefix: "./atdd/atdd.py" matches "atdd"
574
+ - Chained commands: "cd foo && pytest" (each part checked)
575
+ """
576
+ import re
577
+
578
+ # Split on && and || to check each command part
579
+ parts = re.split(r'\s*(?:&&|\|\|)\s*', command)
580
+
581
+ for part in parts:
582
+ part = part.strip()
583
+ if not part:
584
+ continue
585
+
586
+ # Check direct prefix match
587
+ matched = False
588
+ for prefix in prefixes:
589
+ # Direct match
590
+ if part.startswith(prefix + " ") or part == prefix:
591
+ matched = True
592
+ break
593
+ # Path match (e.g., ./atdd/atdd.py matches "atdd")
594
+ if "/" in part:
595
+ cmd_name = part.split()[0].split("/")[-1]
596
+ # Remove .py extension
597
+ if cmd_name.endswith(".py"):
598
+ cmd_name = cmd_name[:-3]
599
+ if cmd_name == prefix:
600
+ matched = True
601
+ break
602
+
603
+ if not matched:
604
+ return False
605
+
606
+ return True
607
+
608
+
609
+ def test_session_gate_commands_use_canonical_prefixes(
610
+ active_session_files: List[Path],
611
+ gate_command_prefixes: List[str],
612
+ gate_command_exceptions: List[str]
613
+ ):
614
+ """
615
+ Test that gate commands use canonical command prefixes.
616
+
617
+ Given: Active session files with gate commands
618
+ When: Checking command prefixes in code blocks
619
+ Then: All commands start with allowed prefixes from config
620
+
621
+ Configurable via .atdd/config.yaml:
622
+ coach:
623
+ session_gate_command_prefixes: ["atdd", "pytest", "npm"]
624
+ session_gate_command_exceptions: ["SESSION-00-*.md"]
625
+ """
626
+ import fnmatch
627
+
628
+ violations = []
629
+
630
+ for f in active_session_files:
631
+ # Check if file is exempt
632
+ is_exempt = any(
633
+ fnmatch.fnmatch(f.name, pattern)
634
+ for pattern in gate_command_exceptions
635
+ )
636
+ if is_exempt:
637
+ continue
638
+
639
+ parsed = parse_session_file(f)
640
+ body = parsed["body"]
641
+
642
+ commands = extract_gate_commands(body)
643
+
644
+ for cmd in commands:
645
+ if not command_matches_prefix(cmd, gate_command_prefixes):
646
+ # Extract first word for clearer error message
647
+ first_word = cmd.split()[0] if cmd.split() else cmd
648
+ violations.append(
649
+ f"{f.name}: '{first_word}...' not in allowed prefixes"
650
+ )
651
+
652
+ if violations:
653
+ pytest.fail(
654
+ f"Found {len(violations)} gate commands with non-canonical prefixes:\n" +
655
+ "\n".join(f" - {v}" for v in violations[:20]) +
656
+ (f"\n ... and {len(violations) - 20} more" if len(violations) > 20 else "") +
657
+ f"\n\nAllowed prefixes: {gate_command_prefixes}\n"
658
+ "Configure in .atdd/config.yaml: coach.session_gate_command_prefixes"
659
+ )
660
+
661
+
501
662
  # ============================================================================
502
663
  # Pre-Implementation Gate
503
664
  # ============================================================================
@@ -177,30 +177,35 @@ artifact_contracts:
177
177
  note: "Full artifact contract conventions defined in artifact.convention.yaml"
178
178
 
179
179
  urn_format:
180
- contract_urn: "contract:domain:resource (e.g., contract:ux:foundations, contract:match:result)"
181
- telemetry_urn: "telemetry:domain:resource[.category] (e.g., telemetry:ux:foundations, telemetry:ux:foundations.colors)"
180
+ contract_urn: "contract:{theme}(:{category})*:{aspect}(.{variant})?"
181
+ telemetry_urn: "telemetry:{theme}(:{category})*:{aspect}(.{variant})?"
182
+ examples:
183
+ - "contract:commons:ux:foundations:color"
184
+ - "contract:mechanic:decision.choice"
185
+ - "contract:sensory:gesture.raw"
186
+ - "telemetry:commons:ux:foundations"
182
187
 
183
188
  telemetry_filesystem:
184
189
  description: "Telemetry URN maps to filesystem directory containing signal files"
185
- pattern: "telemetry/{domain}/{resource}[/{category}]/{signal-type}.{plane}[.{measure}].json"
186
- urn_to_path: "telemetry:{domain}:{resource}[.{category}] → telemetry/{domain}/{resource}[/{category}]/"
190
+ pattern: "telemetry/{segments}/{signal-type}.{plane}[.{measure}].json"
191
+ urn_to_path: "telemetry:{theme}:{segments} → telemetry/{theme}/{segments}/"
187
192
 
188
193
  examples:
189
194
  - urn: "telemetry:mechanic:decision.choice"
190
- path: "telemetry/decision/choice/"
195
+ path: "telemetry/mechanic/decision/choice/"
191
196
  files:
192
197
  - "metric.db.count.json"
193
198
  - "metric.be.duration.json"
194
199
  - "event.be.json"
195
200
 
196
- - urn: "telemetry:ux:foundations"
197
- path: "telemetry/ux/foundations/"
201
+ - urn: "telemetry:commons:ux:foundations"
202
+ path: "telemetry/commons/ux/foundations/"
198
203
  files:
199
204
  - "metric.ui.render_latency.json"
200
205
  - "event.ui.json"
201
206
 
202
- - urn: "telemetry:ux:foundations.colors"
203
- path: "telemetry/ux/foundations/colors/"
207
+ - urn: "telemetry:commons:ux:foundations:color"
208
+ path: "telemetry/commons/ux/foundations/color/"
204
209
  files:
205
210
  - "metric.ui.render_latency.json"
206
211
  - "event.ui.json"
@@ -222,18 +227,21 @@ artifact_contracts:
222
227
 
223
228
  produce_artifacts:
224
229
  required_fields:
225
- - name: "Artifact name (e.g., ux:foundations)"
226
- - contract: "Contract URN or null (e.g., contract:ux:foundations)"
227
- - telemetry: "Telemetry URN for observability and analytics or null (e.g., telemetry:ux:foundations)"
230
+ - name: "Artifact name (e.g., commons:ux:foundations)"
231
+ - contract: "Contract URN or null (e.g., contract:commons:ux:foundations)"
232
+ - telemetry: "Telemetry URN for observability and analytics or null (e.g., telemetry:commons:ux:foundations)"
228
233
  optional_fields:
229
234
  - to: "Visibility (internal|external), defaults to external"
230
235
  - urn: "Legacy artifact URN"
231
236
  - version: "Version string (e.g., v1)"
232
237
 
233
238
  example:
234
- - name: ux:foundations
235
- contract: contract:ux:foundations
236
- telemetry: telemetry:ux:foundations
239
+ - name: commons:ux:foundations
240
+ contract: contract:commons:ux:foundations
241
+ telemetry: telemetry:commons:ux:foundations
242
+ - name: commons:ux:foundations:color
243
+ contract: contract:commons:ux:foundations:color
244
+ telemetry: telemetry:commons:ux:foundations:color
237
245
  - name: internal:cache
238
246
  contract: null
239
247
  telemetry: null
@@ -251,10 +259,10 @@ artifact_contracts:
251
259
 
252
260
  example:
253
261
  - name: appendix:mockup
254
- - name: match:config
255
- from: wagon:setup-match
256
- contract: contract:match:config
257
- telemetry: telemetry:match:config
262
+ - name: commons:ux:foundations
263
+ from: wagon:maintain-ux
264
+ contract: contract:commons:ux:foundations
265
+ telemetry: telemetry:commons:ux:foundations
258
266
 
259
267
  features_format:
260
268
  legacy_format: