deepwork 0.2.0__py3-none-any.whl → 0.3.1__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.
- deepwork/cli/install.py +116 -71
- deepwork/cli/sync.py +20 -20
- deepwork/core/adapters.py +88 -51
- deepwork/core/command_executor.py +173 -0
- deepwork/core/generator.py +148 -31
- deepwork/core/hooks_syncer.py +51 -25
- deepwork/core/parser.py +8 -0
- deepwork/core/pattern_matcher.py +271 -0
- deepwork/core/rules_parser.py +559 -0
- deepwork/core/rules_queue.py +321 -0
- deepwork/hooks/README.md +181 -0
- deepwork/hooks/__init__.py +77 -1
- deepwork/hooks/claude_hook.sh +55 -0
- deepwork/hooks/gemini_hook.sh +55 -0
- deepwork/hooks/rules_check.py +700 -0
- deepwork/hooks/wrapper.py +363 -0
- deepwork/schemas/job_schema.py +14 -1
- deepwork/schemas/rules_schema.py +135 -0
- deepwork/standard_jobs/deepwork_jobs/job.yml +35 -53
- deepwork/standard_jobs/deepwork_jobs/steps/define.md +9 -6
- deepwork/standard_jobs/deepwork_jobs/steps/implement.md +28 -26
- deepwork/standard_jobs/deepwork_jobs/steps/learn.md +2 -2
- deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +30 -0
- deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +8 -0
- deepwork/standard_jobs/deepwork_rules/job.yml +47 -0
- deepwork/standard_jobs/deepwork_rules/rules/.gitkeep +13 -0
- deepwork/standard_jobs/deepwork_rules/rules/api-documentation-sync.md.example +10 -0
- deepwork/standard_jobs/deepwork_rules/rules/readme-documentation.md.example +10 -0
- deepwork/standard_jobs/deepwork_rules/rules/security-review.md.example +11 -0
- deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +46 -0
- deepwork/standard_jobs/deepwork_rules/rules/source-test-pairing.md.example +13 -0
- deepwork/standard_jobs/deepwork_rules/steps/define.md +249 -0
- deepwork/templates/claude/skill-job-meta.md.jinja +70 -0
- deepwork/templates/claude/skill-job-step.md.jinja +198 -0
- deepwork/templates/gemini/skill-job-meta.toml.jinja +76 -0
- deepwork/templates/gemini/skill-job-step.toml.jinja +147 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/METADATA +56 -25
- deepwork-0.3.1.dist-info/RECORD +62 -0
- deepwork/core/policy_parser.py +0 -295
- deepwork/hooks/evaluate_policies.py +0 -376
- deepwork/schemas/policy_schema.py +0 -78
- deepwork/standard_jobs/deepwork_policy/hooks/capture_prompt_work_tree.sh +0 -27
- deepwork/standard_jobs/deepwork_policy/hooks/global_hooks.yml +0 -8
- deepwork/standard_jobs/deepwork_policy/hooks/policy_stop_hook.sh +0 -56
- deepwork/standard_jobs/deepwork_policy/job.yml +0 -35
- deepwork/standard_jobs/deepwork_policy/steps/define.md +0 -195
- deepwork/templates/claude/command-job-step.md.jinja +0 -210
- deepwork/templates/default_policy.yml +0 -53
- deepwork/templates/gemini/command-job-step.toml.jinja +0 -169
- deepwork-0.2.0.dist-info/RECORD +0 -49
- /deepwork/standard_jobs/{deepwork_policy → deepwork_rules}/hooks/user_prompt_submit.sh +0 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/WHEEL +0 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/entry_points.txt +0 -0
- {deepwork-0.2.0.dist-info → deepwork-0.3.1.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""Rule definition parser (v2 - frontmatter markdown format)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from deepwork.core.pattern_matcher import (
|
|
11
|
+
has_variables,
|
|
12
|
+
match_pattern,
|
|
13
|
+
matches_any_pattern,
|
|
14
|
+
resolve_pattern,
|
|
15
|
+
)
|
|
16
|
+
from deepwork.schemas.rules_schema import RULES_FRONTMATTER_SCHEMA
|
|
17
|
+
from deepwork.utils.validation import ValidationError, validate_against_schema
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RulesParseError(Exception):
|
|
21
|
+
"""Exception raised for rule parsing errors."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DetectionMode(Enum):
|
|
27
|
+
"""How the rule detects when to fire."""
|
|
28
|
+
|
|
29
|
+
TRIGGER_SAFETY = "trigger_safety" # Fire when trigger matches, safety doesn't
|
|
30
|
+
SET = "set" # Bidirectional file correspondence
|
|
31
|
+
PAIR = "pair" # Directional file correspondence
|
|
32
|
+
CREATED = "created" # Fire when created files match patterns
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ActionType(Enum):
|
|
36
|
+
"""What happens when the rule fires."""
|
|
37
|
+
|
|
38
|
+
PROMPT = "prompt" # Show instructions to agent (default)
|
|
39
|
+
COMMAND = "command" # Run an idempotent command
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Valid compare_to values
|
|
43
|
+
COMPARE_TO_VALUES = frozenset({"base", "default_tip", "prompt"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CommandAction:
|
|
48
|
+
"""Configuration for command action."""
|
|
49
|
+
|
|
50
|
+
command: str # Command template (supports {file}, {files}, {repo_root})
|
|
51
|
+
run_for: str = "each_match" # "each_match" or "all_matches"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class PairConfig:
|
|
56
|
+
"""Configuration for pair detection mode."""
|
|
57
|
+
|
|
58
|
+
trigger: str # Pattern that triggers
|
|
59
|
+
expects: list[str] # Patterns for expected corresponding files
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Rule:
|
|
64
|
+
"""Represents a single rule definition (v2 format)."""
|
|
65
|
+
|
|
66
|
+
# Identity
|
|
67
|
+
name: str # Human-friendly name (displayed in promise tags)
|
|
68
|
+
filename: str # Filename without .md extension (used for queue)
|
|
69
|
+
|
|
70
|
+
# Detection mode (exactly one must be set)
|
|
71
|
+
detection_mode: DetectionMode
|
|
72
|
+
|
|
73
|
+
# Common options (required)
|
|
74
|
+
compare_to: str # Required: "base", "default_tip", or "prompt"
|
|
75
|
+
|
|
76
|
+
# Detection mode details (optional, depends on mode)
|
|
77
|
+
triggers: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode
|
|
78
|
+
safety: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode
|
|
79
|
+
set_patterns: list[str] = field(default_factory=list) # For SET mode
|
|
80
|
+
pair_config: PairConfig | None = None # For PAIR mode
|
|
81
|
+
created_patterns: list[str] = field(default_factory=list) # For CREATED mode
|
|
82
|
+
|
|
83
|
+
# Action type
|
|
84
|
+
action_type: ActionType = ActionType.PROMPT
|
|
85
|
+
instructions: str = "" # For PROMPT action (markdown body)
|
|
86
|
+
command_action: CommandAction | None = None # For COMMAND action
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_frontmatter(
|
|
90
|
+
cls,
|
|
91
|
+
frontmatter: dict[str, Any],
|
|
92
|
+
markdown_body: str,
|
|
93
|
+
filename: str,
|
|
94
|
+
) -> "Rule":
|
|
95
|
+
"""
|
|
96
|
+
Create Rule from parsed frontmatter and markdown body.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
frontmatter: Parsed YAML frontmatter
|
|
100
|
+
markdown_body: Markdown content after frontmatter
|
|
101
|
+
filename: Filename without .md extension
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Rule instance
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
RulesParseError: If validation fails
|
|
108
|
+
"""
|
|
109
|
+
# Get name (required)
|
|
110
|
+
name = frontmatter.get("name", "")
|
|
111
|
+
if not name:
|
|
112
|
+
raise RulesParseError(f"Rule '{filename}' missing required 'name' field")
|
|
113
|
+
|
|
114
|
+
# Determine detection mode
|
|
115
|
+
has_trigger = "trigger" in frontmatter
|
|
116
|
+
has_set = "set" in frontmatter
|
|
117
|
+
has_pair = "pair" in frontmatter
|
|
118
|
+
has_created = "created" in frontmatter
|
|
119
|
+
|
|
120
|
+
mode_count = sum([has_trigger, has_set, has_pair, has_created])
|
|
121
|
+
if mode_count == 0:
|
|
122
|
+
raise RulesParseError(f"Rule '{name}' must have 'trigger', 'set', 'pair', or 'created'")
|
|
123
|
+
if mode_count > 1:
|
|
124
|
+
raise RulesParseError(f"Rule '{name}' has multiple detection modes - use only one")
|
|
125
|
+
|
|
126
|
+
# Parse based on detection mode
|
|
127
|
+
detection_mode: DetectionMode
|
|
128
|
+
triggers: list[str] = []
|
|
129
|
+
safety: list[str] = []
|
|
130
|
+
set_patterns: list[str] = []
|
|
131
|
+
pair_config: PairConfig | None = None
|
|
132
|
+
created_patterns: list[str] = []
|
|
133
|
+
|
|
134
|
+
if has_trigger:
|
|
135
|
+
detection_mode = DetectionMode.TRIGGER_SAFETY
|
|
136
|
+
trigger = frontmatter["trigger"]
|
|
137
|
+
triggers = [trigger] if isinstance(trigger, str) else list(trigger)
|
|
138
|
+
safety_data = frontmatter.get("safety", [])
|
|
139
|
+
safety = [safety_data] if isinstance(safety_data, str) else list(safety_data)
|
|
140
|
+
|
|
141
|
+
elif has_set:
|
|
142
|
+
detection_mode = DetectionMode.SET
|
|
143
|
+
set_patterns = list(frontmatter["set"])
|
|
144
|
+
if len(set_patterns) < 2:
|
|
145
|
+
raise RulesParseError(f"Rule '{name}' set requires at least 2 patterns")
|
|
146
|
+
|
|
147
|
+
elif has_pair:
|
|
148
|
+
detection_mode = DetectionMode.PAIR
|
|
149
|
+
pair_data = frontmatter["pair"]
|
|
150
|
+
expects = pair_data["expects"]
|
|
151
|
+
expects_list = [expects] if isinstance(expects, str) else list(expects)
|
|
152
|
+
pair_config = PairConfig(
|
|
153
|
+
trigger=pair_data["trigger"],
|
|
154
|
+
expects=expects_list,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
elif has_created:
|
|
158
|
+
detection_mode = DetectionMode.CREATED
|
|
159
|
+
created = frontmatter["created"]
|
|
160
|
+
created_patterns = [created] if isinstance(created, str) else list(created)
|
|
161
|
+
|
|
162
|
+
# Determine action type
|
|
163
|
+
action_type: ActionType
|
|
164
|
+
command_action: CommandAction | None = None
|
|
165
|
+
|
|
166
|
+
if "action" in frontmatter:
|
|
167
|
+
action_type = ActionType.COMMAND
|
|
168
|
+
action_data = frontmatter["action"]
|
|
169
|
+
command_action = CommandAction(
|
|
170
|
+
command=action_data["command"],
|
|
171
|
+
run_for=action_data.get("run_for", "each_match"),
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
action_type = ActionType.PROMPT
|
|
175
|
+
# Markdown body is the instructions
|
|
176
|
+
if not markdown_body.strip():
|
|
177
|
+
raise RulesParseError(f"Rule '{name}' with prompt action requires markdown body")
|
|
178
|
+
|
|
179
|
+
# Get compare_to (required field)
|
|
180
|
+
compare_to = frontmatter["compare_to"]
|
|
181
|
+
|
|
182
|
+
return cls(
|
|
183
|
+
name=name,
|
|
184
|
+
filename=filename,
|
|
185
|
+
detection_mode=detection_mode,
|
|
186
|
+
triggers=triggers,
|
|
187
|
+
safety=safety,
|
|
188
|
+
set_patterns=set_patterns,
|
|
189
|
+
pair_config=pair_config,
|
|
190
|
+
created_patterns=created_patterns,
|
|
191
|
+
action_type=action_type,
|
|
192
|
+
instructions=markdown_body.strip(),
|
|
193
|
+
command_action=command_action,
|
|
194
|
+
compare_to=compare_to,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_frontmatter_file(filepath: Path) -> tuple[dict[str, Any], str]:
|
|
199
|
+
"""
|
|
200
|
+
Parse a markdown file with YAML frontmatter.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
filepath: Path to .md file
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Tuple of (frontmatter_dict, markdown_body)
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
RulesParseError: If parsing fails
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
content = filepath.read_text(encoding="utf-8")
|
|
213
|
+
except OSError as e:
|
|
214
|
+
raise RulesParseError(f"Failed to read rule file: {e}") from e
|
|
215
|
+
|
|
216
|
+
# Split frontmatter from body
|
|
217
|
+
if not content.startswith("---"):
|
|
218
|
+
raise RulesParseError(
|
|
219
|
+
f"Rule file '{filepath.name}' must start with '---' frontmatter delimiter"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Find end of frontmatter
|
|
223
|
+
end_marker = content.find("\n---", 3)
|
|
224
|
+
if end_marker == -1:
|
|
225
|
+
raise RulesParseError(
|
|
226
|
+
f"Rule file '{filepath.name}' missing closing '---' frontmatter delimiter"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
frontmatter_str = content[4:end_marker] # Skip initial "---\n"
|
|
230
|
+
markdown_body = content[end_marker + 4 :] # Skip "\n---\n" or "\n---"
|
|
231
|
+
|
|
232
|
+
# Parse YAML frontmatter
|
|
233
|
+
try:
|
|
234
|
+
frontmatter = yaml.safe_load(frontmatter_str)
|
|
235
|
+
except yaml.YAMLError as e:
|
|
236
|
+
raise RulesParseError(f"Invalid YAML frontmatter in '{filepath.name}': {e}") from e
|
|
237
|
+
|
|
238
|
+
if frontmatter is None:
|
|
239
|
+
frontmatter = {}
|
|
240
|
+
|
|
241
|
+
if not isinstance(frontmatter, dict):
|
|
242
|
+
raise RulesParseError(
|
|
243
|
+
f"Frontmatter in '{filepath.name}' must be a mapping, got {type(frontmatter).__name__}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return frontmatter, markdown_body
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def parse_rule_file(filepath: Path) -> Rule:
|
|
250
|
+
"""
|
|
251
|
+
Parse a single rule from a frontmatter markdown file.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
filepath: Path to .md file in .deepwork/rules/
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Parsed Rule object
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
RulesParseError: If parsing or validation fails
|
|
261
|
+
"""
|
|
262
|
+
if not filepath.exists():
|
|
263
|
+
raise RulesParseError(f"Rule file does not exist: {filepath}")
|
|
264
|
+
|
|
265
|
+
if not filepath.is_file():
|
|
266
|
+
raise RulesParseError(f"Rule path is not a file: {filepath}")
|
|
267
|
+
|
|
268
|
+
frontmatter, markdown_body = parse_frontmatter_file(filepath)
|
|
269
|
+
|
|
270
|
+
# Validate against schema
|
|
271
|
+
try:
|
|
272
|
+
validate_against_schema(frontmatter, RULES_FRONTMATTER_SCHEMA)
|
|
273
|
+
except ValidationError as e:
|
|
274
|
+
raise RulesParseError(f"Rule '{filepath.name}' validation failed: {e}") from e
|
|
275
|
+
|
|
276
|
+
# Create Rule object
|
|
277
|
+
filename = filepath.stem # filename without .md extension
|
|
278
|
+
return Rule.from_frontmatter(frontmatter, markdown_body, filename)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load_rules_from_directory(rules_dir: Path) -> list[Rule]:
|
|
282
|
+
"""
|
|
283
|
+
Load all rules from a directory.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
rules_dir: Path to .deepwork/rules/ directory
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of parsed Rule objects (sorted by filename)
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
RulesParseError: If any rule file fails to parse
|
|
293
|
+
"""
|
|
294
|
+
if not rules_dir.exists():
|
|
295
|
+
return []
|
|
296
|
+
|
|
297
|
+
if not rules_dir.is_dir():
|
|
298
|
+
raise RulesParseError(f"Rules path is not a directory: {rules_dir}")
|
|
299
|
+
|
|
300
|
+
rules = []
|
|
301
|
+
for filepath in sorted(rules_dir.glob("*.md")):
|
|
302
|
+
rule = parse_rule_file(filepath)
|
|
303
|
+
rules.append(rule)
|
|
304
|
+
|
|
305
|
+
return rules
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# =============================================================================
|
|
309
|
+
# Evaluation Logic
|
|
310
|
+
# =============================================================================
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def evaluate_trigger_safety(
|
|
314
|
+
rule: Rule,
|
|
315
|
+
changed_files: list[str],
|
|
316
|
+
) -> bool:
|
|
317
|
+
"""
|
|
318
|
+
Evaluate a trigger/safety mode rule.
|
|
319
|
+
|
|
320
|
+
Returns True if rule should fire:
|
|
321
|
+
- At least one changed file matches a trigger pattern
|
|
322
|
+
- AND no changed file matches a safety pattern
|
|
323
|
+
"""
|
|
324
|
+
# Check if any trigger matches
|
|
325
|
+
trigger_matched = False
|
|
326
|
+
for file_path in changed_files:
|
|
327
|
+
if matches_any_pattern(file_path, rule.triggers):
|
|
328
|
+
trigger_matched = True
|
|
329
|
+
break
|
|
330
|
+
|
|
331
|
+
if not trigger_matched:
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
# Check if any safety pattern matches
|
|
335
|
+
if rule.safety:
|
|
336
|
+
for file_path in changed_files:
|
|
337
|
+
if matches_any_pattern(file_path, rule.safety):
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def evaluate_set_correspondence(
|
|
344
|
+
rule: Rule,
|
|
345
|
+
changed_files: list[str],
|
|
346
|
+
) -> tuple[bool, list[str], list[str]]:
|
|
347
|
+
"""
|
|
348
|
+
Evaluate a set (bidirectional correspondence) rule.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Tuple of (should_fire, trigger_files, missing_files)
|
|
352
|
+
- should_fire: True if correspondence is incomplete
|
|
353
|
+
- trigger_files: Files that triggered (matched a pattern)
|
|
354
|
+
- missing_files: Expected files that didn't change
|
|
355
|
+
"""
|
|
356
|
+
trigger_files: list[str] = []
|
|
357
|
+
missing_files: list[str] = []
|
|
358
|
+
changed_set = set(changed_files)
|
|
359
|
+
|
|
360
|
+
for file_path in changed_files:
|
|
361
|
+
# Check each pattern in the set
|
|
362
|
+
for pattern in rule.set_patterns:
|
|
363
|
+
result = match_pattern(pattern, file_path)
|
|
364
|
+
if result.matched:
|
|
365
|
+
trigger_files.append(file_path)
|
|
366
|
+
|
|
367
|
+
# Check if all other corresponding files also changed
|
|
368
|
+
for other_pattern in rule.set_patterns:
|
|
369
|
+
if other_pattern == pattern:
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
if has_variables(other_pattern):
|
|
373
|
+
expected = resolve_pattern(other_pattern, result.variables)
|
|
374
|
+
else:
|
|
375
|
+
expected = other_pattern
|
|
376
|
+
|
|
377
|
+
if expected not in changed_set:
|
|
378
|
+
if expected not in missing_files:
|
|
379
|
+
missing_files.append(expected)
|
|
380
|
+
|
|
381
|
+
break # Only match one pattern per file
|
|
382
|
+
|
|
383
|
+
# Rule fires if there are trigger files with missing correspondences
|
|
384
|
+
should_fire = len(trigger_files) > 0 and len(missing_files) > 0
|
|
385
|
+
return should_fire, trigger_files, missing_files
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def evaluate_pair_correspondence(
|
|
389
|
+
rule: Rule,
|
|
390
|
+
changed_files: list[str],
|
|
391
|
+
) -> tuple[bool, list[str], list[str]]:
|
|
392
|
+
"""
|
|
393
|
+
Evaluate a pair (directional correspondence) rule.
|
|
394
|
+
|
|
395
|
+
Only trigger-side changes require corresponding expected files.
|
|
396
|
+
Expected-side changes alone do not trigger.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Tuple of (should_fire, trigger_files, missing_files)
|
|
400
|
+
"""
|
|
401
|
+
if rule.pair_config is None:
|
|
402
|
+
return False, [], []
|
|
403
|
+
|
|
404
|
+
trigger_files: list[str] = []
|
|
405
|
+
missing_files: list[str] = []
|
|
406
|
+
changed_set = set(changed_files)
|
|
407
|
+
|
|
408
|
+
trigger_pattern = rule.pair_config.trigger
|
|
409
|
+
expects_patterns = rule.pair_config.expects
|
|
410
|
+
|
|
411
|
+
for file_path in changed_files:
|
|
412
|
+
# Only check trigger pattern (directional)
|
|
413
|
+
result = match_pattern(trigger_pattern, file_path)
|
|
414
|
+
if result.matched:
|
|
415
|
+
trigger_files.append(file_path)
|
|
416
|
+
|
|
417
|
+
# Check if all expected files also changed
|
|
418
|
+
for expects_pattern in expects_patterns:
|
|
419
|
+
if has_variables(expects_pattern):
|
|
420
|
+
expected = resolve_pattern(expects_pattern, result.variables)
|
|
421
|
+
else:
|
|
422
|
+
expected = expects_pattern
|
|
423
|
+
|
|
424
|
+
if expected not in changed_set:
|
|
425
|
+
if expected not in missing_files:
|
|
426
|
+
missing_files.append(expected)
|
|
427
|
+
|
|
428
|
+
should_fire = len(trigger_files) > 0 and len(missing_files) > 0
|
|
429
|
+
return should_fire, trigger_files, missing_files
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def evaluate_created(
|
|
433
|
+
rule: Rule,
|
|
434
|
+
created_files: list[str],
|
|
435
|
+
) -> bool:
|
|
436
|
+
"""
|
|
437
|
+
Evaluate a created mode rule.
|
|
438
|
+
|
|
439
|
+
Returns True if rule should fire:
|
|
440
|
+
- At least one created file matches a created pattern
|
|
441
|
+
"""
|
|
442
|
+
for file_path in created_files:
|
|
443
|
+
if matches_any_pattern(file_path, rule.created_patterns):
|
|
444
|
+
return True
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass
|
|
449
|
+
class RuleEvaluationResult:
|
|
450
|
+
"""Result of evaluating a single rule."""
|
|
451
|
+
|
|
452
|
+
rule: Rule
|
|
453
|
+
should_fire: bool
|
|
454
|
+
trigger_files: list[str] = field(default_factory=list)
|
|
455
|
+
missing_files: list[str] = field(default_factory=list) # For set/pair modes
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def evaluate_rule(
|
|
459
|
+
rule: Rule,
|
|
460
|
+
changed_files: list[str],
|
|
461
|
+
created_files: list[str] | None = None,
|
|
462
|
+
) -> RuleEvaluationResult:
|
|
463
|
+
"""
|
|
464
|
+
Evaluate whether a rule should fire based on changed files.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
rule: Rule to evaluate
|
|
468
|
+
changed_files: List of changed file paths (relative)
|
|
469
|
+
created_files: List of newly created file paths (relative), for CREATED mode
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
RuleEvaluationResult with evaluation details
|
|
473
|
+
"""
|
|
474
|
+
if rule.detection_mode == DetectionMode.TRIGGER_SAFETY:
|
|
475
|
+
should_fire = evaluate_trigger_safety(rule, changed_files)
|
|
476
|
+
trigger_files = (
|
|
477
|
+
[f for f in changed_files if matches_any_pattern(f, rule.triggers)]
|
|
478
|
+
if should_fire
|
|
479
|
+
else []
|
|
480
|
+
)
|
|
481
|
+
return RuleEvaluationResult(
|
|
482
|
+
rule=rule,
|
|
483
|
+
should_fire=should_fire,
|
|
484
|
+
trigger_files=trigger_files,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
elif rule.detection_mode == DetectionMode.SET:
|
|
488
|
+
should_fire, trigger_files, missing_files = evaluate_set_correspondence(rule, changed_files)
|
|
489
|
+
return RuleEvaluationResult(
|
|
490
|
+
rule=rule,
|
|
491
|
+
should_fire=should_fire,
|
|
492
|
+
trigger_files=trigger_files,
|
|
493
|
+
missing_files=missing_files,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
elif rule.detection_mode == DetectionMode.PAIR:
|
|
497
|
+
should_fire, trigger_files, missing_files = evaluate_pair_correspondence(
|
|
498
|
+
rule, changed_files
|
|
499
|
+
)
|
|
500
|
+
return RuleEvaluationResult(
|
|
501
|
+
rule=rule,
|
|
502
|
+
should_fire=should_fire,
|
|
503
|
+
trigger_files=trigger_files,
|
|
504
|
+
missing_files=missing_files,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
elif rule.detection_mode == DetectionMode.CREATED:
|
|
508
|
+
files_to_check = created_files if created_files is not None else []
|
|
509
|
+
should_fire = evaluate_created(rule, files_to_check)
|
|
510
|
+
trigger_files = (
|
|
511
|
+
[f for f in files_to_check if matches_any_pattern(f, rule.created_patterns)]
|
|
512
|
+
if should_fire
|
|
513
|
+
else []
|
|
514
|
+
)
|
|
515
|
+
return RuleEvaluationResult(
|
|
516
|
+
rule=rule,
|
|
517
|
+
should_fire=should_fire,
|
|
518
|
+
trigger_files=trigger_files,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return RuleEvaluationResult(rule=rule, should_fire=False)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def evaluate_rules(
|
|
525
|
+
rules: list[Rule],
|
|
526
|
+
changed_files: list[str],
|
|
527
|
+
promised_rules: set[str] | None = None,
|
|
528
|
+
created_files: list[str] | None = None,
|
|
529
|
+
) -> list[RuleEvaluationResult]:
|
|
530
|
+
"""
|
|
531
|
+
Evaluate which rules should fire.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
rules: List of rules to evaluate
|
|
535
|
+
changed_files: List of changed file paths (relative)
|
|
536
|
+
promised_rules: Set of rule names that have been marked as addressed
|
|
537
|
+
via <promise> tags (case-insensitive)
|
|
538
|
+
created_files: List of newly created file paths (relative), for CREATED mode
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
List of RuleEvaluationResult for rules that should fire
|
|
542
|
+
"""
|
|
543
|
+
if promised_rules is None:
|
|
544
|
+
promised_rules = set()
|
|
545
|
+
|
|
546
|
+
# Normalize promised names for case-insensitive comparison
|
|
547
|
+
promised_lower = {name.lower() for name in promised_rules}
|
|
548
|
+
|
|
549
|
+
results = []
|
|
550
|
+
for rule in rules:
|
|
551
|
+
# Skip if already promised/addressed (case-insensitive)
|
|
552
|
+
if rule.name.lower() in promised_lower:
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
result = evaluate_rule(rule, changed_files, created_files)
|
|
556
|
+
if result.should_fire:
|
|
557
|
+
results.append(result)
|
|
558
|
+
|
|
559
|
+
return results
|