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/rule_writes.py ADDED
@@ -0,0 +1,920 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Validated writes of rule files and rule groups for ``ww rules add`` and kin.
3
+
4
+ The ``ww-rule`` skill carries the judgment of turning an operator's words into
5
+ rules; these commands carry out its decisions on disk, and nothing else writes
6
+ rules for it. Each command is planned first (:class:`RuleWrite`: the files to
7
+ write or delete and the directories to create), then applied together and
8
+ validated by loading the configuration as ww would. When the configuration
9
+ would not load, or the write would not have the effect planned, every file is
10
+ put back as it was and the command fails: a write never leaves the project in
11
+ a state ``ww lint`` rejects. Nothing is committed; rule files are
12
+ configuration, committed with the change that needs them.
13
+
14
+ Rule files are edited in place. The repo's ``ww.yaml`` is
15
+ never rewritten: a new root group goes into ``ww-rules.yaml``, a file ww owns
16
+ and rewrites whole, which the repo file lists under ``imports``; adding that
17
+ one list entry is the only change ww makes to the repo file, and it is checked
18
+ to leave every other value as it was. A group declared anywhere else is the
19
+ operator's own, so ``rules filter`` refuses it and says what to write.
20
+
21
+ ``rules promote`` is the one write that also changes the rule-automation
22
+ store: it copies an approved check's command into the ``check`` of every rule
23
+ file the check covers, then deletes the check and those rules' entries, since
24
+ a rule with its own command is never looked up in the store again.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ from collections.abc import Iterator
32
+ from dataclasses import dataclass, replace
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import yaml
37
+
38
+ from ww.changes import project_files, select_files
39
+ from ww.config import load_configuration
40
+ from ww.config.composition import ComposedConfiguration
41
+ from ww.config.rules import (
42
+ parse_check_command,
43
+ rule_summary,
44
+ rule_text_hash,
45
+ )
46
+ from ww.config_files import RULES_IMPORT_FILE, display_path
47
+ from ww.config_writes import FileWrite, Transaction, dump_yaml, import_write
48
+ from ww.errors import ConfigurationError, StateError
49
+ from ww.extensions import ExtensionRegistry
50
+ from ww.rule_store import RuleAutomation, RuleStore, describe_command
51
+ from ww.workflow_config import (
52
+ INIT_STEP_NAME,
53
+ ItemFlow,
54
+ NameFilter,
55
+ RuleDefinition,
56
+ RuleGroup,
57
+ RuleGroupRef,
58
+ StepDefinition,
59
+ WorkflowConfiguration,
60
+ every_step,
61
+ )
62
+
63
+ # A rule file's stem: lower-case kebab-case, as the derived ones are, e.g.
64
+ # "write-tests-first"; "write--tests" does not match.
65
+ STEM = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
66
+ STEM_WORDS = 5
67
+ # One lower-case word or number, e.g. "tests" in "write tests first".
68
+ _WORD = re.compile(r"[a-z0-9]+")
69
+ _DELIMITER = "---\n"
70
+ _IMPORT_HEADER = (
71
+ "# Rule groups written by `ww rules add --group` and `ww rules filter`.\n"
72
+ "# ww rewrites this file whole; the repo file imports it.\n"
73
+ )
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class RuleWrite:
78
+ """What one command changes on disk, and what it tells the operator.
79
+
80
+ ``rule`` or ``group`` names what must exist in the configuration once
81
+ the write is applied, and whose placement the report shows. ``promoted``
82
+ is the store check ``rules promote`` deletes, with the rule hashes whose
83
+ entries go with it.
84
+ """
85
+
86
+ writes: tuple[FileWrite, ...]
87
+ report: tuple[str, ...]
88
+ directories: tuple[Path, ...] = ()
89
+ warnings: tuple[str, ...] = ()
90
+ rule: str | None = None
91
+ group: str | None = None
92
+ promoted: tuple[str, tuple[str, ...]] | None = None
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class RuleProject:
97
+ """The project a write applies to, with its configuration as it is now.
98
+
99
+ ``configuration`` is the validated configuration before the write, and
100
+ ``composed`` the composed YAML it was parsed from.
101
+ """
102
+
103
+ root: Path
104
+ config_path: Path
105
+ configuration: WorkflowConfiguration
106
+ composed: ComposedConfiguration
107
+ extensions: ExtensionRegistry | None = None
108
+
109
+ @property
110
+ def base(self) -> Path:
111
+ """The directory rule paths in the composed configuration resolve against."""
112
+ return self.config_path.parent
113
+
114
+ def label(self, path: Path) -> str:
115
+ return display_path(path, self.root)
116
+
117
+ def group(self, name: str) -> RuleGroup:
118
+ group = self.configuration.rule_groups_by_name.get(name)
119
+ if group is None:
120
+ known = ", ".join(sorted(self.configuration.rule_groups_by_name))
121
+ raise StateError(
122
+ f"no rule group {name!r}"
123
+ + (f"; the groups are {known}" if known else "; there are no groups")
124
+ )
125
+ return group
126
+
127
+
128
+ # Planning ---------------------------------------------------------------------
129
+
130
+
131
+ def rule_stem(text: str) -> str:
132
+ """A file stem from a rule's first sentence: at most five kebab-case words."""
133
+ words = _WORD.findall(rule_summary(text).lower())[:STEM_WORDS]
134
+ if not words:
135
+ raise StateError("the rule's first sentence has no words to name it; pass --id")
136
+ return "-".join(words)
137
+
138
+
139
+ def check_mapping(
140
+ shell: str | None,
141
+ argv: tuple[str, ...] | None,
142
+ assertion: tuple[str, ...] | None,
143
+ ) -> dict[str, Any] | None:
144
+ """A rule file's ``check`` from the command-line options, validated."""
145
+ if shell is None and not argv:
146
+ if assertion:
147
+ raise StateError("--assert needs --check-shell or --check-argv")
148
+ return None
149
+ mapping: dict[str, Any] = (
150
+ {"shell": shell} if shell is not None else {"argv": list(argv or ())}
151
+ )
152
+ if assertion:
153
+ mapping["assert"] = [_assertion(value) for value in assertion]
154
+ try:
155
+ parse_check_command(dict(mapping), "--check")
156
+ except ConfigurationError as error:
157
+ raise StateError(str(error)) from error
158
+ return mapping
159
+
160
+
161
+ def _assertion(value: str) -> str | dict[str, str]:
162
+ """One ``--assert`` condition: ``empty``, or ``equals:<value>``."""
163
+ if value == "empty":
164
+ return "empty"
165
+ kind, separator, expected = value.partition(":")
166
+ if kind == "equals" and separator:
167
+ return {"equals": expected}
168
+ raise StateError(f"--assert takes empty or equals:<value>, not {value!r}")
169
+
170
+
171
+ def plan_add_rule(
172
+ project: RuleProject,
173
+ group_name: str,
174
+ text: str,
175
+ *,
176
+ paths: tuple[str, ...] = (),
177
+ check: dict[str, Any] | None = None,
178
+ stem: str | None = None,
179
+ ) -> RuleWrite:
180
+ """A new rule file in the group's first directory, never over another."""
181
+ body = _body(text)
182
+ group = project.group(group_name)
183
+ directory = _group_directory(project, group)
184
+ if stem is not None and not STEM.fullmatch(stem):
185
+ raise StateError(f"--id {stem!r} must be lower-case kebab-case")
186
+ stem = stem or rule_stem(body)
187
+ file = directory / f"{stem}.md"
188
+ if file.exists():
189
+ raise StateError(
190
+ f"{project.label(file)} already exists; choose another --id, or "
191
+ f"amend that rule with `rules edit {group.name}/{stem}`"
192
+ )
193
+ frontmatter: dict[str, Any] = {}
194
+ if paths:
195
+ frontmatter["paths"] = list(paths)
196
+ if check is not None:
197
+ frontmatter["check"] = check
198
+ content = f"---\n{dump_yaml(frontmatter)}---\n{body}" if frontmatter else body
199
+ rule_id = f"{group.name}/{stem}"
200
+ return RuleWrite(
201
+ writes=(FileWrite(file, content),),
202
+ report=(
203
+ f"Created {project.label(file)}: rule `{rule_id}`.",
204
+ *_glob_report(project, paths),
205
+ ),
206
+ warnings=_glob_warnings(project, paths),
207
+ rule=rule_id,
208
+ )
209
+
210
+
211
+ def plan_add_group(
212
+ project: RuleProject,
213
+ name: str,
214
+ directory: Path,
215
+ *,
216
+ workflows: NameFilter | None = None,
217
+ steps: NameFilter | None = None,
218
+ ) -> RuleWrite:
219
+ """A new root group in ``ww-rules.yaml``, imported by the repo file.
220
+
221
+ A filter left ``None`` is not written, so the group applies everywhere.
222
+ """
223
+ if name in project.configuration.rule_groups_by_name:
224
+ raise StateError(f"rule group {name!r} already exists")
225
+ folder = _inside(project, directory)
226
+ if folder.exists() and not folder.is_dir():
227
+ raise StateError(f"{project.label(folder)} is a file, not a directory")
228
+ imports = _RulesImport.read(project)
229
+ groups = dict(imports.groups)
230
+ if name in groups: # pragma: no cover - the configuration would have it
231
+ raise StateError(f"rule group {name!r} already exists in {imports.label}")
232
+ item = Path(os.path.relpath(folder, imports.path.parent)).as_posix() + "/"
233
+ groups[name] = _group_entry([item], workflows, steps)
234
+ report = [f"Added rule group `{name}` ({item}) to {imports.label}."]
235
+ writes = [imports.write(groups)]
236
+ if not imports.imported:
237
+ writes.append(imports.import_it(project))
238
+ report.append(
239
+ f"Added {imports.label} to imports in {project.label(project.config_path)}."
240
+ )
241
+ warnings: tuple[str, ...] = ()
242
+ if not folder.is_dir() or not any(folder.glob("*.md")):
243
+ warnings = (
244
+ f"{project.label(folder)} holds no rule yet, and git does not keep an "
245
+ f"empty directory: add one with `rules add {name} --text ...` before "
246
+ "committing.",
247
+ )
248
+ return RuleWrite(
249
+ writes=tuple(writes),
250
+ report=tuple(report),
251
+ directories=(folder,) if not folder.is_dir() else (),
252
+ warnings=warnings,
253
+ group=name,
254
+ )
255
+
256
+
257
+ def plan_edit(
258
+ project: RuleProject,
259
+ automation: RuleAutomation,
260
+ rule_id: str,
261
+ *,
262
+ text: str | None = None,
263
+ paths: tuple[str, ...] | None = None,
264
+ ) -> RuleWrite:
265
+ """A rule file with a new body, new globs, or both; the rest kept as it is."""
266
+ if text is None and paths is None:
267
+ raise StateError("rules edit needs --text, --paths, or both")
268
+ rule, file = _rule_file(project, rule_id)
269
+ opening, frontmatter, closing, body = _split(file.read_text(encoding="utf-8"))
270
+ report = [f"Edited {project.label(file)}: rule `{rule_id}`."]
271
+ warnings: list[str] = []
272
+ if paths is not None:
273
+ frontmatter = _set_key(frontmatter or "", "paths", list(paths), file)
274
+ opening, closing = opening or _DELIMITER, closing or _DELIMITER
275
+ report.extend(_glob_report(project, paths))
276
+ warnings.extend(_glob_warnings(project, paths))
277
+ if text is not None:
278
+ body = _body(text)
279
+ if rule_text_hash(body) == rule.text_hash:
280
+ report.append("The wording is unchanged apart from whitespace.")
281
+ warnings.extend(_wording_warnings(automation, rule, body))
282
+ content = f"{opening}{frontmatter}{closing}{body}" if opening is not None else body
283
+ return RuleWrite(
284
+ writes=(FileWrite(file, content),),
285
+ report=tuple(report),
286
+ warnings=tuple(warnings),
287
+ rule=rule_id,
288
+ )
289
+
290
+
291
+ def plan_move(project: RuleProject, rule_id: str, group_name: str) -> RuleWrite:
292
+ """The rule's file moved, unchanged, into another group's first directory."""
293
+ _, file = _rule_file(project, rule_id)
294
+ group = project.group(group_name)
295
+ target = _group_directory(project, group) / file.name
296
+ if target.resolve() == file.resolve():
297
+ raise StateError(f"{project.label(file)} is already in group {group.name!r}")
298
+ if target.exists():
299
+ raise StateError(
300
+ f"{project.label(target)} already exists; the group has a rule named "
301
+ f"{file.stem!r}"
302
+ )
303
+ new_id = f"{group.name}/{file.stem}"
304
+ return RuleWrite(
305
+ writes=(
306
+ FileWrite(target, file.read_text(encoding="utf-8")),
307
+ FileWrite(file, None),
308
+ ),
309
+ report=(
310
+ f"Moved {project.label(file)} to {project.label(target)}: rule "
311
+ f"`{rule_id}` is now `{new_id}`. Its wording is unchanged, so what the "
312
+ "rule-automation store knows about it still applies.",
313
+ ),
314
+ rule=new_id,
315
+ )
316
+
317
+
318
+ def plan_filter(
319
+ project: RuleProject,
320
+ group_name: str,
321
+ *,
322
+ workflows: NameFilter | None,
323
+ steps: NameFilter | None,
324
+ all_workflows: bool = False,
325
+ all_steps: bool = False,
326
+ ) -> RuleWrite:
327
+ """New ``workflows``/``steps`` filters for a group in ``ww-rules.yaml``.
328
+
329
+ ``None`` leaves a filter as it is; a filter admitting every name is
330
+ written as ``"*"``; an empty one admits nothing, so the group applies only
331
+ where a step names it; ``all_*`` removes the filter, which also admits
332
+ every name.
333
+ """
334
+ if workflows is None and steps is None and not all_workflows and not all_steps:
335
+ raise StateError(
336
+ "rules filter needs --workflows, --steps, --all-workflows, or --all-steps"
337
+ )
338
+ if (workflows is not None and all_workflows) or (steps is not None and all_steps):
339
+ raise StateError("--all-workflows and --all-steps replace a filter; pass one")
340
+ group = project.group(group_name)
341
+ imports = _RulesImport.read(project)
342
+ if group_name not in imports.groups or not imports.imported:
343
+ raise StateError(
344
+ f"rule group {group_name!r} is declared in "
345
+ f"{_declared_in(project, group)}, which ww does not rewrite; set its "
346
+ f"filters there by hand ({_filter_yaml(group_name, workflows, steps)})"
347
+ )
348
+ winner = next(
349
+ (
350
+ override.overridden_by
351
+ for override in project.composed.overrides
352
+ if override.kind == "rule group"
353
+ and override.name == group_name
354
+ and override.overridden_in == imports.label
355
+ ),
356
+ None,
357
+ )
358
+ if winner is not None:
359
+ raise StateError(
360
+ f"rule group {group_name!r} is declared again in {winner}, which "
361
+ f"replaces the one in {imports.label}; change it there"
362
+ )
363
+ entry = dict(_group_mapping(imports.groups[group_name]))
364
+ for key, value, clear in (
365
+ ("workflows", workflows, all_workflows),
366
+ ("steps", steps, all_steps),
367
+ ):
368
+ if clear:
369
+ entry.pop(key, None)
370
+ elif value is not None:
371
+ entry[key] = value.to_data()
372
+ groups = {**imports.groups, group_name: entry}
373
+ return RuleWrite(
374
+ writes=(imports.write(groups),),
375
+ report=(
376
+ f"Changed the filters of rule group `{group_name}` in {imports.label}.",
377
+ ),
378
+ group=group_name,
379
+ )
380
+
381
+
382
+ def plan_promote(
383
+ project: RuleProject, automation: RuleAutomation, name: str
384
+ ) -> RuleWrite:
385
+ """A converted store check copied into the rule files it covers."""
386
+ check = automation.checks.get(name)
387
+ if check is None:
388
+ known = ", ".join(sorted(automation.checks))
389
+ raise StateError(
390
+ f"the rule-automation store has no check {name!r}"
391
+ + (f"; its checks are {known}" if known else "")
392
+ )
393
+ if check.status != "converted" or check.pending is not None:
394
+ raise StateError(
395
+ f"check {name!r} is {check.status}"
396
+ + (" with a pending revision an earlier ww left" if check.pending else "")
397
+ + "; only a converted check without one is promoted, so record it "
398
+ "again with `rules convert` first"
399
+ )
400
+ waiting = sorted(
401
+ key[:12]
402
+ for key, entry in automation.rules.items()
403
+ if entry.check == name and entry.status != "converted"
404
+ )
405
+ if waiting:
406
+ raise StateError(
407
+ f"rules {', '.join(waiting)} name check {name!r} without being "
408
+ "converted by it; record it again with `rules convert` first"
409
+ )
410
+ covers = check.spec.covers
411
+ declared = _declared_rules(project.configuration)
412
+ rules = [rule for key in covers for rule in declared.get(key, ())]
413
+ if not rules:
414
+ raise StateError(
415
+ f"no declared rule has the wording check {name!r} covers; "
416
+ "`rules prune` removes it"
417
+ )
418
+ inline = sorted({rule.id for rule in rules if rule.source is None})
419
+ if inline:
420
+ raise StateError(
421
+ f"check {name!r} covers {', '.join(inline)}, written in a step's own "
422
+ "`rules` list rather than a rule file; give that rule its command "
423
+ "there by hand"
424
+ )
425
+ command = dict(check.spec.command.commands[0].to_dict())
426
+ if check.spec.command.assertion is not None:
427
+ command["assert"] = check.spec.command.assertion.to_data()
428
+ writes: dict[Path, FileWrite] = {}
429
+ for rule in rules:
430
+ if rule.check is not None:
431
+ raise StateError(f"rule `{rule.id}` already has a check of its own")
432
+ file = _inside(project, Path(str(rule.source)))
433
+ if file in writes:
434
+ continue
435
+ opening, frontmatter, closing, body = _split(file.read_text(encoding="utf-8"))
436
+ frontmatter = _set_key(frontmatter or "", "check", command, file)
437
+ writes[file] = FileWrite(
438
+ file, f"{opening or _DELIMITER}{frontmatter}{closing or _DELIMITER}{body}"
439
+ )
440
+ report = [
441
+ f"Promoted check `{name}` ({describe_command(check.spec.command)}) into "
442
+ + ", ".join(project.label(file) for file in writes)
443
+ + "; removed it and its rules' entries from the rule-automation store.",
444
+ ]
445
+ warnings = []
446
+ if len(writes) > 1:
447
+ warnings.append(
448
+ f"The command now runs once for each of these {len(writes)} rules, "
449
+ "on the changed files each rule's globs select, instead of once."
450
+ )
451
+ if check.spec.config:
452
+ report.append(
453
+ "Its configuration stays where it is: " + ", ".join(check.spec.config)
454
+ )
455
+ return RuleWrite(
456
+ writes=tuple(writes.values()),
457
+ report=tuple(report),
458
+ warnings=tuple(warnings),
459
+ rule=rules[0].id,
460
+ promoted=(name, covers),
461
+ )
462
+
463
+
464
+ # Applying ---------------------------------------------------------------------
465
+
466
+
467
+ @dataclass(frozen=True)
468
+ class WriteOutcome:
469
+ """What a write did, or would do under ``--dry-run``."""
470
+
471
+ report: tuple[str, ...]
472
+ warnings: tuple[str, ...]
473
+ placement: tuple[str, ...]
474
+ applied: bool
475
+
476
+ def render(self) -> str:
477
+ lines = list(self.report)
478
+ lines.extend(f"Warning: {warning}" for warning in self.warnings)
479
+ if self.placement:
480
+ lines.append("Reaches these steps (an agent step's page shows it):")
481
+ lines.extend(f"- {line}" for line in self.placement)
482
+ else:
483
+ lines.append(
484
+ "Reaches no step yet: no filter admits one and no step names it."
485
+ )
486
+ if not self.applied:
487
+ lines.append(
488
+ "Dry run: the configuration would be valid; nothing was written."
489
+ )
490
+ return "\n".join(lines) + "\n"
491
+
492
+
493
+ def apply_write(
494
+ project: RuleProject, write: RuleWrite, *, dry_run: bool = False
495
+ ) -> WriteOutcome:
496
+ """Write, validate by loading the configuration, and keep it or put it back.
497
+
498
+ A dry run always puts the files back, after the same validation.
499
+ """
500
+ with Transaction() as transaction:
501
+ transaction.apply(write.writes, write.directories)
502
+ try:
503
+ configuration = load_configuration(project.config_path, project.extensions)
504
+ _expect(configuration, write)
505
+ except ConfigurationError as error:
506
+ transaction.roll_back()
507
+ raise StateError(
508
+ f"refused: the configuration would not be valid ({error}); "
509
+ "nothing was written"
510
+ ) from error
511
+ placement = _placement(configuration, write)
512
+ if dry_run:
513
+ transaction.roll_back()
514
+ if write.promoted is not None and not dry_run:
515
+ name, covers = write.promoted
516
+ RuleStore(project.root).modify(
517
+ lambda current: _without_check(current, name, covers)
518
+ )
519
+ return WriteOutcome(write.report, write.warnings, placement, not dry_run)
520
+
521
+
522
+ def _expect(configuration: WorkflowConfiguration, write: RuleWrite) -> None:
523
+ if write.group is not None and write.group not in configuration.rule_groups_by_name:
524
+ raise ConfigurationError(f"rule group {write.group!r} is not declared")
525
+ if write.rule is not None and write.rule not in _declared_rule_ids(configuration):
526
+ raise ConfigurationError(f"no rule {write.rule!r} is declared")
527
+
528
+
529
+ def _without_check(
530
+ automation: RuleAutomation, name: str, covers: tuple[str, ...]
531
+ ) -> RuleAutomation:
532
+ return replace(
533
+ automation,
534
+ checks={key: entry for key, entry in automation.checks.items() if key != name},
535
+ rules={
536
+ key: entry
537
+ for key, entry in automation.rules.items()
538
+ if not (key in covers and entry.check == name)
539
+ },
540
+ )
541
+
542
+
543
+ # Where a rule applies ---------------------------------------------------------
544
+
545
+
546
+ def _walk(
547
+ steps: tuple[StepDefinition, ...], parent: str | None = None
548
+ ) -> Iterator[tuple[StepDefinition, str]]:
549
+ """Every step with its logical path, as the plan compiler names them."""
550
+ for step in steps:
551
+ path = f"{parent}/{step.name}" if parent else step.name
552
+ yield step, path
553
+ nested = (
554
+ *step.child_steps,
555
+ *step.loop_steps,
556
+ *(step.items.steps if isinstance(step.items, ItemFlow) else ()),
557
+ )
558
+ yield from _walk(nested, path)
559
+
560
+
561
+ def _placement(
562
+ configuration: WorkflowConfiguration, write: RuleWrite
563
+ ) -> tuple[str, ...]:
564
+ """``workflow: step, step`` for each workflow the rule or group reaches."""
565
+ groups = configuration.rule_groups_by_name
566
+
567
+ def reaches(group: RuleGroup) -> bool:
568
+ return write.group == group.name or any(
569
+ rule.id == write.rule for rule in group.rules
570
+ )
571
+
572
+ lines = []
573
+ for workflow in configuration.workflows:
574
+ walked = tuple(_walk(workflow.steps))
575
+ precise = frozenset(path for _, path in walked)
576
+ names = []
577
+ for step, path in walked:
578
+ if step.name == INIT_STEP_NAME:
579
+ continue
580
+ admitted = any(
581
+ reaches(group)
582
+ and group.applies_to(workflow.name, step.name, path, precise)
583
+ for group in configuration.rule_groups
584
+ )
585
+ named = any(
586
+ (isinstance(entry, RuleGroupRef) and reaches(groups[entry.name]))
587
+ or (isinstance(entry, RuleDefinition) and entry.id == write.rule)
588
+ for entry in step.rules
589
+ )
590
+ if admitted or named:
591
+ names.append(path)
592
+ if names:
593
+ lines.append(f"{workflow.name}: " + ", ".join(dict.fromkeys(names)))
594
+ return tuple(lines)
595
+
596
+
597
+ # Helpers ----------------------------------------------------------------------
598
+
599
+
600
+ def _body(text: str) -> str:
601
+ body = text.strip()
602
+ if not body:
603
+ raise StateError("--text must hold the rule's sentence")
604
+ if body.splitlines()[0].strip() == "---":
605
+ raise StateError(
606
+ "--text must not start with a --- line, which opens frontmatter"
607
+ )
608
+ return body + "\n"
609
+
610
+
611
+ def _inside(project: RuleProject, path: Path) -> Path:
612
+ """``path`` resolved, relative to the root, refused outside the project."""
613
+ resolved = (path if path.is_absolute() else project.root / path).resolve()
614
+ try:
615
+ resolved.relative_to(project.root.resolve())
616
+ except ValueError:
617
+ raise StateError(
618
+ f"{path} is outside the project; ww writes rules only inside it"
619
+ ) from None
620
+ return resolved
621
+
622
+
623
+ def _group_directory(project: RuleProject, group: RuleGroup) -> Path:
624
+ """The first directory a group lists, where its new rules go."""
625
+ if group.origin != "configuration":
626
+ origin = group.origin.removeprefix("extension ")
627
+ raise StateError(
628
+ f"rule group {group.name!r} is shipped by {origin}; "
629
+ "add the rule to a group of the project's configuration"
630
+ )
631
+ declared = project.composed.raw.get("rules")
632
+ raw = declared.get(group.name) if isinstance(declared, dict) else None
633
+ items = _group_mapping(raw).get("rules", []) if raw is not None else []
634
+ names = project.configuration.rule_groups_by_name
635
+ for item in items:
636
+ if isinstance(item, str) and item not in names:
637
+ candidate = Path(item) if Path(item).is_absolute() else project.base / item
638
+ if candidate.is_dir():
639
+ return _inside(project, candidate)
640
+ raise StateError(
641
+ f"rule group {group.name!r} lists no directory to add a rule file to; "
642
+ f"add one to it, or use a group that has one"
643
+ )
644
+
645
+
646
+ def _group_mapping(raw: Any) -> dict[str, Any]:
647
+ if isinstance(raw, list):
648
+ return {"rules": list(raw)}
649
+ if isinstance(raw, dict):
650
+ return dict(raw)
651
+ raise StateError(f"rule group entry {raw!r} is neither a list nor a mapping")
652
+
653
+
654
+ def _group_entry(
655
+ items: list[str],
656
+ workflows: NameFilter | None,
657
+ steps: NameFilter | None,
658
+ ) -> dict[str, Any]:
659
+ entry: dict[str, Any] = {"rules": items}
660
+ if workflows is not None:
661
+ entry["workflows"] = workflows.to_data()
662
+ if steps is not None:
663
+ entry["steps"] = steps.to_data()
664
+ return entry
665
+
666
+
667
+ def _filter_yaml(
668
+ name: str, workflows: NameFilter | None, steps: NameFilter | None
669
+ ) -> str:
670
+ parts = [
671
+ f"{key}: " + ('"*"' if value.admits_all else f"[{', '.join(value.listed)}]")
672
+ for key, value in (("workflows", workflows), ("steps", steps))
673
+ if value is not None
674
+ ]
675
+ return f"rules.{name}: " + ("; ".join(parts) if parts else "remove the filter keys")
676
+
677
+
678
+ def _declared_in(project: RuleProject, group: RuleGroup) -> str:
679
+ if group.origin != "configuration":
680
+ return group.origin
681
+ origin = next(
682
+ (
683
+ override.overridden_by
684
+ for override in reversed(project.composed.overrides)
685
+ if override.kind == "rule group" and override.name == group.name
686
+ ),
687
+ None,
688
+ )
689
+ if origin is not None:
690
+ return origin
691
+ for label in reversed(project.composed.sources):
692
+ path = project.base / label
693
+ try:
694
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
695
+ except (OSError, yaml.YAMLError):
696
+ continue
697
+ if (
698
+ isinstance(raw, dict)
699
+ and isinstance(raw.get("rules"), dict)
700
+ and (group.name in raw["rules"])
701
+ ):
702
+ return label
703
+ return project.label(project.config_path)
704
+
705
+
706
+ def _declared_rules(
707
+ configuration: WorkflowConfiguration,
708
+ ) -> dict[str, tuple[RuleDefinition, ...]]:
709
+ """Every declared rule by wording hash, each rule ID once."""
710
+ found: dict[str, dict[str, RuleDefinition]] = {}
711
+ for rule in _every_rule(configuration):
712
+ found.setdefault(rule.text_hash, {}).setdefault(rule.id, rule)
713
+ return {key: tuple(rules.values()) for key, rules in found.items()}
714
+
715
+
716
+ def _declared_rule_ids(configuration: WorkflowConfiguration) -> set[str]:
717
+ return {rule.id for rule in _every_rule(configuration)}
718
+
719
+
720
+ def _every_rule(configuration: WorkflowConfiguration) -> Iterator[RuleDefinition]:
721
+ for group in configuration.rule_groups:
722
+ yield from group.rules
723
+ for step in every_step(configuration):
724
+ for entry in step.rules:
725
+ if isinstance(entry, RuleDefinition):
726
+ yield entry
727
+
728
+
729
+ def _rule_file(project: RuleProject, rule_id: str) -> tuple[RuleDefinition, Path]:
730
+ """A declared rule and the file it lives in, which ww may change."""
731
+ rule = next(
732
+ (rule for rule in _every_rule(project.configuration) if rule.id == rule_id),
733
+ None,
734
+ )
735
+ if rule is None:
736
+ raise StateError(
737
+ f"no rule {rule_id!r} is declared; `rules` lists every rule ID"
738
+ )
739
+ group = project.configuration.rule_groups_by_name.get(rule_id.split("/", 1)[0])
740
+ if (
741
+ group is not None
742
+ and group.origin != "configuration"
743
+ and any(candidate.id == rule_id for candidate in group.rules)
744
+ ):
745
+ raise StateError(
746
+ f"rule `{rule_id}` is shipped by {group.origin}; ww does not change it"
747
+ )
748
+ if rule.source is None:
749
+ raise StateError(
750
+ f"rule `{rule_id}` is written in a step's own `rules` list in the "
751
+ "YAML, which ww does not rewrite; change it there"
752
+ )
753
+ return rule, _inside(project, Path(rule.source))
754
+
755
+
756
+ def _split(content: str) -> tuple[str | None, str | None, str | None, str]:
757
+ """A rule file's opening ``---``, frontmatter, closing ``---``, and body.
758
+
759
+ Without frontmatter the first three are ``None``. The parts join back to
760
+ the file byte for byte.
761
+ """
762
+ lines = content.splitlines(keepends=True)
763
+ if not lines or lines[0].strip() != "---":
764
+ return None, None, None, content
765
+ for index, line in enumerate(lines[1:], 1):
766
+ if line.strip() == "---":
767
+ return (
768
+ lines[0],
769
+ "".join(lines[1:index]),
770
+ line if line.endswith("\n") else line + "\n",
771
+ "".join(lines[index + 1 :]),
772
+ )
773
+ raise StateError("the rule file does not close its frontmatter")
774
+
775
+
776
+ def _set_key(frontmatter: str, key: str, value: Any, file: Path) -> str:
777
+ """``frontmatter`` with one top-level key replaced or added, the rest kept.
778
+
779
+ The key's own lines are removed and the new value appended, so every
780
+ other line keeps its bytes. The result is read back: if it does not hold
781
+ exactly the old values with the new one, the write is refused.
782
+ """
783
+ try:
784
+ before = yaml.safe_load(frontmatter) if frontmatter.strip() else {}
785
+ except yaml.YAMLError as error:
786
+ raise StateError(f"invalid frontmatter in {file}: {error}") from error
787
+ if not isinstance(before, dict):
788
+ raise StateError(f"the frontmatter of {file} is not a mapping")
789
+ kept: list[str] = []
790
+ skipping = False
791
+ # The key's own top-level line, e.g. "checks:" or "checks :" for "checks".
792
+ start = re.compile(rf"{re.escape(key)}\s*:")
793
+ for line in frontmatter.splitlines(keepends=True):
794
+ if start.match(line):
795
+ skipping = True
796
+ continue
797
+ if skipping and line.strip() and (line[0] in " \t" or line.startswith("- ")):
798
+ continue
799
+ skipping = False
800
+ kept.append(line)
801
+ if kept and not kept[-1].endswith("\n"):
802
+ kept[-1] += "\n"
803
+ result = "".join(kept) + dump_yaml({key: value})
804
+ expected = {**{k: v for k, v in before.items() if k != key}, key: value}
805
+ try:
806
+ after = yaml.safe_load(result)
807
+ except yaml.YAMLError:
808
+ after = None
809
+ if after != expected:
810
+ raise StateError(
811
+ f"ww cannot change {key} in the frontmatter of {file} without "
812
+ "disturbing the rest; change it by hand"
813
+ )
814
+ return result
815
+
816
+
817
+ def _glob_report(project: RuleProject, paths: tuple[str, ...]) -> tuple[str, ...]:
818
+ if not paths:
819
+ return ()
820
+ files = project_files(project.root)
821
+ return tuple(
822
+ f"`{glob}` matches {len(select_files(files, (glob,)))} file(s) now."
823
+ for glob in paths
824
+ )
825
+
826
+
827
+ def _glob_warnings(project: RuleProject, paths: tuple[str, ...]) -> tuple[str, ...]:
828
+ if not paths:
829
+ return ()
830
+ files = project_files(project.root)
831
+ return tuple(
832
+ f"`{glob}` matches no file in the project, so the rule applies to no "
833
+ "file until one exists"
834
+ for glob in paths
835
+ if not select_files(files, (glob,))
836
+ )
837
+
838
+
839
+ def _wording_warnings(
840
+ automation: RuleAutomation, rule: RuleDefinition, body: str
841
+ ) -> tuple[str, ...]:
842
+ new_hash = rule_text_hash(body)
843
+ if new_hash == rule.text_hash:
844
+ return ()
845
+ warnings = [
846
+ f"The wording changes, so its hash changes from {rule.text_hash[:12]} to "
847
+ f"{new_hash[:12]}."
848
+ ]
849
+ entry = automation.rules.get(rule.text_hash)
850
+ if entry is not None:
851
+ warnings.append(
852
+ f"The rule-automation store entry {rule.text_hash[:12]} ({entry.status}) "
853
+ "stops matching this rule; once no rule has the old wording, "
854
+ "`rules prune` removes it."
855
+ )
856
+ if entry.status == "converted" and entry.check is not None:
857
+ warnings.append(
858
+ f"Its approved check `{entry.check}` stops running for this rule. To "
859
+ f"keep the command, put the old wording back, run `rules promote "
860
+ f"{entry.check}`, then edit; otherwise a verifier judges the new "
861
+ "wording and may propose a check again."
862
+ )
863
+ return tuple(warnings)
864
+
865
+
866
+ @dataclass(frozen=True)
867
+ class _RulesImport:
868
+ """``ww-rules.yaml``: the rule groups ww writes, and whether it is imported."""
869
+
870
+ path: Path
871
+ label: str
872
+ groups: dict[str, Any]
873
+ other: dict[str, Any]
874
+ imported: bool
875
+ root_text: str
876
+ root_raw: dict[str, Any]
877
+
878
+ @classmethod
879
+ def read(cls, project: RuleProject) -> _RulesImport:
880
+ path = project.base / RULES_IMPORT_FILE
881
+ label = project.label(path)
882
+ root_text = project.config_path.read_text(encoding="utf-8")
883
+ root_raw = yaml.safe_load(root_text)
884
+ if not isinstance(root_raw, dict): # pragma: no cover - it loaded
885
+ raise StateError(f"{project.label(project.config_path)} is not a mapping")
886
+ imports = root_raw.get("imports") or []
887
+ imported = isinstance(imports, list) and any(
888
+ isinstance(entry, str)
889
+ and (project.base / entry).resolve() == path.resolve()
890
+ for entry in imports
891
+ )
892
+ groups: dict[str, Any] = {}
893
+ other: dict[str, Any] = {}
894
+ if path.exists():
895
+ if not imported:
896
+ raise StateError(
897
+ f"{label} exists but {project.label(project.config_path)} does "
898
+ "not import it; add it to imports or move it away first"
899
+ )
900
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
901
+ if not isinstance(raw, dict) or not isinstance(raw.get("rules", {}), dict):
902
+ raise StateError(f"{label} must hold a `rules` mapping")
903
+ groups = dict(raw.get("rules") or {})
904
+ other = {key: value for key, value in raw.items() if key != "rules"}
905
+ return cls(path, label, groups, other, imported, root_text, root_raw)
906
+
907
+ def write(self, groups: dict[str, Any]) -> FileWrite:
908
+ return FileWrite(
909
+ self.path, _IMPORT_HEADER + dump_yaml({**self.other, "rules": groups})
910
+ )
911
+
912
+ def import_it(self, project: RuleProject) -> FileWrite:
913
+ """The repo file with this file added to its imports, nothing else."""
914
+ return import_write(
915
+ project.config_path,
916
+ self.root_text,
917
+ self.root_raw,
918
+ RULES_IMPORT_FILE,
919
+ project.label(project.config_path),
920
+ )