ww-agentic-workflows 1.0.0.dev3__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.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/config/__init__.py ADDED
@@ -0,0 +1,582 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Strict loading for the normalized ``ww.yaml`` schema."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import yaml
11
+
12
+ from ww.config_files import configuration_file_exists
13
+ from ww.errors import ConfigurationError
14
+ from ww.extensions import ExtensionRegistry
15
+ from ww.runtimes import RUNTIME_INSTRUCTIONS
16
+ from ww.workflow_config import (
17
+ ALL,
18
+ DOCUMENT_SCOPES,
19
+ DocumentDefinition,
20
+ DocumentScope,
21
+ HandlerDefinition,
22
+ HookDefinition,
23
+ ModeDefinition,
24
+ NameFilter,
25
+ ProfileDefinition,
26
+ RuleGroup,
27
+ StepDefinition,
28
+ WorkflowConfiguration,
29
+ WorkflowDefinition,
30
+ )
31
+ from ww.workflow_validation import validate_configuration
32
+
33
+ from .actions import _parse_hooks
34
+ from .composition import compose_configuration
35
+ from .rules import RuleGroupContribution, parse_rules_root, resolve_step_rules
36
+ from .steps import _parse_handlers, _parse_step
37
+ from .values import (
38
+ _NAME,
39
+ _description,
40
+ _description_items,
41
+ _mapping,
42
+ _name,
43
+ _name_filter,
44
+ _named_entry,
45
+ _only,
46
+ _optional_agent,
47
+ _optional_string,
48
+ _profile,
49
+ _required_list,
50
+ _role,
51
+ _string_list,
52
+ _subagents,
53
+ _unique,
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class YamlConfigurationLoader:
59
+ """The built-in ``ww.yaml`` notation frontend.
60
+
61
+ ``extensions`` supply the rule groups the project's configured extensions
62
+ ship, merged under the root ``rules`` before any step names them.
63
+ """
64
+
65
+ path: Path
66
+ extensions: ExtensionRegistry | None = None
67
+
68
+ def __call__(self) -> WorkflowConfiguration:
69
+ return parse_yaml_configuration(self.path, self.extensions)
70
+
71
+
72
+ def parse_yaml_configuration(
73
+ path: Path, extensions: ExtensionRegistry | None = None
74
+ ) -> WorkflowConfiguration:
75
+ """Load a normalized configuration without deriving execution behavior.
76
+
77
+ Parsing checks the YAML notation's shape. Cross-definition semantics are
78
+ deliberately checked by :func:`validate_configuration` after any frontend
79
+ has produced this same normalized model. The files ``path`` imports are
80
+ composed into it first, so the parser reads one document; rule paths
81
+ resolve against ``path``'s directory, where composition rebases them.
82
+ """
83
+ if not configuration_file_exists(path):
84
+ raise ConfigurationError(f"workflow configuration not found: {path}")
85
+ composed = compose_configuration(path)
86
+ configuration = parse_yaml_text(
87
+ composed.text,
88
+ str(path),
89
+ base=path.parent,
90
+ extension_rule_groups=(
91
+ extensions.rule_groups() if extensions is not None else ()
92
+ ),
93
+ )
94
+ return replace(
95
+ configuration,
96
+ workflow_provenance=composed.workflow_provenance,
97
+ )
98
+
99
+
100
+ def parse_yaml_text(
101
+ text: str,
102
+ source: str = "<string>",
103
+ *,
104
+ base: Path | None = None,
105
+ extension_rule_groups: tuple[tuple[str, RuleGroupContribution], ...] = (),
106
+ ) -> WorkflowConfiguration:
107
+ """Parse ``ww.yaml`` notation held in memory, named ``source``.
108
+
109
+ ``base`` is the directory rule paths resolve against; it defaults to the
110
+ current directory.
111
+ """
112
+ raw = _raw_from_text(text, source)
113
+ base = base if base is not None else Path.cwd()
114
+ modes = _parse_modes(raw.get("modes", []))
115
+ profiles = _parse_profiles(raw.get("profiles", {}))
116
+ rule_groups = parse_rules_root(raw.get("rules"), base, extension_rule_groups)
117
+ handlers = _parse_handlers(raw.get("handlers", []))
118
+ workflows_raw = _required_list(raw, "workflows", "configuration")
119
+ global_hooks = _parse_hooks(raw.get("hooks", {}), "global", "hooks")
120
+ handlers_by_name = {handler.name: handler for handler in handlers}
121
+ parsed = tuple(
122
+ _parse_workflow(item, f"workflows[{index}]", handlers_by_name)
123
+ for index, item in enumerate(workflows_raw)
124
+ )
125
+ workflows = _resolve_explicit(_resolve_inheritance(parsed))
126
+ handlers = _resolve_explicit_handlers(handlers)
127
+ workflows, handlers = resolve_step_rules(workflows, handlers, rule_groups, base)
128
+ return WorkflowConfiguration(
129
+ _extend_modes_to_heirs(modes, workflows),
130
+ profiles,
131
+ handlers,
132
+ _extend_to_heirs(global_hooks, workflows),
133
+ workflows,
134
+ documents=_parse_documents(raw.get("documents", [])),
135
+ rule_groups=_extend_groups_to_heirs(rule_groups, workflows),
136
+ )
137
+
138
+
139
+ def load_configuration(
140
+ path: Path, extensions: ExtensionRegistry | None = None
141
+ ) -> WorkflowConfiguration:
142
+ """Load and semantically validate the built-in YAML notation."""
143
+ return validate_configuration(
144
+ YamlConfigurationLoader(path, extensions)(), extensions
145
+ )
146
+
147
+
148
+ def load_modes(
149
+ path: Path, extensions: ExtensionRegistry | None = None
150
+ ) -> dict[str, ModeDefinition]:
151
+ return {mode.name: mode for mode in load_configuration(path, extensions).modes}
152
+
153
+
154
+ def _raw_from_text(text: str, source: str) -> dict[str, Any]:
155
+ try:
156
+ raw = yaml.safe_load(text)
157
+ except yaml.YAMLError as error:
158
+ raise ConfigurationError(f"invalid YAML in {source}: {error}") from error
159
+ if not isinstance(raw, dict):
160
+ raise ConfigurationError("workflow configuration must be a mapping")
161
+ allowed = {
162
+ "modes",
163
+ "profiles",
164
+ "documents",
165
+ "handlers",
166
+ "hooks",
167
+ "workflows",
168
+ "rules",
169
+ }
170
+ unknown = set(raw) - allowed
171
+ if unknown:
172
+ raise ConfigurationError(
173
+ f"configuration has unknown key(s): {', '.join(sorted(unknown))}"
174
+ )
175
+ return raw
176
+
177
+
178
+ def _parse_modes(data: Any) -> tuple[ModeDefinition, ...]:
179
+ if not isinstance(data, list):
180
+ raise ConfigurationError("modes must be a list")
181
+ result = []
182
+ for index, item in enumerate(data):
183
+ path = f"modes[{index}]"
184
+ allowed = {"name", "description", "workflows", "steps"}
185
+ mapping = _named_entry(
186
+ _mapping(item, path),
187
+ path,
188
+ allowed=allowed,
189
+ ignored={"workflows", "steps"},
190
+ )
191
+ _only(mapping, allowed, path)
192
+ name = _name(mapping, path)
193
+ # A mode's ``[]`` admits every name, as a hook's does.
194
+ filters = {
195
+ key: _name_filter(mapping[key], f"mode {name!r} {key}", empty=ALL)
196
+ for key in ("workflows", "steps")
197
+ if key in mapping
198
+ }
199
+ result.append(
200
+ ModeDefinition(
201
+ name,
202
+ _description_items(mapping.get("description"), f"mode {name!r}"),
203
+ workflows=filters.get("workflows"),
204
+ steps=filters.get("steps"),
205
+ )
206
+ )
207
+ return tuple(result)
208
+
209
+
210
+ def _parse_documents(data: Any) -> tuple[DocumentDefinition, ...]:
211
+ """Parse root ``documents``: named entries with a description and scope."""
212
+ if data is None:
213
+ return ()
214
+ if not isinstance(data, list):
215
+ raise ConfigurationError("documents must be a list")
216
+ result = []
217
+ for index, item in enumerate(data):
218
+ path = f"documents[{index}]"
219
+ mapping = _named_entry(_mapping(item, path), path)
220
+ _only(mapping, {"name", "description", "scope", "path"}, path)
221
+ scope = mapping.get("scope", "task")
222
+ if scope not in DOCUMENT_SCOPES:
223
+ raise ConfigurationError(
224
+ f"{path}.scope must be 'task', 'project', or 'user'"
225
+ )
226
+ try:
227
+ result.append(
228
+ DocumentDefinition(
229
+ _name(mapping, path),
230
+ _description(mapping.get("description"), path),
231
+ cast(DocumentScope, scope),
232
+ path=_optional_string(mapping, "path", path),
233
+ )
234
+ )
235
+ except ValueError as error:
236
+ raise ConfigurationError(f"{path}: {error}") from error
237
+ _unique((item.name for item in result), "document")
238
+ return tuple(result)
239
+
240
+
241
+ def _parse_profiles(data: Any) -> tuple[ProfileDefinition, ...]:
242
+ if data is None:
243
+ return ()
244
+ if not isinstance(data, dict):
245
+ raise ConfigurationError("profiles must be a mapping")
246
+ result = []
247
+ for name, description in data.items():
248
+ if not isinstance(name, str) or not _NAME.fullmatch(name):
249
+ raise ConfigurationError("profiles keys must be normalized names")
250
+ if description is not None and (
251
+ not isinstance(description, str) or not description.strip()
252
+ ):
253
+ raise ConfigurationError(f"profiles.{name} must be a string or null")
254
+ result.append(ProfileDefinition(name, description))
255
+ return tuple(result)
256
+
257
+
258
+ # The workflow keys an inheriting workflow may set, and the definition fields
259
+ # each one overrides; everything else comes from the workflow it inherits.
260
+ _OVERRIDES = {
261
+ "description": ("description",),
262
+ "modes": ("modes",),
263
+ "agent": ("agent",),
264
+ "model": ("model",),
265
+ "reasoning": ("reasoning",),
266
+ "profile": ("profile", "profile_description"),
267
+ "role": ("role",),
268
+ "subagents": ("subagents",),
269
+ "explicit": ("explicit",),
270
+ "runtime": ("runtime",),
271
+ "restartable": ("restartable",),
272
+ "recommended_next_workflow": ("recommended_next_workflow",),
273
+ "hooks_from": ("hooks_from",),
274
+ "needs_hooks_from": ("needs_hooks_from",),
275
+ }
276
+
277
+
278
+ @dataclass(frozen=True)
279
+ class _ParsedWorkflow:
280
+ """One workflow entry, and which of its fields the entry set itself."""
281
+
282
+ definition: WorkflowDefinition
283
+ overrides: frozenset[str]
284
+
285
+
286
+ def _parse_workflow(
287
+ data: Any, path: str, handlers_by_name: dict[str, HandlerDefinition]
288
+ ) -> _ParsedWorkflow:
289
+ workflow_keys = {
290
+ "name",
291
+ "description",
292
+ "steps",
293
+ "hooks",
294
+ "modes",
295
+ "agent",
296
+ "model",
297
+ "reasoning",
298
+ "profile",
299
+ "role",
300
+ "subagents",
301
+ "explicit",
302
+ "runtime",
303
+ "restartable",
304
+ "inherit",
305
+ "recommended_next_workflow",
306
+ "hooks_from",
307
+ "needs_hooks_from",
308
+ }
309
+ mapping = _named_entry(
310
+ _mapping(data, path),
311
+ path,
312
+ allowed=workflow_keys,
313
+ ignored={"steps"},
314
+ )
315
+ if "workflows" in mapping:
316
+ raise ConfigurationError(
317
+ f"{path}.workflows defines nested workflows, which are not supported"
318
+ )
319
+ _only(mapping, workflow_keys, path)
320
+ name = _name(mapping, path)
321
+ runtime = mapping.get("runtime")
322
+ if runtime is not None and runtime not in RUNTIME_INSTRUCTIONS:
323
+ raise ConfigurationError(
324
+ f"{path}.runtime must be one of: " + ", ".join(RUNTIME_INSTRUCTIONS)
325
+ )
326
+ restartable = mapping.get("restartable", False)
327
+ if not isinstance(restartable, bool):
328
+ raise ConfigurationError(f"{path}.restartable must be true or false")
329
+ inherits = mapping.get("inherit")
330
+ if inherits is not None and (not isinstance(inherits, str) or not inherits):
331
+ raise ConfigurationError(f"{path}.inherit must name a workflow")
332
+ if inherits is not None:
333
+ # A workflow that needs other steps or hooks is a workflow of its
334
+ # own, not a copy of another one.
335
+ declared = sorted({"steps", "hooks"}.intersection(mapping))
336
+ if declared:
337
+ raise ConfigurationError(
338
+ f"workflow {name!r} inherits {inherits!r} and cannot declare "
339
+ + " or ".join(declared)
340
+ )
341
+ steps: tuple[StepDefinition, ...] = ()
342
+ else:
343
+ steps_data = _required_list(mapping, "steps", f"workflow {name!r}")
344
+ steps = tuple(
345
+ _parse_step(item, f"{path}.steps[{index}]", handlers_by_name)
346
+ for index, item in enumerate(steps_data)
347
+ )
348
+ recommended = mapping.get("recommended_next_workflow")
349
+ if recommended is not None and (
350
+ not isinstance(recommended, str) or not recommended.strip()
351
+ ):
352
+ raise ConfigurationError(
353
+ f"{path}.recommended_next_workflow must name a workflow"
354
+ )
355
+ hooks_from = mapping.get("hooks_from")
356
+ if hooks_from is not None and (
357
+ not isinstance(hooks_from, str) or not hooks_from.strip()
358
+ ):
359
+ raise ConfigurationError(f"{path}.hooks_from must name a workflow")
360
+ needs_hooks_from = mapping.get("needs_hooks_from", False)
361
+ if not isinstance(needs_hooks_from, bool):
362
+ raise ConfigurationError(f"{path}.needs_hooks_from must be true or false")
363
+ definition = WorkflowDefinition(
364
+ name=name,
365
+ description=_description(mapping.get("description"), f"workflow {name!r}"),
366
+ steps=steps,
367
+ hooks=_parse_hooks(
368
+ mapping.get("hooks", {}), "workflow", f"workflow {name!r} hooks"
369
+ ),
370
+ modes=_string_list(mapping.get("modes", []), f"workflow {name!r} modes"),
371
+ agent=_optional_agent(mapping, "agent", f"workflow {name!r}"),
372
+ model=_optional_string(mapping, "model", f"workflow {name!r}"),
373
+ reasoning=_optional_string(mapping, "reasoning", f"workflow {name!r}"),
374
+ **_profile(mapping, f"workflow {name!r}"),
375
+ role=_role(mapping, f"workflow {name!r}"),
376
+ subagents=_subagents(mapping, f"workflow {name!r}"),
377
+ explicit=_optional_bool(mapping, "explicit", f"workflow {name!r}"),
378
+ runtime=runtime,
379
+ restartable=restartable,
380
+ inherits=inherits,
381
+ recommended_next_workflow=recommended,
382
+ hooks_from=hooks_from,
383
+ needs_hooks_from=needs_hooks_from,
384
+ )
385
+ return _ParsedWorkflow(
386
+ definition,
387
+ frozenset(
388
+ field
389
+ for key, fields in _OVERRIDES.items()
390
+ if key in mapping
391
+ for field in fields
392
+ ),
393
+ )
394
+
395
+
396
+ def _resolve_inheritance(
397
+ parsed: tuple[_ParsedWorkflow, ...],
398
+ ) -> tuple[WorkflowDefinition, ...]:
399
+ """Complete every inheriting workflow from the one it names, in order.
400
+
401
+ The copy takes everything, steps and workflow hooks included, and the
402
+ entry's own settings replace the copied ones. Chains resolve from the
403
+ root; a cycle or an unknown name is an error.
404
+ """
405
+ entries = {entry.definition.name: entry for entry in parsed}
406
+ if len(entries) != len(parsed):
407
+ # Duplicate names are reported by validation, with its usual message.
408
+ return tuple(entry.definition for entry in parsed)
409
+ resolved: dict[str, WorkflowDefinition] = {}
410
+
411
+ def resolve(name: str, chain: tuple[str, ...]) -> WorkflowDefinition:
412
+ if name in resolved:
413
+ return resolved[name]
414
+ entry = entries[name]
415
+ parent = entry.definition.inherits
416
+ if parent is None:
417
+ resolved[name] = entry.definition
418
+ return entry.definition
419
+ if parent not in entries:
420
+ raise ConfigurationError(
421
+ f"workflow {name!r} inherits unknown workflow {parent!r}"
422
+ )
423
+ if parent in chain:
424
+ cycle = " -> ".join((*chain, name, parent))
425
+ raise ConfigurationError(f"workflow inheritance cycle: {cycle}")
426
+ base = resolve(parent, (*chain, name))
427
+ resolved[name] = replace(
428
+ base,
429
+ name=name,
430
+ inherits=parent,
431
+ **{
432
+ field: getattr(entry.definition, field)
433
+ for field in sorted(entry.overrides)
434
+ },
435
+ )
436
+ return resolved[name]
437
+
438
+ return tuple(resolve(entry.definition.name, ()) for entry in parsed)
439
+
440
+
441
+ def _optional_bool(mapping: dict[str, Any], key: str, path: str) -> bool | None:
442
+ if key not in mapping:
443
+ return None
444
+ value = mapping[key]
445
+ if not isinstance(value, bool):
446
+ raise ConfigurationError(f"{path}.{key} must be true or false")
447
+ return value
448
+
449
+
450
+ def _resolve_explicit(
451
+ workflows: tuple[WorkflowDefinition, ...],
452
+ ) -> tuple[WorkflowDefinition, ...]:
453
+ """Freeze inherited visibility guidance after workflow inheritance resolves."""
454
+ resolved = []
455
+ for workflow in workflows:
456
+ explicit = workflow.explicit if workflow.explicit is not None else False
457
+ resolved.append(
458
+ replace(
459
+ workflow,
460
+ explicit=explicit,
461
+ steps=_resolve_explicit_steps(workflow.steps, explicit),
462
+ )
463
+ )
464
+ return tuple(resolved)
465
+
466
+
467
+ def _resolve_explicit_handlers(
468
+ handlers: tuple[HandlerDefinition, ...],
469
+ ) -> tuple[HandlerDefinition, ...]:
470
+ """Give reusable step handlers a default after workflow references resolve."""
471
+ return tuple(
472
+ _resolve_explicit_steps((handler,), False)[0]
473
+ if isinstance(handler, StepDefinition)
474
+ else handler
475
+ for handler in handlers
476
+ )
477
+
478
+
479
+ def _resolve_explicit_steps(
480
+ values: tuple[StepDefinition, ...], parent: bool
481
+ ) -> tuple[StepDefinition, ...]:
482
+ result = []
483
+ for step in values:
484
+ effective = step.explicit if step.explicit is not None else parent
485
+ items = (
486
+ replace(
487
+ step.items,
488
+ steps=_resolve_explicit_steps(step.items.steps, effective),
489
+ )
490
+ if step.items is not None
491
+ else None
492
+ )
493
+ children = (
494
+ replace(
495
+ step.children,
496
+ steps=_resolve_explicit_steps(step.children.steps, effective),
497
+ )
498
+ if step.children is not None
499
+ else None
500
+ )
501
+ result.append(
502
+ replace(
503
+ step,
504
+ explicit=effective,
505
+ child_steps=_resolve_explicit_steps(step.child_steps, effective),
506
+ loop_steps=_resolve_explicit_steps(step.loop_steps, effective),
507
+ assessment_outcomes=_resolve_explicit_steps(
508
+ step.assessment_outcomes, effective
509
+ ),
510
+ items=items,
511
+ children=children,
512
+ )
513
+ )
514
+ return tuple(result)
515
+
516
+
517
+ def _extend_to_heirs(
518
+ hooks: tuple[HookDefinition, ...], workflows: tuple[WorkflowDefinition, ...]
519
+ ) -> tuple[HookDefinition, ...]:
520
+ """Let a global hook filtered to a workflow also run for its heirs.
521
+
522
+ Inheriting a workflow means behaving like it, so a hook written for
523
+ ``hotfix`` also runs for a ``bugfix`` that inherits it.
524
+ """
525
+ return tuple(
526
+ replace(hook, workflows=_with_heirs(hook.workflows, workflows))
527
+ if hook.workflows.listed
528
+ else hook
529
+ for hook in hooks
530
+ )
531
+
532
+
533
+ def _extend_groups_to_heirs(
534
+ groups: tuple[RuleGroup, ...], workflows: tuple[WorkflowDefinition, ...]
535
+ ) -> tuple[RuleGroup, ...]:
536
+ """Let a rule group filtered to a workflow also apply to its heirs."""
537
+ return tuple(
538
+ replace(group, workflows=_with_heirs(group.workflows, workflows))
539
+ if group.workflows.listed
540
+ else group
541
+ for group in groups
542
+ )
543
+
544
+
545
+ def _extend_modes_to_heirs(
546
+ modes: tuple[ModeDefinition, ...], workflows: tuple[WorkflowDefinition, ...]
547
+ ) -> tuple[ModeDefinition, ...]:
548
+ """Let a mode filtered to a workflow also apply automatically to its heirs."""
549
+ return tuple(
550
+ replace(mode, workflows=_with_heirs(mode.workflows, workflows))
551
+ if mode.workflows is not None and mode.workflows.listed
552
+ else mode
553
+ for mode in modes
554
+ )
555
+
556
+
557
+ def _with_heirs(
558
+ selected: NameFilter, workflows: tuple[WorkflowDefinition, ...]
559
+ ) -> NameFilter:
560
+ """``selected`` followed by every workflow that inherits one of them."""
561
+ names = selected.listed
562
+ parents = {workflow.name: workflow.inherits for workflow in workflows}
563
+
564
+ def lineage(name: str) -> set[str]:
565
+ found: set[str] = set()
566
+ current: str | None = name
567
+ while current is not None and current not in found:
568
+ found.add(current)
569
+ current = parents.get(current)
570
+ return found
571
+
572
+ return NameFilter.of(
573
+ (
574
+ *names,
575
+ *(
576
+ workflow.name
577
+ for workflow in workflows
578
+ if workflow.name not in names
579
+ and lineage(workflow.name).intersection(names)
580
+ ),
581
+ )
582
+ )