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_store.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The rule-automation store: what ww learned about rules without a command.
|
|
3
|
+
|
|
4
|
+
A rule without a command is judged by a verifier agent, unless a converted
|
|
5
|
+
check covers its wording. ``ww-scriptize-rules`` builds such checks outside
|
|
6
|
+
any task, and ``ww rules convert`` records each on the operator's
|
|
7
|
+
confirmation. ww keeps that knowledge in ``ww-rule-automation.json`` at the
|
|
8
|
+
project root, a file meant to be committed so every checkout and task shares
|
|
9
|
+
it. Nothing here is configuration:
|
|
10
|
+
verification never rewrites YAML or rule files, and the store is derived
|
|
11
|
+
knowledge only; the operator's ``ww rules`` writes are the only way rule files
|
|
12
|
+
change (:mod:`ww.rule_writes`).
|
|
13
|
+
|
|
14
|
+
Two maps make up the store. ``rules`` is keyed by the hash of a rule's
|
|
15
|
+
normalised text (:func:`ww.config.rules.rule_text_hash`, the single source of
|
|
16
|
+
truth for that key), so the same wording shares its knowledge wherever it is
|
|
17
|
+
declared and a changed wording starts over. ``checks`` is keyed by a short
|
|
18
|
+
check name; one check may cover several rules, the
|
|
19
|
+
normal case for an ecosystem tool whose one configuration holds many rules.
|
|
20
|
+
Only a ``converted`` check is ever run, and a rule is mechanical only when
|
|
21
|
+
its entry is ``converted`` and names a ``converted`` check. A store written
|
|
22
|
+
before verifiers stopped proposing checks may still hold their interim
|
|
23
|
+
statuses (an approach, a proposed check, a pending revision); they are read
|
|
24
|
+
as they are and never written any more.
|
|
25
|
+
|
|
26
|
+
The file is shared by every task of the project, so each change is a
|
|
27
|
+
read-modify-write under a dedicated file lock, taken inside the task lock of
|
|
28
|
+
the command that changes it and never the other way round.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from dataclasses import dataclass, field, replace
|
|
37
|
+
from pathlib import Path, PurePosixPath
|
|
38
|
+
from typing import Any, Literal, cast
|
|
39
|
+
|
|
40
|
+
from ww.actions import Commands
|
|
41
|
+
from ww.config.rules import parse_check_command
|
|
42
|
+
from ww.config_files import RULE_AUTOMATION_FILE
|
|
43
|
+
from ww.contracts import CheckAutomationStatus, RuleAutomationStatus
|
|
44
|
+
from ww.errors import ConfigurationError, StateError
|
|
45
|
+
from ww.locking import FileLocks
|
|
46
|
+
from ww.validation import (
|
|
47
|
+
expect_bool,
|
|
48
|
+
expect_literal,
|
|
49
|
+
expect_optional_string,
|
|
50
|
+
expect_string,
|
|
51
|
+
is_strict_int,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
STORE_FILE = RULE_AUTOMATION_FILE
|
|
55
|
+
STORE_SCHEMA_VERSION = 1
|
|
56
|
+
# Who approved a proposal. ww asks the operator for every approval; ``auto``
|
|
57
|
+
# is what a store written by an earlier ww may record for its own approvals.
|
|
58
|
+
RuleApprover = Literal["operator", "auto"]
|
|
59
|
+
# A check name: lower-case words joined by single hyphens, e.g. "lint-src".
|
|
60
|
+
CHECK_NAME = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
|
61
|
+
CHECK_NAME_LIMIT = 40
|
|
62
|
+
_RULE_KEYS = {
|
|
63
|
+
"text",
|
|
64
|
+
"status",
|
|
65
|
+
"interpretation",
|
|
66
|
+
"approach",
|
|
67
|
+
"check",
|
|
68
|
+
"extends",
|
|
69
|
+
"reason",
|
|
70
|
+
"candidates",
|
|
71
|
+
"proposed_in",
|
|
72
|
+
"proposed_run",
|
|
73
|
+
"approved_by",
|
|
74
|
+
"approved_in",
|
|
75
|
+
}
|
|
76
|
+
_APPROVAL_KEYS = {"proposed_run", "approved_by", "approved_in"}
|
|
77
|
+
_SPEC_KEYS = {"argv", "shell", "args", "env", "assert", "config", "covers", "proven"}
|
|
78
|
+
_CHECK_KEYS = (
|
|
79
|
+
_SPEC_KEYS
|
|
80
|
+
| {
|
|
81
|
+
"status",
|
|
82
|
+
"proposed_at",
|
|
83
|
+
"approved_at",
|
|
84
|
+
"proposed_in",
|
|
85
|
+
"pending",
|
|
86
|
+
"reason",
|
|
87
|
+
}
|
|
88
|
+
| _APPROVAL_KEYS
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class RuleEntry:
|
|
94
|
+
"""What ww knows about one rule wording.
|
|
95
|
+
|
|
96
|
+
``check`` names the check that covers the rule, or, for an approach, the
|
|
97
|
+
check the verifier would create or extend (``extends``). ``reason``
|
|
98
|
+
explains a ``not_convertible`` or ``rejected`` rule; ``candidates`` are
|
|
99
|
+
the readings of an ``ambiguous`` one. ``proposed_in`` is the
|
|
100
|
+
verification item that last reported on it, and ``proposed_run`` its run
|
|
101
|
+
(``<task>/<run>``). ``approved_by`` and ``approved_in`` record who
|
|
102
|
+
approved its approach, reading, or check, and in which run; ``None`` for
|
|
103
|
+
an approval recorded before the store kept them.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
text: str
|
|
107
|
+
status: RuleAutomationStatus
|
|
108
|
+
interpretation: str | None = None
|
|
109
|
+
approach: str | None = None
|
|
110
|
+
check: str | None = None
|
|
111
|
+
extends: bool = False
|
|
112
|
+
reason: str | None = None
|
|
113
|
+
candidates: tuple[str, ...] = ()
|
|
114
|
+
proposed_in: str | None = None
|
|
115
|
+
proposed_run: str | None = None
|
|
116
|
+
approved_by: RuleApprover | None = None
|
|
117
|
+
approved_in: str | None = None
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, object]:
|
|
120
|
+
data: dict[str, object] = {"text": self.text, "status": self.status}
|
|
121
|
+
for key, value in (
|
|
122
|
+
("interpretation", self.interpretation),
|
|
123
|
+
("approach", self.approach),
|
|
124
|
+
("check", self.check),
|
|
125
|
+
("reason", self.reason),
|
|
126
|
+
("proposed_in", self.proposed_in),
|
|
127
|
+
("proposed_run", self.proposed_run),
|
|
128
|
+
("approved_by", self.approved_by),
|
|
129
|
+
("approved_in", self.approved_in),
|
|
130
|
+
):
|
|
131
|
+
if value is not None:
|
|
132
|
+
data[key] = value
|
|
133
|
+
if self.extends:
|
|
134
|
+
data["extends"] = True
|
|
135
|
+
if self.candidates:
|
|
136
|
+
data["candidates"] = list(self.candidates)
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: Any, path: str) -> RuleEntry:
|
|
141
|
+
mapping = _mapping(data, path, _RULE_KEYS)
|
|
142
|
+
return cls(
|
|
143
|
+
text=expect_string(mapping.get("text"), f"{path}.text"),
|
|
144
|
+
status=expect_literal(
|
|
145
|
+
mapping.get("status"), RuleAutomationStatus, f"{path}.status"
|
|
146
|
+
),
|
|
147
|
+
interpretation=expect_optional_string(
|
|
148
|
+
mapping.get("interpretation"), f"{path}.interpretation"
|
|
149
|
+
),
|
|
150
|
+
approach=expect_optional_string(
|
|
151
|
+
mapping.get("approach"), f"{path}.approach"
|
|
152
|
+
),
|
|
153
|
+
check=expect_optional_string(mapping.get("check"), f"{path}.check"),
|
|
154
|
+
extends=expect_bool(mapping.get("extends", False), f"{path}.extends"),
|
|
155
|
+
reason=expect_optional_string(mapping.get("reason"), f"{path}.reason"),
|
|
156
|
+
candidates=_strings(mapping.get("candidates", []), f"{path}.candidates"),
|
|
157
|
+
proposed_in=expect_optional_string(
|
|
158
|
+
mapping.get("proposed_in"), f"{path}.proposed_in"
|
|
159
|
+
),
|
|
160
|
+
**_approval(mapping, path),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _approval(mapping: dict[str, Any], path: str) -> dict[str, Any]:
|
|
165
|
+
"""The provenance fields an entry shares: its run, approver, approval run."""
|
|
166
|
+
approver = mapping.get("approved_by")
|
|
167
|
+
return {
|
|
168
|
+
"proposed_run": expect_optional_string(
|
|
169
|
+
mapping.get("proposed_run"), f"{path}.proposed_run"
|
|
170
|
+
),
|
|
171
|
+
"approved_by": (
|
|
172
|
+
expect_literal(approver, RuleApprover, f"{path}.approved_by")
|
|
173
|
+
if approver is not None
|
|
174
|
+
else None
|
|
175
|
+
),
|
|
176
|
+
"approved_in": expect_optional_string(
|
|
177
|
+
mapping.get("approved_in"), f"{path}.approved_in"
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class CheckSpec:
|
|
184
|
+
"""One derived check: its command, the files holding its logic, its rules.
|
|
185
|
+
|
|
186
|
+
``command`` is the cli handler shape (``argv`` or ``shell`` with ``args``
|
|
187
|
+
and ``env``, and ``assert``); ``config`` lists the project files that
|
|
188
|
+
carry the check's logic, so a later rule can join the same tool;
|
|
189
|
+
``covers`` are the text hashes of the rules it checks; ``proven`` is the
|
|
190
|
+
verifier's report that the check fails on a deliberate violation and
|
|
191
|
+
passes on the real change set.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
command: Commands
|
|
195
|
+
config: tuple[str, ...] = ()
|
|
196
|
+
covers: tuple[str, ...] = ()
|
|
197
|
+
proven: bool = False
|
|
198
|
+
|
|
199
|
+
def to_dict(self) -> dict[str, object]:
|
|
200
|
+
data: dict[str, object] = dict(self.command.commands[0].to_dict())
|
|
201
|
+
data["assert"] = (
|
|
202
|
+
self.command.assertion.to_data() if self.command.assertion else None
|
|
203
|
+
)
|
|
204
|
+
data["config"] = list(self.config)
|
|
205
|
+
data["covers"] = list(self.covers)
|
|
206
|
+
data["proven"] = self.proven
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def from_dict(cls, data: dict[str, Any], path: str) -> CheckSpec:
|
|
211
|
+
command_keys = cast(
|
|
212
|
+
dict[str, Any],
|
|
213
|
+
{
|
|
214
|
+
key: data[key]
|
|
215
|
+
for key in ("argv", "shell", "args", "env", "assert")
|
|
216
|
+
if key in data and data[key] is not None
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
return cls(
|
|
220
|
+
command=parse_command(command_keys, path),
|
|
221
|
+
config=_strings(data.get("config", []), f"{path}.config"),
|
|
222
|
+
covers=_strings(data.get("covers", []), f"{path}.covers"),
|
|
223
|
+
proven=expect_bool(data.get("proven", False), f"{path}.proven"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def is_check_name(value: str) -> bool:
|
|
228
|
+
"""Whether ``value`` is a check name: kebab-case, at most 40 characters.
|
|
229
|
+
|
|
230
|
+
The limit keeps a name from ever looking like a rule's 64-character hash.
|
|
231
|
+
"""
|
|
232
|
+
return len(value) <= CHECK_NAME_LIMIT and CHECK_NAME.fullmatch(value) is not None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_config_path(path: str) -> bool:
|
|
236
|
+
"""Whether ``path`` stays inside the directory it is read from."""
|
|
237
|
+
pure = PurePosixPath(path)
|
|
238
|
+
return bool(path.strip()) and not pure.is_absolute() and ".." not in pure.parts
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_command(mapping: dict[str, Any], path: str) -> Commands:
|
|
242
|
+
"""A check command in the cli handler shape; one command, no ``idempotent``."""
|
|
243
|
+
try:
|
|
244
|
+
return parse_check_command(mapping, path)
|
|
245
|
+
except ConfigurationError as error:
|
|
246
|
+
raise StateError(str(error)) from error
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def describe_command(command: Commands) -> str:
|
|
250
|
+
"""A check command as the operator reads it before approving it."""
|
|
251
|
+
first = command.commands[0]
|
|
252
|
+
if first.shell is not None:
|
|
253
|
+
text = first.shell
|
|
254
|
+
if first.args:
|
|
255
|
+
text += " (args: " + " ".join(first.args) + ")"
|
|
256
|
+
if first.env:
|
|
257
|
+
text += " (env: " + ", ".join(f"{k}={v}" for k, v in first.env) + ")"
|
|
258
|
+
else:
|
|
259
|
+
text = " ".join(first.argv)
|
|
260
|
+
return text
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True)
|
|
264
|
+
class CheckEntry:
|
|
265
|
+
"""One derived check and its approval state.
|
|
266
|
+
|
|
267
|
+
``pending`` is a revision of a ``converted`` check that a verifier once
|
|
268
|
+
proposed, as only an old store holds; it never runs, and ``rules
|
|
269
|
+
convert`` drops it. ``reason`` explains a
|
|
270
|
+
``rejected`` check, such as the operator's ``ww rules revoke``. The
|
|
271
|
+
provenance fields are those of :class:`RuleEntry`.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
spec: CheckSpec
|
|
275
|
+
status: CheckAutomationStatus
|
|
276
|
+
proposed_at: str | None = None
|
|
277
|
+
approved_at: str | None = None
|
|
278
|
+
proposed_in: str | None = None
|
|
279
|
+
pending: CheckSpec | None = None
|
|
280
|
+
reason: str | None = None
|
|
281
|
+
proposed_run: str | None = None
|
|
282
|
+
approved_by: RuleApprover | None = None
|
|
283
|
+
approved_in: str | None = None
|
|
284
|
+
|
|
285
|
+
def to_dict(self) -> dict[str, object]:
|
|
286
|
+
data = self.spec.to_dict()
|
|
287
|
+
data["status"] = self.status
|
|
288
|
+
for key, value in (
|
|
289
|
+
("proposed_at", self.proposed_at),
|
|
290
|
+
("approved_at", self.approved_at),
|
|
291
|
+
("proposed_in", self.proposed_in),
|
|
292
|
+
("reason", self.reason),
|
|
293
|
+
("proposed_run", self.proposed_run),
|
|
294
|
+
("approved_by", self.approved_by),
|
|
295
|
+
("approved_in", self.approved_in),
|
|
296
|
+
):
|
|
297
|
+
if value is not None:
|
|
298
|
+
data[key] = value
|
|
299
|
+
if self.pending is not None:
|
|
300
|
+
data["pending"] = self.pending.to_dict()
|
|
301
|
+
return data
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_dict(cls, data: Any, path: str) -> CheckEntry:
|
|
305
|
+
mapping = _mapping(data, path, _CHECK_KEYS)
|
|
306
|
+
pending = mapping.get("pending")
|
|
307
|
+
return cls(
|
|
308
|
+
spec=CheckSpec.from_dict(mapping, path),
|
|
309
|
+
status=expect_literal(
|
|
310
|
+
mapping.get("status"), CheckAutomationStatus, f"{path}.status"
|
|
311
|
+
),
|
|
312
|
+
proposed_at=expect_optional_string(
|
|
313
|
+
mapping.get("proposed_at"), f"{path}.proposed_at"
|
|
314
|
+
),
|
|
315
|
+
approved_at=expect_optional_string(
|
|
316
|
+
mapping.get("approved_at"), f"{path}.approved_at"
|
|
317
|
+
),
|
|
318
|
+
proposed_in=expect_optional_string(
|
|
319
|
+
mapping.get("proposed_in"), f"{path}.proposed_in"
|
|
320
|
+
),
|
|
321
|
+
pending=(
|
|
322
|
+
CheckSpec.from_dict(
|
|
323
|
+
_mapping(pending, f"{path}.pending", _SPEC_KEYS), f"{path}.pending"
|
|
324
|
+
)
|
|
325
|
+
if pending is not None
|
|
326
|
+
else None
|
|
327
|
+
),
|
|
328
|
+
reason=expect_optional_string(mapping.get("reason"), f"{path}.reason"),
|
|
329
|
+
**_approval(mapping, path),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class RuleAutomation:
|
|
335
|
+
"""The whole store. Its maps are owned by the instance and never mutated;
|
|
336
|
+
``with_rule`` and ``with_check`` return a changed copy."""
|
|
337
|
+
|
|
338
|
+
rules: dict[str, RuleEntry] = field(default_factory=dict)
|
|
339
|
+
checks: dict[str, CheckEntry] = field(default_factory=dict)
|
|
340
|
+
|
|
341
|
+
def with_rule(self, text_hash: str, entry: RuleEntry) -> RuleAutomation:
|
|
342
|
+
return replace(self, rules={**self.rules, text_hash: entry})
|
|
343
|
+
|
|
344
|
+
def with_check(self, name: str, entry: CheckEntry) -> RuleAutomation:
|
|
345
|
+
return replace(self, checks={**self.checks, name: entry})
|
|
346
|
+
|
|
347
|
+
def converted_check(self, text_hash: str) -> tuple[str, CheckEntry] | None:
|
|
348
|
+
"""The converted check that makes a rule mechanical, if there is one."""
|
|
349
|
+
entry = self.rules.get(text_hash)
|
|
350
|
+
if entry is None or entry.status != "converted" or entry.check is None:
|
|
351
|
+
return None
|
|
352
|
+
check = self.checks.get(entry.check)
|
|
353
|
+
if check is None or check.status != "converted":
|
|
354
|
+
return None
|
|
355
|
+
return entry.check, check
|
|
356
|
+
|
|
357
|
+
def to_dict(self) -> dict[str, object]:
|
|
358
|
+
return {
|
|
359
|
+
"schema_version": STORE_SCHEMA_VERSION,
|
|
360
|
+
"rules": {key: entry.to_dict() for key, entry in self.rules.items()},
|
|
361
|
+
"checks": {key: entry.to_dict() for key, entry in self.checks.items()},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def from_dict(cls, data: Any) -> RuleAutomation:
|
|
366
|
+
if not isinstance(data, dict):
|
|
367
|
+
raise ValueError("the rule automation store must be an object")
|
|
368
|
+
version = data.get("schema_version")
|
|
369
|
+
if not is_strict_int(version) or version != STORE_SCHEMA_VERSION:
|
|
370
|
+
raise ValueError(f"unsupported rule automation schema: {version!r}")
|
|
371
|
+
unknown = set(data) - {"schema_version", "rules", "checks"}
|
|
372
|
+
if unknown:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"unknown rule automation keys: " + ", ".join(sorted(unknown))
|
|
375
|
+
)
|
|
376
|
+
rules = data.get("rules", {})
|
|
377
|
+
checks = data.get("checks", {})
|
|
378
|
+
if not isinstance(rules, dict) or not isinstance(checks, dict):
|
|
379
|
+
raise ValueError("rule automation rules and checks must be objects")
|
|
380
|
+
for name in checks:
|
|
381
|
+
if not isinstance(name, str) or not is_check_name(name):
|
|
382
|
+
raise ValueError(f"invalid check name in the store: {name!r}")
|
|
383
|
+
return cls(
|
|
384
|
+
rules={
|
|
385
|
+
expect_string(key, "rule hash"): RuleEntry.from_dict(
|
|
386
|
+
value, f"rules.{key}"
|
|
387
|
+
)
|
|
388
|
+
for key, value in rules.items()
|
|
389
|
+
},
|
|
390
|
+
checks={
|
|
391
|
+
key: CheckEntry.from_dict(value, f"checks.{key}")
|
|
392
|
+
for key, value in checks.items()
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class RuleStore:
|
|
398
|
+
"""Load and change ``ww-rule-automation.json`` at the project root.
|
|
399
|
+
|
|
400
|
+
A missing file is an empty store. An unreadable or malformed file is an
|
|
401
|
+
error, never an empty store: losing approvals silently would re-ask the
|
|
402
|
+
operator and could hide a decision.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(self, root: Path) -> None:
|
|
406
|
+
self.path = Path(root) / STORE_FILE
|
|
407
|
+
self.locks = FileLocks(Path(root))
|
|
408
|
+
|
|
409
|
+
def exists(self) -> bool:
|
|
410
|
+
return self.path.is_file()
|
|
411
|
+
|
|
412
|
+
def load(self) -> RuleAutomation:
|
|
413
|
+
if not self.path.exists():
|
|
414
|
+
return RuleAutomation()
|
|
415
|
+
try:
|
|
416
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
417
|
+
return RuleAutomation.from_dict(raw)
|
|
418
|
+
except (OSError, json.JSONDecodeError, ValueError, StateError) as error:
|
|
419
|
+
raise StateError(
|
|
420
|
+
f"invalid rule automation store {self.path}: {error}"
|
|
421
|
+
) from error
|
|
422
|
+
|
|
423
|
+
def modify(
|
|
424
|
+
self, change: Callable[[RuleAutomation], RuleAutomation]
|
|
425
|
+
) -> RuleAutomation:
|
|
426
|
+
"""Apply ``change`` to the current store under its lock and save it.
|
|
427
|
+
|
|
428
|
+
The whole read-modify-write holds the store's lock, so two tasks
|
|
429
|
+
recording results at once never lose each other's entries. The file is
|
|
430
|
+
written only when something changed.
|
|
431
|
+
"""
|
|
432
|
+
with self.locks.lock(self.path, purpose="rule automation store"):
|
|
433
|
+
current = self.load()
|
|
434
|
+
updated = change(current)
|
|
435
|
+
if updated != current:
|
|
436
|
+
self.locks.atomic_write(
|
|
437
|
+
self.path, json.dumps(updated.to_dict(), indent=2) + "\n"
|
|
438
|
+
)
|
|
439
|
+
return updated
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _mapping(data: Any, path: str, allowed: set[str]) -> dict[str, Any]:
|
|
443
|
+
if not isinstance(data, dict):
|
|
444
|
+
raise ValueError(f"{path} must be an object")
|
|
445
|
+
unknown = set(data) - allowed
|
|
446
|
+
if unknown:
|
|
447
|
+
raise ValueError(f"{path} has unknown keys: " + ", ".join(sorted(unknown)))
|
|
448
|
+
return data
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _strings(value: Any, path: str) -> tuple[str, ...]:
|
|
452
|
+
if not isinstance(value, list) or not all(
|
|
453
|
+
isinstance(item, str) and item for item in value
|
|
454
|
+
):
|
|
455
|
+
raise ValueError(f"{path} must be a list of non-empty strings")
|
|
456
|
+
return tuple(value)
|