packwright 0.1.0__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,645 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from .errors import PackwrightValidationError
3
+ from .handoff import HANDOFF_ARTIFACTS
4
+ from .knowledge_contract import knowledge_artifacts
5
+ from .naming import character_slug, is_valid_slug, normalize_slug, save_context_skill_path
6
+ from .path_safety import resolve_mechanism_file
7
+ from .workspace_contract import (
8
+ WORKSPACE_DOMAIN_TEMPLATE_DIR,
9
+ WORKSPACE_INDEX_OWNER,
10
+ WORKSPACE_LAYOUT,
11
+ WORKSPACE_LIFECYCLE_DIRS,
12
+ WORKSPACE_ROOT,
13
+ WORKSPACE_SHARED_DIR,
14
+ workspace_artifacts,
15
+ )
16
+
17
+
18
+ SUPPORTED_KINDS = {"AtlasMechanismSpec", "CharacterMechanismSpec"}
19
+ SUPPORTED_VERSIONS = {"0.5", "0.6"}
20
+ MVP_ADAPTER = "codex"
21
+ SUPPORTED_ADAPTERS = {"codex", "claude-code", "cursor"}
22
+
23
+
24
+ def validate_mechanism(data):
25
+ """Validate the platform-neutral character mechanism spec."""
26
+ issues = []
27
+ if not isinstance(data, Mapping):
28
+ raise PackwrightValidationError(["mechanism root must be a mapping"])
29
+
30
+ _validate_top_level(data, issues)
31
+ _validate_metadata(data.get("metadata"), issues)
32
+ _validate_parameters(data.get("parameters", {}), issues)
33
+ _validate_run(data.get("run"), issues)
34
+ _validate_archetype(data.get("archetype"), issues)
35
+ _validate_targets(data.get("targets"), issues)
36
+ _validate_implementation_scope(data.get("implementation_scope"), issues)
37
+ _validate_identity(data, issues)
38
+ _validate_operating(data, issues)
39
+ _validate_mechanism_refs(data, issues)
40
+ _validate_projection(data, issues)
41
+ _validate_emotion(data, issues)
42
+ _validate_session_start(data.get("session_start"), issues)
43
+ _validate_memory(data, issues)
44
+ _validate_workspace(data.get("workspace"), issues)
45
+ _validate_skills(data, issues)
46
+ _validate_outputs(data, issues)
47
+ _validate_checker(data.get("checker"), issues)
48
+ _validate_coverage(data, issues)
49
+ _validate_reserved_specs(data, issues)
50
+
51
+ if issues:
52
+ raise PackwrightValidationError(issues)
53
+ return data
54
+ def path_exists(data, dotted_path):
55
+ current = data
56
+ for part in dotted_path.split("."):
57
+ if isinstance(current, Mapping) and part in current:
58
+ current = current[part]
59
+ else:
60
+ return False
61
+ return True
62
+
63
+
64
+ def file_exists(data, path):
65
+ try:
66
+ resolve_mechanism_file(data, path)
67
+ except PackwrightValidationError:
68
+ return False
69
+ return True
70
+
71
+
72
+ def _validate_top_level(data, issues):
73
+ required = (
74
+ "version",
75
+ "kind",
76
+ "metadata",
77
+ "parameters",
78
+ "run",
79
+ "archetype",
80
+ "targets",
81
+ "implementation_scope",
82
+ "identity",
83
+ "operating",
84
+ "mechanism",
85
+ "projection",
86
+ "emotion",
87
+ "session_start",
88
+ "memory",
89
+ "workspace",
90
+ "skills",
91
+ "outputs",
92
+ "checker",
93
+ "coverage",
94
+ "reserved_specs",
95
+ )
96
+ for key in required:
97
+ if key not in data:
98
+ issues.append(f"missing top-level key: {key}")
99
+
100
+ if data.get("kind") not in SUPPORTED_KINDS:
101
+ issues.append(f"kind must be one of {sorted(SUPPORTED_KINDS)}")
102
+ if str(data.get("version")) not in SUPPORTED_VERSIONS:
103
+ issues.append(f"version must be one of {sorted(SUPPORTED_VERSIONS)}")
104
+
105
+
106
+ def _validate_metadata(metadata, issues):
107
+ if not _is_mapping(metadata):
108
+ issues.append("metadata must be a mapping")
109
+ return
110
+ for key in ("name", "title", "description"):
111
+ if not _non_empty_string(metadata.get(key)):
112
+ issues.append(f"metadata.{key} must be a non-empty string")
113
+ if "archetype" in metadata and not _non_empty_string(metadata.get("archetype")):
114
+ issues.append("metadata.archetype must be a non-empty string when provided")
115
+ _validate_optional_slug(metadata, "metadata.slug", issues)
116
+
117
+
118
+ def _validate_parameters(parameters, issues):
119
+ if not _is_mapping(parameters):
120
+ issues.append("parameters must be a mapping")
121
+ return
122
+ for name, spec in parameters.items():
123
+ if not _non_empty_string(name):
124
+ issues.append("parameter keys must be non-empty strings")
125
+ continue
126
+ if not _is_mapping(spec):
127
+ issues.append(f"parameters.{name} must be a mapping")
128
+ continue
129
+ if "required" in spec and not isinstance(spec["required"], bool):
130
+ issues.append(f"parameters.{name}.required must be a boolean")
131
+ if "description" in spec and not _non_empty_string(spec["description"]):
132
+ issues.append(f"parameters.{name}.description must be a non-empty string")
133
+
134
+
135
+ def _validate_run(run, issues):
136
+ if not _is_mapping(run):
137
+ issues.append("run must be a mapping")
138
+ return
139
+ for key in ("objective", "scope", "source"):
140
+ if not _non_empty_string(run.get(key)):
141
+ issues.append(f"run.{key} must be a non-empty string")
142
+
143
+
144
+ def _validate_archetype(archetype, issues):
145
+ if not _is_mapping(archetype):
146
+ issues.append("archetype must be a mapping")
147
+ return
148
+ for key in ("id", "label", "description", "profile_scope", "workstream_scope"):
149
+ if not _non_empty_string(archetype.get(key)):
150
+ issues.append(f"archetype.{key} must be a non-empty string")
151
+ promotion = archetype.get("promotion")
152
+ if not _is_mapping(promotion):
153
+ issues.append("archetype.promotion must be a mapping")
154
+ return
155
+ for key in ("from", "to", "rule"):
156
+ if not _non_empty_string(promotion.get(key)):
157
+ issues.append(f"archetype.promotion.{key} must be a non-empty string")
158
+ if not _is_non_empty_list(promotion.get("signals")):
159
+ issues.append("archetype.promotion.signals must be a non-empty list")
160
+
161
+
162
+ def _validate_targets(targets, issues):
163
+ if not _is_mapping(targets):
164
+ issues.append("targets must be a mapping")
165
+ return
166
+ if targets.get("mvp_adapter") != MVP_ADAPTER:
167
+ issues.append(f"targets.mvp_adapter must be {MVP_ADAPTER}")
168
+ supported = targets.get("supported")
169
+ if not _is_non_empty_list(supported) or not SUPPORTED_ADAPTERS.issubset(set(supported)):
170
+ issues.append("targets.supported must include codex, claude-code, and cursor")
171
+ reserved = targets.get("reserved", {})
172
+ if not _is_mapping(reserved):
173
+ issues.append("targets.reserved must be a mapping")
174
+ return
175
+ for name, spec in reserved.items():
176
+ if not _is_mapping(spec):
177
+ issues.append(f"targets.reserved.{name} must be a mapping")
178
+ continue
179
+ if spec.get("status") != "reserved":
180
+ issues.append(f"targets.reserved.{name}.status must be reserved")
181
+ if not _non_empty_string(spec.get("reason")):
182
+ issues.append(f"targets.reserved.{name}.reason must be a non-empty string")
183
+
184
+
185
+ def _validate_implementation_scope(implementation_scope, issues):
186
+ if not _is_mapping(implementation_scope):
187
+ issues.append("implementation_scope must be a mapping")
188
+ return
189
+ if not _non_empty_string(implementation_scope.get("applies_to")):
190
+ issues.append("implementation_scope.applies_to must be a non-empty string")
191
+ boundaries = implementation_scope.get("boundaries")
192
+ if not _is_non_empty_list(boundaries):
193
+ issues.append("implementation_scope.boundaries must be a non-empty list")
194
+ return
195
+ _validate_id_list(boundaries, "implementation_scope.boundaries", issues)
196
+ for index, boundary in enumerate(_as_list(boundaries)):
197
+ if _is_mapping(boundary) and not _non_empty_string(boundary.get("text")):
198
+ issues.append(f"implementation_scope.boundaries[{index}].text must be a non-empty string")
199
+
200
+
201
+ def _validate_identity(data, issues):
202
+ identity = data.get("identity")
203
+ if not _is_mapping(identity):
204
+ issues.append("identity must be a mapping")
205
+ return
206
+ for key in ("name", "role", "positioning", "persona_path", "voice_path", "relationship_path"):
207
+ if not _non_empty_string(identity.get(key)):
208
+ issues.append(f"identity.{key} must be a non-empty string")
209
+ for key in ("user_name", "voice_summary", "mission"):
210
+ if key in identity and not _non_empty_string(identity.get(key)):
211
+ issues.append(f"identity.{key} must be a non-empty string when provided")
212
+ _validate_optional_slug(identity, "identity.slug", issues)
213
+ if not _is_non_empty_list(identity.get("stable_traits")):
214
+ issues.append("identity.stable_traits must be a non-empty list")
215
+ if not _is_non_empty_list(identity.get("work_focus")):
216
+ issues.append("identity.work_focus must be a non-empty list")
217
+ if not _is_non_empty_list(identity.get("personality")):
218
+ issues.append("identity.personality must be a non-empty list")
219
+ for key in ("persona_path", "voice_path", "relationship_path"):
220
+ _validate_file_ref(data, identity.get(key), f"identity.{key}", issues)
221
+
222
+
223
+ def _validate_operating(data, issues):
224
+ operating = data.get("operating")
225
+ if not _is_mapping(operating):
226
+ issues.append("operating must be a mapping")
227
+ return
228
+ for key in ("principles_path", "boundaries_path"):
229
+ if not _non_empty_string(operating.get(key)):
230
+ issues.append(f"operating.{key} must be a non-empty string")
231
+ else:
232
+ _validate_file_ref(data, operating.get(key), f"operating.{key}", issues)
233
+ if not _is_non_empty_list(operating.get("hot_rules")):
234
+ issues.append("operating.hot_rules must be a non-empty list")
235
+
236
+
237
+ def _validate_mechanism_refs(data, issues):
238
+ mechanism = data.get("mechanism")
239
+ if not _is_mapping(mechanism):
240
+ issues.append("mechanism must be a mapping")
241
+ return
242
+ for key in ("context_loading_path", "session_guards_path", "memory_policy_path"):
243
+ if not _non_empty_string(mechanism.get(key)):
244
+ issues.append(f"mechanism.{key} must be a non-empty string")
245
+ else:
246
+ _validate_file_ref(data, mechanism.get(key), f"mechanism.{key}", issues)
247
+
248
+
249
+ def _validate_projection(data, issues):
250
+ projection = data.get("projection")
251
+ if not _is_mapping(projection):
252
+ issues.append("projection must be a mapping")
253
+ return
254
+ for key in ("platform_capabilities_path", "ownership_contract_path"):
255
+ if not _non_empty_string(projection.get(key)):
256
+ issues.append(f"projection.{key} must be a non-empty string")
257
+ else:
258
+ _validate_file_ref(data, projection.get(key), f"projection.{key}", issues)
259
+
260
+
261
+ def _validate_emotion(data, issues):
262
+ emotion = data.get("emotion")
263
+ if not _is_mapping(emotion):
264
+ issues.append("emotion must be a mapping")
265
+ return
266
+ if emotion.get("status") != "structured_reserved":
267
+ issues.append("emotion.status must be structured_reserved")
268
+ if emotion.get("runtime") != "not_implemented":
269
+ issues.append("emotion.runtime must be not_implemented")
270
+ if emotion.get("default_mode") != "light":
271
+ issues.append("emotion.default_mode must be light")
272
+ recommended = emotion.get("recommended_mode")
273
+ if recommended is not None and recommended not in {"light", "always", "paused"}:
274
+ issues.append("emotion.recommended_mode must be light, always, or paused when provided")
275
+ modes = emotion.get("user_visible_modes")
276
+ if modes is not None and modes != ["light", "always", "paused"]:
277
+ issues.append("emotion.user_visible_modes must be [light, always, paused]")
278
+ overhead = emotion.get("estimated_overhead", {})
279
+ if overhead is not None:
280
+ if not _is_mapping(overhead):
281
+ issues.append("emotion.estimated_overhead must be a mapping when provided")
282
+ else:
283
+ for mode in ("light", "always", "paused"):
284
+ if mode not in overhead or not _non_empty_string(overhead.get(mode)):
285
+ issues.append(f"emotion.estimated_overhead.{mode} must be a non-empty string")
286
+ if not _non_empty_string(emotion.get("role")):
287
+ issues.append("emotion.role must be a non-empty string")
288
+ if "direct_interaction" in emotion and emotion.get("direct_interaction") not in {
289
+ "work_only",
290
+ "some_direct_emotional_interaction",
291
+ "decide_later",
292
+ }:
293
+ issues.append("emotion.direct_interaction must be work_only, some_direct_emotional_interaction, or decide_later")
294
+ if "relationship_continuity" in emotion and emotion.get("relationship_continuity") not in {
295
+ "task_only",
296
+ "warm_selective",
297
+ "close_continuous",
298
+ }:
299
+ issues.append("emotion.relationship_continuity must be task_only, warm_selective, or close_continuous")
300
+ for key in (
301
+ "model_path",
302
+ "state_schema_path",
303
+ "update_policy_path",
304
+ "voice_modulation_path",
305
+ "memory_events_path",
306
+ ):
307
+ if not _non_empty_string(emotion.get(key)):
308
+ issues.append(f"emotion.{key} must be a non-empty string")
309
+ else:
310
+ _validate_file_ref(data, emotion.get(key), f"emotion.{key}", issues)
311
+ projection = emotion.get("projection")
312
+ if not _is_mapping(projection):
313
+ issues.append("emotion.projection must be a mapping")
314
+ else:
315
+ allowed = {
316
+ "codex": {"optional_sidecar_when_explicitly_enabled", "spec_guided_behavior_only"},
317
+ "claude-code": {"spec_guided_behavior_only"},
318
+ "cursor": {"spec_guided_behavior_only"},
319
+ }
320
+ for adapter in SUPPORTED_ADAPTERS:
321
+ if projection.get(adapter) not in allowed[adapter]:
322
+ issues.append(f"emotion.projection.{adapter} must be one of {sorted(allowed[adapter])}")
323
+
324
+
325
+ def _validate_session_start(session_start, issues):
326
+ if not _is_mapping(session_start):
327
+ issues.append("session_start must be a mapping")
328
+ return
329
+ if session_start.get("hook") != "SessionStart":
330
+ issues.append("session_start.hook must be SessionStart")
331
+ if session_start.get("injects_facts_only") is not True:
332
+ issues.append("session_start.injects_facts_only must be true")
333
+ facts = session_start.get("facts")
334
+ if not _is_non_empty_list(facts):
335
+ issues.append("session_start.facts must be a non-empty list")
336
+ return
337
+ _validate_id_list(facts, "session_start.facts", issues)
338
+ for index, fact in enumerate(_as_list(facts)):
339
+ if _is_mapping(fact) and not _non_empty_string(fact.get("source")):
340
+ issues.append(f"session_start.facts[{index}].source must be a non-empty string")
341
+
342
+
343
+ def _validate_memory(data, issues):
344
+ memory = data.get("memory")
345
+ if not _is_mapping(memory):
346
+ issues.append("memory must be a mapping")
347
+ return
348
+ local_files = memory.get("local_files")
349
+ if not _is_non_empty_list(local_files):
350
+ issues.append("memory.local_files must be a non-empty list")
351
+ else:
352
+ _validate_id_list(local_files, "memory.local_files", issues)
353
+ ids = set()
354
+ for index, item in enumerate(_as_list(local_files)):
355
+ if not _is_mapping(item):
356
+ continue
357
+ ids.add(item.get("id"))
358
+ for key in ("path", "track"):
359
+ if not _non_empty_string(item.get(key)):
360
+ issues.append(f"memory.local_files[{index}].{key} must be a non-empty string")
361
+ _validate_file_ref(data, item.get("path"), f"memory.local_files[{index}].path", issues)
362
+ for required in (
363
+ "memory_index",
364
+ "profile",
365
+ "session_index",
366
+ "source_map",
367
+ "collaboration",
368
+ "pinned_memory",
369
+ "workstreams",
370
+ "workstream_template",
371
+ "todos",
372
+ "project_template",
373
+ "relationship_state",
374
+ "emotion_state",
375
+ ):
376
+ if required not in ids:
377
+ issues.append(f"memory.local_files must include {required}")
378
+ if not _is_non_empty_list(memory.get("durable_dirs")):
379
+ issues.append("memory.durable_dirs must be a non-empty list")
380
+ _validate_memory_limits(memory.get("limits"), issues)
381
+ if not _non_empty_string(memory.get("scratch_dir")):
382
+ issues.append("memory.scratch_dir must be a non-empty string")
383
+
384
+
385
+ def _validate_memory_limits(limits, issues):
386
+ if not _is_mapping(limits):
387
+ issues.append("memory.limits must be a mapping")
388
+ return
389
+ expected = {
390
+ "pinned_items": 20,
391
+ "recent_activity_hot_entries": 20,
392
+ "session_index_entries": 20,
393
+ "workstream_summary_bullets": 7,
394
+ "project_summary_lines": 12,
395
+ "workspace_artifact_index_entries": 50,
396
+ }
397
+ for key, value in expected.items():
398
+ if limits.get(key) != value:
399
+ issues.append(f"memory.limits.{key} must be {value}")
400
+
401
+
402
+ def _validate_workspace(workspace, issues):
403
+ if not _is_mapping(workspace):
404
+ issues.append("workspace must be a mapping")
405
+ return
406
+ for key in ("root", "layout", "domain_template_dir", "shared_dir", "index_owner"):
407
+ if not _non_empty_string(workspace.get(key)):
408
+ issues.append(f"workspace.{key} must be a non-empty string")
409
+ if workspace.get("root") != WORKSPACE_ROOT:
410
+ issues.append(f"workspace.root must be {WORKSPACE_ROOT}")
411
+ if workspace.get("layout") != WORKSPACE_LAYOUT:
412
+ issues.append(f"workspace.layout must be {WORKSPACE_LAYOUT}")
413
+ if workspace.get("domain_template_dir") != WORKSPACE_DOMAIN_TEMPLATE_DIR:
414
+ issues.append(f"workspace.domain_template_dir must be {WORKSPACE_DOMAIN_TEMPLATE_DIR}")
415
+ if workspace.get("shared_dir") != WORKSPACE_SHARED_DIR:
416
+ issues.append(f"workspace.shared_dir must be {WORKSPACE_SHARED_DIR}")
417
+ if workspace.get("index_owner") != WORKSPACE_INDEX_OWNER:
418
+ issues.append(f"workspace.index_owner must be {WORKSPACE_INDEX_OWNER}")
419
+ if workspace.get("lifecycle_dirs") != list(WORKSPACE_LIFECYCLE_DIRS):
420
+ expected = ", ".join(WORKSPACE_LIFECYCLE_DIRS)
421
+ issues.append(f"workspace.lifecycle_dirs must be [{expected}]")
422
+ if not _is_non_empty_list(workspace.get("rules")):
423
+ issues.append("workspace.rules must be a non-empty list")
424
+
425
+
426
+ def _validate_skills(data, issues):
427
+ skills = data.get("skills")
428
+ if not _is_non_empty_list(skills):
429
+ issues.append("skills must be a non-empty list")
430
+ return
431
+ _validate_id_list(skills, "skills", issues)
432
+ for index, skill in enumerate(_as_list(skills)):
433
+ if not _is_mapping(skill):
434
+ continue
435
+ for key in ("path", "layer", "trigger"):
436
+ if not _non_empty_string(skill.get(key)):
437
+ issues.append(f"skills[{index}].{key} must be a non-empty string")
438
+ _validate_file_ref(data, skill.get("path"), f"skills[{index}].path", issues)
439
+
440
+
441
+ def _validate_outputs(data, issues):
442
+ outputs = data.get("outputs")
443
+ if not _is_mapping(outputs):
444
+ issues.append("outputs must be a mapping")
445
+ return
446
+ for adapter in SUPPORTED_ADAPTERS:
447
+ config = outputs.get(adapter)
448
+ if not _is_mapping(config):
449
+ issues.append(f"outputs.{adapter} must be a mapping")
450
+ continue
451
+ if config.get("kind") != "adapter_pack":
452
+ issues.append(f"outputs.{adapter}.kind must be adapter_pack")
453
+ artifacts = config.get("artifacts")
454
+ if not _is_non_empty_list(artifacts):
455
+ issues.append(f"outputs.{adapter}.artifacts must be a non-empty list")
456
+ continue
457
+ skill_path = save_context_skill_path(data, adapter)
458
+ if adapter == "codex":
459
+ required = (
460
+ "AGENTS.md",
461
+ skill_path,
462
+ "manifest.json",
463
+ "memory/index.md",
464
+ "memory/profile.md",
465
+ "memory/session-index.md",
466
+ "memory/source-map.md",
467
+ "memory/collaboration.md",
468
+ "memory/pinned.md",
469
+ "memory/workstreams.md",
470
+ "memory/workstreams/_template.md",
471
+ "memory/projects/_template.md",
472
+ "memory/todos.md",
473
+ "memory/relationship-state.md",
474
+ "memory/emotion-state.json.example",
475
+ *knowledge_artifacts(),
476
+ *workspace_artifacts(),
477
+ )
478
+ elif adapter == "claude-code":
479
+ required = (
480
+ "CLAUDE.md",
481
+ skill_path,
482
+ ".claude/settings.local.json.example",
483
+ "manifest.json",
484
+ "memory/index.md",
485
+ "memory/profile.md",
486
+ "memory/session-index.md",
487
+ "memory/source-map.md",
488
+ "memory/collaboration.md",
489
+ "memory/pinned.md",
490
+ "memory/workstreams.md",
491
+ "memory/workstreams/_template.md",
492
+ "memory/projects/_template.md",
493
+ "memory/todos.md",
494
+ "memory/relationship-state.md",
495
+ "memory/emotion-state.json.example",
496
+ *knowledge_artifacts(),
497
+ *workspace_artifacts(),
498
+ )
499
+ else:
500
+ slug = character_slug(data)
501
+ required = (
502
+ "manifest.json",
503
+ f".cursor/rules/{slug}.mdc",
504
+ f".cursor/rules/{slug}-memory.mdc",
505
+ skill_path,
506
+ "memory/index.md",
507
+ "memory/profile.md",
508
+ "memory/session-index.md",
509
+ "memory/source-map.md",
510
+ "memory/collaboration.md",
511
+ "memory/pinned.md",
512
+ "memory/workstreams.md",
513
+ "memory/workstreams/_template.md",
514
+ "memory/projects/_template.md",
515
+ "memory/todos.md",
516
+ "memory/relationship-state.md",
517
+ "memory/emotion-state.json.example",
518
+ *knowledge_artifacts(),
519
+ *HANDOFF_ARTIFACTS,
520
+ *workspace_artifacts(),
521
+ )
522
+ for artifact in required:
523
+ if artifact not in artifacts:
524
+ issues.append(f"outputs.{adapter}.artifacts must include {artifact}")
525
+
526
+
527
+ def _validate_checker(checker, issues):
528
+ if not _is_mapping(checker):
529
+ issues.append("checker must be a mapping")
530
+ return
531
+ threshold = checker.get("threshold")
532
+ if not isinstance(threshold, int) or threshold < 0 or threshold > 100:
533
+ issues.append("checker.threshold must be an integer from 0 to 100")
534
+ if not _is_non_empty_list(checker.get("required_checks")):
535
+ issues.append("checker.required_checks must be a non-empty list")
536
+
537
+
538
+ def _validate_coverage(data, issues):
539
+ coverage = data.get("coverage")
540
+ if not _is_mapping(coverage):
541
+ issues.append("coverage must be a mapping")
542
+ return
543
+ required = coverage.get("required_mechanisms")
544
+ implemented_by = coverage.get("implemented_by")
545
+ if not _is_non_empty_list(required):
546
+ issues.append("coverage.required_mechanisms must be a non-empty list")
547
+ required = []
548
+ if not _is_mapping(implemented_by):
549
+ issues.append("coverage.implemented_by must be a mapping")
550
+ implemented_by = {}
551
+
552
+ seen = set()
553
+ for mechanism in _as_list(required):
554
+ if not _non_empty_string(mechanism):
555
+ issues.append("coverage mechanisms must be non-empty strings")
556
+ continue
557
+ if mechanism in seen:
558
+ issues.append(f"duplicate character mechanism: {mechanism}")
559
+ seen.add(mechanism)
560
+
561
+ paths = implemented_by.get(mechanism)
562
+ if not _is_non_empty_list(paths):
563
+ issues.append(f"coverage.implemented_by.{mechanism} must be a non-empty list")
564
+ continue
565
+ for path in paths:
566
+ if not _non_empty_string(path):
567
+ issues.append(f"coverage path for {mechanism} must be a non-empty string")
568
+ elif not path_exists(data, path):
569
+ issues.append(f"coverage path for {mechanism} does not exist: {path}")
570
+
571
+
572
+ def _validate_reserved_specs(data, issues):
573
+ reserved_specs = data.get("reserved_specs", {})
574
+ if not _is_mapping(reserved_specs):
575
+ issues.append("reserved_specs must be a mapping")
576
+ return
577
+ for name, spec in reserved_specs.items():
578
+ if not _is_mapping(spec):
579
+ issues.append(f"reserved_specs.{name} must be a mapping")
580
+ continue
581
+ if spec.get("status") not in {"reserved", "structured_reserved"}:
582
+ issues.append(f"reserved_specs.{name}.status must be reserved or structured_reserved")
583
+ if spec.get("runtime") != "not_implemented":
584
+ issues.append(f"reserved_specs.{name}.runtime must be not_implemented")
585
+ if not _non_empty_string(spec.get("spec_path")):
586
+ issues.append(f"reserved_specs.{name}.spec_path must be a non-empty string")
587
+
588
+
589
+ def _validate_id_list(items, path, issues):
590
+ seen = set()
591
+ for index, item in enumerate(_as_list(items)):
592
+ item_path = f"{path}[{index}]"
593
+ if not _is_mapping(item):
594
+ issues.append(f"{item_path} must be a mapping")
595
+ continue
596
+ item_id = item.get("id")
597
+ if not _non_empty_string(item_id):
598
+ issues.append(f"{item_path}.id must be a non-empty string")
599
+ elif item_id in seen:
600
+ issues.append(f"duplicate id in {path}: {item_id}")
601
+ else:
602
+ seen.add(item_id)
603
+
604
+
605
+ def _validate_optional_slug(mapping, label, issues):
606
+ if not _is_mapping(mapping) or "slug" not in mapping:
607
+ return
608
+ value = mapping.get("slug")
609
+ if not _non_empty_string(value):
610
+ issues.append(f"{label} must be a non-empty string when provided")
611
+ return
612
+ normalized = normalize_slug(value, default="")
613
+ if not normalized or not is_valid_slug(normalized):
614
+ issues.append(f"{label} must normalize to a lowercase ASCII slug")
615
+
616
+
617
+ def _validate_file_ref(data, rel_path, label, issues):
618
+ if not _non_empty_string(rel_path):
619
+ return
620
+ try:
621
+ resolve_mechanism_file(data, rel_path, label)
622
+ except PackwrightValidationError as exc:
623
+ issues.extend(exc.issues)
624
+
625
+
626
+ def _is_mapping(value):
627
+ return isinstance(value, Mapping)
628
+
629
+
630
+ def _is_list(value):
631
+ return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray))
632
+
633
+
634
+ def _is_non_empty_list(value):
635
+ return _is_list(value) and len(value) > 0
636
+
637
+
638
+ def _as_list(value):
639
+ if _is_list(value):
640
+ return value
641
+ return []
642
+
643
+
644
+ def _non_empty_string(value):
645
+ return isinstance(value, str) and bool(value.strip())