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/rules.py ADDED
@@ -0,0 +1,511 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Rule files, the root ``rules`` mapping, and a step's ``rules`` list.
3
+
4
+ A rule is a sentence a step's agent must follow, stored as the body of a
5
+ Markdown file whose optional YAML frontmatter scopes it to files (``paths``),
6
+ gives it a command ww runs when the step completes (``check``), and asks for
7
+ a worker to judge it (``agent``, ``model``, ``reasoning``). The root
8
+ ``rules`` mapping names groups of rule files; a step's ``rules`` list adds its
9
+ own rules and names groups it wants regardless of their filters.
10
+
11
+ The text normalisation and hash defined here are the single source of truth
12
+ for a rule's identity by wording.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import re
19
+ from dataclasses import dataclass, replace
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import yaml
24
+
25
+ from ww.actions import Commands
26
+ from ww.actions.command import CommandAction
27
+ from ww.errors import ConfigurationError
28
+ from ww.extensions.api import RuleGroupContribution
29
+ from ww.validation import is_positive_int
30
+ from ww.workflow_config import (
31
+ ALL,
32
+ ALL_NAMES,
33
+ NO_NAMES,
34
+ HandlerDefinition,
35
+ ItemFlow,
36
+ NameFilter,
37
+ RuleDefinition,
38
+ RuleGroup,
39
+ RuleGroupRef,
40
+ RuleHints,
41
+ StepDefinition,
42
+ StepRule,
43
+ UnresolvedStepRule,
44
+ WorkflowDefinition,
45
+ )
46
+
47
+ from .values import (
48
+ _NAME,
49
+ _mapping,
50
+ _name_filter,
51
+ _only,
52
+ _optional_agent,
53
+ _optional_string,
54
+ )
55
+
56
+ RULE_FILE_KEYS = {"paths", "check", "max_fixes", "agent", "model", "reasoning"}
57
+ STEP_RULE_KEYS = {
58
+ "text",
59
+ "argv",
60
+ "shell",
61
+ "args",
62
+ "env",
63
+ "assert",
64
+ "max_fixes",
65
+ "agent",
66
+ "model",
67
+ "reasoning",
68
+ }
69
+ _CHECK_KEYS = {"argv", "shell", "args", "env", "assert"}
70
+ _GROUP_KEYS = {"rules", "workflows", "steps", "agent", "model", "reasoning"}
71
+ # A bare string shaped like this names a group or a file, never a sentence:
72
+ # "python" and "rules/python.md" match, "Write tests first" does not.
73
+ _REFERENCE = re.compile(r"[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?")
74
+ # The end of a sentence: ".", "!" or "?" before a space or the end, e.g. the
75
+ # "." in "Run tests. Then lint." but not the one in "setup.py".
76
+ _SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
77
+ # A run of whitespace, e.g. the " \n " between two words.
78
+ _WHITESPACE = re.compile(r"\s+")
79
+
80
+
81
+ def normalize_rule_text(text: str) -> str:
82
+ """The wording a rule is identified by: stripped, whitespace collapsed."""
83
+ return _WHITESPACE.sub(" ", text.strip())
84
+
85
+
86
+ def rule_text_hash(text: str) -> str:
87
+ """The sha256 of a rule's normalised text, the key of derived knowledge."""
88
+ return hashlib.sha256(normalize_rule_text(text).encode("utf-8")).hexdigest()
89
+
90
+
91
+ def rule_summary(text: str) -> str:
92
+ """The first sentence of ``text``, or its first line when it has none."""
93
+ stripped = text.strip()
94
+ end = _SENTENCE_END.search(stripped)
95
+ first = stripped[: end.end()] if end else stripped.splitlines()[0]
96
+ return normalize_rule_text(first)
97
+
98
+
99
+ def rule_source(source: str | None, root: Path) -> str | None:
100
+ """A rule file as ww shows it: relative to ``root`` when it lies inside."""
101
+ if source is None:
102
+ return None
103
+ path = Path(source)
104
+ try:
105
+ return path.resolve().relative_to(root.resolve()).as_posix()
106
+ except ValueError:
107
+ return str(path)
108
+
109
+
110
+ def looks_like_reference(value: str) -> bool:
111
+ """Whether a bare step entry names a group or file rather than a sentence."""
112
+ return bool(_REFERENCE.fullmatch(value)) and not value.endswith(
113
+ (".", "!", "?", ",", ";", ":")
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class _DeclaredGroup:
119
+ """One group as written, before its items are resolved.
120
+
121
+ A string item is a group name, else a path relative to ``base``; a
122
+ ``Path`` item is always a path. ``base`` is ``None`` for an extension's
123
+ group, whose strings may only name groups.
124
+ """
125
+
126
+ name: str
127
+ items: tuple[Path | str, ...]
128
+ workflows: NameFilter
129
+ steps: NameFilter
130
+ hints: RuleHints
131
+ origin: str
132
+ base: Path | None
133
+
134
+
135
+ def parse_rule_file(
136
+ path: Path, rule_id: str, hints: RuleHints | None = None
137
+ ) -> RuleDefinition:
138
+ """Read one rule file: optional YAML frontmatter, then the rule's body.
139
+
140
+ ``hints`` are the group's; the file's own ``agent``, ``model``, and
141
+ ``reasoning`` replace them field by field.
142
+ """
143
+ hints = hints if hints is not None else RuleHints()
144
+ label = str(path)
145
+ try:
146
+ content = path.read_text(encoding="utf-8")
147
+ except OSError as error:
148
+ raise ConfigurationError(f"cannot read rule file {label}: {error}") from error
149
+ frontmatter, body = _split_frontmatter(content, label)
150
+ text = body.strip()
151
+ if not text:
152
+ raise ConfigurationError(f"rule file {label} has no rule text")
153
+ _only(frontmatter, RULE_FILE_KEYS, f"rule file {label}")
154
+ context = f"rule file {label}"
155
+ return RuleDefinition(
156
+ id=rule_id,
157
+ text=text,
158
+ summary=rule_summary(text),
159
+ text_hash=rule_text_hash(text),
160
+ paths=(
161
+ _paths(frontmatter.get("paths"), context) if "paths" in frontmatter else ()
162
+ ),
163
+ check=(
164
+ parse_check_command(
165
+ _mapping(frontmatter["check"], f"{context}.check"), f"{context}.check"
166
+ )
167
+ if "check" in frontmatter
168
+ else None
169
+ ),
170
+ max_fixes=_max_fixes(frontmatter, context),
171
+ hints=hints.overlay(_hints(frontmatter, context)),
172
+ source=label,
173
+ )
174
+
175
+
176
+ def _split_frontmatter(content: str, label: str) -> tuple[dict[str, Any], str]:
177
+ lines = content.splitlines(keepends=True)
178
+ if not lines or lines[0].strip() != "---":
179
+ return {}, content
180
+ for index, line in enumerate(lines[1:], 1):
181
+ if line.strip() == "---":
182
+ raw = "".join(lines[1:index])
183
+ try:
184
+ data = yaml.safe_load(raw) if raw.strip() else {}
185
+ except yaml.YAMLError as error:
186
+ raise ConfigurationError(
187
+ f"invalid frontmatter in rule file {label}: {error}"
188
+ ) from error
189
+ if not isinstance(data, dict):
190
+ raise ConfigurationError(
191
+ f"frontmatter of rule file {label} must be a mapping"
192
+ )
193
+ return data, "".join(lines[index + 1 :])
194
+ raise ConfigurationError(f"rule file {label} does not close its frontmatter")
195
+
196
+
197
+ def _paths(value: Any, context: str) -> tuple[str, ...]:
198
+ if (
199
+ not isinstance(value, list)
200
+ or not value
201
+ or not all(isinstance(item, str) and item.strip() for item in value)
202
+ ):
203
+ raise ConfigurationError(f"{context}.paths must be a non-empty list of globs")
204
+ return tuple(value)
205
+
206
+
207
+ def _max_fixes(mapping: dict[str, Any], context: str) -> int | None:
208
+ if "max_fixes" not in mapping:
209
+ return None
210
+ value = mapping["max_fixes"]
211
+ if not is_positive_int(value):
212
+ raise ConfigurationError(f"{context}.max_fixes must be a positive integer")
213
+ return int(value)
214
+
215
+
216
+ def _hints(mapping: dict[str, Any], context: str) -> RuleHints:
217
+ return RuleHints(
218
+ _optional_agent(mapping, "agent", context),
219
+ _optional_string(mapping, "model", context),
220
+ _optional_string(mapping, "reasoning", context),
221
+ )
222
+
223
+
224
+ def parse_check_command(mapping: dict[str, Any], context: str) -> Commands:
225
+ """A check: the cli handler's ``argv`` or ``shell`` shape and ``assert``."""
226
+ for key in ("idempotent", "command"):
227
+ if key in mapping:
228
+ raise ConfigurationError(f"{context}.{key} is not allowed on a check")
229
+ _only(mapping, _CHECK_KEYS, context)
230
+ return CommandAction().parse(mapping, "check", "", context)
231
+
232
+
233
+ def parse_rules_root(
234
+ raw: Any,
235
+ base: Path,
236
+ extension_groups: tuple[tuple[str, RuleGroupContribution], ...] = (),
237
+ ) -> tuple[RuleGroup, ...]:
238
+ """Resolve the root ``rules`` mapping and extension groups into groups.
239
+
240
+ ``base`` is the directory relative item paths resolve against.
241
+ Extension groups, each paired with the extension that ships it, come
242
+ first; a name declared twice is an error. Items resolve in order: a group
243
+ name, else a file or a directory of ``*.md`` files. Groups may name each
244
+ other; a rule reached through a named group keeps that group's ID.
245
+ """
246
+ declared: dict[str, _DeclaredGroup] = {}
247
+ for origin, contribution in extension_groups:
248
+ if contribution.name in declared:
249
+ raise ConfigurationError(
250
+ f"rule group {contribution.name!r} is shipped by both "
251
+ f"{declared[contribution.name].origin} and {origin}"
252
+ )
253
+ declared[contribution.name] = _DeclaredGroup(
254
+ contribution.name,
255
+ contribution.items,
256
+ _contributed_filter(contribution.workflows),
257
+ _contributed_filter(contribution.steps),
258
+ contribution.hints,
259
+ f"extension {origin}",
260
+ None,
261
+ )
262
+ if raw is not None:
263
+ if not isinstance(raw, dict):
264
+ raise ConfigurationError("rules must be a mapping of group names")
265
+ for name, value in raw.items():
266
+ if not isinstance(name, str) or not _NAME.fullmatch(name):
267
+ raise ConfigurationError("rules keys must be normalized group names")
268
+ if name in declared:
269
+ raise ConfigurationError(
270
+ f"rule group {name!r} is declared in the configuration and "
271
+ f"shipped by {declared[name].origin}"
272
+ )
273
+ declared[name] = _declared_group(name, value, base)
274
+ return _GroupResolver(declared).resolve_all()
275
+
276
+
277
+ def _declared_group(name: str, value: Any, base: Path) -> _DeclaredGroup:
278
+ path = f"rules.{name}"
279
+ if isinstance(value, list):
280
+ mapping: dict[str, Any] = {"rules": value}
281
+ elif isinstance(value, dict):
282
+ mapping = value
283
+ _only(mapping, _GROUP_KEYS, path)
284
+ else:
285
+ raise ConfigurationError(f"{path} must be a list of items or a mapping")
286
+ items = mapping.get("rules")
287
+ if not isinstance(items, list) or not items:
288
+ raise ConfigurationError(f"{path}.rules must be a non-empty list")
289
+ for index, item in enumerate(items):
290
+ if not isinstance(item, str) or not item.strip():
291
+ raise ConfigurationError(
292
+ f"{path}.rules[{index}] must be a non-empty string"
293
+ )
294
+ return _DeclaredGroup(
295
+ name,
296
+ tuple(items),
297
+ _name_filter(
298
+ mapping.get("workflows", ALL_NAMES), f"{path}.workflows", empty=NO_NAMES
299
+ ),
300
+ _name_filter(mapping.get("steps", ALL_NAMES), f"{path}.steps", empty=NO_NAMES),
301
+ _hints(mapping, path),
302
+ "configuration",
303
+ base,
304
+ )
305
+
306
+
307
+ def _contributed_filter(names: tuple[str, ...] | None) -> NameFilter:
308
+ """An extension group's filter: ``None`` admits all, a tuple its names."""
309
+ return ALL if names is None else NameFilter(names)
310
+
311
+
312
+ class _GroupResolver:
313
+ """Resolve group items once each, following references and detecting cycles."""
314
+
315
+ def __init__(self, declared: dict[str, _DeclaredGroup]) -> None:
316
+ self.declared = declared
317
+ self.resolved: dict[str, RuleGroup] = {}
318
+ # One parse per file and group hints; copies change only the ID.
319
+ self._parsed: dict[tuple[Path, RuleHints], RuleDefinition] = {}
320
+
321
+ def resolve_all(self) -> tuple[RuleGroup, ...]:
322
+ return tuple(self.resolve(name, ()) for name in self.declared)
323
+
324
+ def resolve(self, name: str, chain: tuple[str, ...]) -> RuleGroup:
325
+ if name in self.resolved:
326
+ return self.resolved[name]
327
+ if name in chain:
328
+ raise ConfigurationError("rule group cycle: " + " -> ".join((*chain, name)))
329
+ group = self.declared[name]
330
+ rules: list[RuleDefinition] = []
331
+ stems: dict[str, Path] = {}
332
+ for item in group.items:
333
+ if isinstance(item, str) and item in self.declared:
334
+ rules.extend(self.resolve(item, (*chain, name)).rules)
335
+ continue
336
+ if isinstance(item, Path):
337
+ path: Path | None = item
338
+ else:
339
+ path = group.base / item if group.base is not None else None
340
+ if path is None or not path.exists():
341
+ raise ConfigurationError(
342
+ f"rule group {name!r} item {str(item)!r} names no group or file"
343
+ )
344
+ for file in rule_files(path):
345
+ other = stems.get(file.stem)
346
+ if other is not None and other != file:
347
+ raise ConfigurationError(
348
+ f"rule group {name!r} has two rules named {file.stem!r}: "
349
+ f"{other} and {file}"
350
+ )
351
+ stems[file.stem] = file
352
+ rules.append(self._rule(file, f"{name}/{file.stem}", group.hints))
353
+ self.resolved[name] = RuleGroup(
354
+ name=name,
355
+ rules=_unique_ids(rules),
356
+ workflows=group.workflows,
357
+ steps=group.steps,
358
+ hints=group.hints,
359
+ origin=group.origin,
360
+ )
361
+ return self.resolved[name]
362
+
363
+ def _rule(self, file: Path, rule_id: str, hints: RuleHints) -> RuleDefinition:
364
+ key = (file.resolve(), hints)
365
+ parsed = self._parsed.get(key)
366
+ if parsed is None:
367
+ parsed = parse_rule_file(file, rule_id, hints)
368
+ self._parsed[key] = parsed
369
+ return replace(parsed, id=rule_id)
370
+
371
+
372
+ def rule_files(path: Path) -> tuple[Path, ...]:
373
+ """A rule file itself, or every ``*.md`` directly inside a directory, sorted."""
374
+ if path.is_dir():
375
+ return tuple(sorted(child for child in path.glob("*.md") if child.is_file()))
376
+ return (path,)
377
+
378
+
379
+ def _unique_ids(rules: list[RuleDefinition]) -> tuple[RuleDefinition, ...]:
380
+ """Keep the first rule of each ID; nested groups may reach one twice."""
381
+ first: dict[str, RuleDefinition] = {}
382
+ for rule in rules:
383
+ first.setdefault(rule.id, rule)
384
+ return tuple(first.values())
385
+
386
+
387
+ def parse_step_rules(value: Any, step_name: str, path: str) -> tuple[StepRule, ...]:
388
+ """Parse a step's ``rules`` list; bare strings are resolved later.
389
+
390
+ A mapping is a rule of the step's own: ``text``, a command, or both. A
391
+ bare string waits for :func:`resolve_step_rules`, which knows the groups
392
+ and the configuration's directory.
393
+ """
394
+ if not isinstance(value, list):
395
+ raise ConfigurationError(f"{path}.rules must be a list")
396
+ result: list[StepRule] = []
397
+ for index, entry in enumerate(value, 1):
398
+ entry_path = f"{path}.rules[{index - 1}]"
399
+ if isinstance(entry, str):
400
+ if not entry.strip():
401
+ raise ConfigurationError(f"{entry_path} must be a non-empty string")
402
+ result.append(UnresolvedStepRule(entry.strip(), step_name, index))
403
+ elif isinstance(entry, dict):
404
+ result.append(_step_rule_mapping(entry, f"{step_name}/{index}", entry_path))
405
+ else:
406
+ raise ConfigurationError(f"{entry_path} must be a string or a mapping")
407
+ return tuple(result)
408
+
409
+
410
+ def _step_rule_mapping(
411
+ mapping: dict[str, Any], rule_id: str, path: str
412
+ ) -> RuleDefinition:
413
+ _only(mapping, STEP_RULE_KEYS, path)
414
+ text = mapping.get("text")
415
+ if text is not None and (not isinstance(text, str) or not text.strip()):
416
+ raise ConfigurationError(f"{path}.text must be a non-empty string")
417
+ command_keys = {key: mapping[key] for key in _CHECK_KEYS if key in mapping}
418
+ check = parse_check_command(command_keys, path) if command_keys else None
419
+ if text is None and check is None:
420
+ raise ConfigurationError(f"{path} requires text or a command")
421
+ wording = text.strip() if text is not None else _command_text(check)
422
+ return RuleDefinition(
423
+ id=rule_id,
424
+ text=wording,
425
+ summary=rule_summary(wording),
426
+ text_hash=rule_text_hash(wording),
427
+ check=check,
428
+ max_fixes=_max_fixes(mapping, path),
429
+ hints=_hints(mapping, path),
430
+ )
431
+
432
+
433
+ def _command_text(check: Commands | None) -> str:
434
+ """A pure check's text: the command it runs, as the rule's summary."""
435
+ assert check is not None
436
+ command = check.commands[0]
437
+ return command.shell if command.shell is not None else " ".join(command.argv)
438
+
439
+
440
+ def resolve_step_rules(
441
+ workflows: tuple[WorkflowDefinition, ...],
442
+ handlers: tuple[HandlerDefinition, ...],
443
+ groups: tuple[RuleGroup, ...],
444
+ base: Path,
445
+ ) -> tuple[tuple[WorkflowDefinition, ...], tuple[HandlerDefinition, ...]]:
446
+ """Resolve every bare string in every step's ``rules`` list.
447
+
448
+ A string names a group, else a rule file or directory relative to
449
+ ``base``, else it is the rule's text. A string shaped like a reference
450
+ that names neither a group nor a file is an error, so a mistyped group
451
+ name is not silently delivered as a rule.
452
+ """
453
+ names = {group.name for group in groups}
454
+
455
+ def resolve(step: StepDefinition) -> StepDefinition:
456
+ rules = tuple(
457
+ resolved
458
+ for entry in step.rules
459
+ for resolved in _resolve_entry(entry, names, base)
460
+ )
461
+ return replace(
462
+ step,
463
+ rules=rules,
464
+ child_steps=tuple(map(resolve, step.child_steps)),
465
+ loop_steps=tuple(map(resolve, step.loop_steps)),
466
+ assessment_outcomes=tuple(map(resolve, step.assessment_outcomes)),
467
+ items=(
468
+ replace(step.items, steps=tuple(map(resolve, step.items.steps)))
469
+ if isinstance(step.items, ItemFlow)
470
+ else step.items
471
+ ),
472
+ )
473
+
474
+ return (
475
+ tuple(
476
+ replace(workflow, steps=tuple(map(resolve, workflow.steps)))
477
+ for workflow in workflows
478
+ ),
479
+ tuple(
480
+ resolve(handler) if isinstance(handler, StepDefinition) else handler
481
+ for handler in handlers
482
+ ),
483
+ )
484
+
485
+
486
+ def _resolve_entry(
487
+ entry: StepRule, group_names: set[str], base: Path
488
+ ) -> tuple[RuleDefinition | RuleGroupRef, ...]:
489
+ if not isinstance(entry, UnresolvedStepRule):
490
+ return (entry,)
491
+ value = entry.value
492
+ if value in group_names:
493
+ return (RuleGroupRef(value),)
494
+ candidate = base / value
495
+ if candidate.exists():
496
+ return tuple(
497
+ parse_rule_file(file, f"{entry.step}/{file.stem}")
498
+ for file in rule_files(candidate)
499
+ )
500
+ if looks_like_reference(value):
501
+ raise ConfigurationError(
502
+ f"step {entry.step!r} rule {value!r} names no group or file"
503
+ )
504
+ return (
505
+ RuleDefinition(
506
+ id=f"{entry.step}/{entry.ordinal}",
507
+ text=value,
508
+ summary=rule_summary(value),
509
+ text_hash=rule_text_hash(value),
510
+ ),
511
+ )