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