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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|