diff --git a/.deepwork/jobs/deepwork_rules/rules/skill-md-validation.md b/.deepwork/jobs/deepwork_rules/rules/skill-md-validation.md index 6f7cb3d2..38f90c51 100644 --- a/.deepwork/jobs/deepwork_rules/rules/skill-md-validation.md +++ b/.deepwork/jobs/deepwork_rules/rules/skill-md-validation.md @@ -1,6 +1,7 @@ --- name: SKILL.md Validation trigger: "**/SKILL.md" +compare_to: base --- A SKILL.md file has been created or modified. Please validate that it follows the required format: diff --git a/.deepwork/rules/architecture-documentation-accuracy.md b/.deepwork/rules/architecture-documentation-accuracy.md index 42f74f88..91798109 100644 --- a/.deepwork/rules/architecture-documentation-accuracy.md +++ b/.deepwork/rules/architecture-documentation-accuracy.md @@ -2,6 +2,7 @@ name: Architecture Documentation Accuracy trigger: src/**/* safety: doc/architecture.md +compare_to: base --- Source code in src/ has been modified. Please review doc/architecture.md for accuracy: 1. Verify the documented architecture matches the current implementation diff --git a/.deepwork/rules/readme-accuracy.md b/.deepwork/rules/readme-accuracy.md index 8284142b..9e75c596 100644 --- a/.deepwork/rules/readme-accuracy.md +++ b/.deepwork/rules/readme-accuracy.md @@ -2,6 +2,7 @@ name: README Accuracy trigger: src/**/* safety: README.md +compare_to: base --- Source code in src/ has been modified. Please review README.md for accuracy: 1. Verify project overview still reflects current functionality diff --git a/.deepwork/rules/standard-jobs-source-of-truth.md b/.deepwork/rules/standard-jobs-source-of-truth.md index 3698489d..2d0092c9 100644 --- a/.deepwork/rules/standard-jobs-source-of-truth.md +++ b/.deepwork/rules/standard-jobs-source-of-truth.md @@ -6,6 +6,7 @@ trigger: safety: - src/deepwork/standard_jobs/deepwork_jobs/**/* - src/deepwork/standard_jobs/deepwork_rules/**/* +compare_to: base --- You modified files in `.deepwork/jobs/deepwork_jobs/` or `.deepwork/jobs/deepwork_rules/`. diff --git a/.deepwork/rules/version-and-changelog-update.md b/.deepwork/rules/version-and-changelog-update.md index 58e35088..ac617f8e 100644 --- a/.deepwork/rules/version-and-changelog-update.md +++ b/.deepwork/rules/version-and-changelog-update.md @@ -4,6 +4,7 @@ trigger: src/**/* safety: - pyproject.toml - CHANGELOG.md +compare_to: base --- Source code in src/ has been modified. **You MUST evaluate whether version and changelog updates are needed.** diff --git a/doc/rules_syntax.md b/doc/rules_syntax.md index f4c3ae83..7680bfd6 100644 --- a/doc/rules_syntax.md +++ b/doc/rules_syntax.md @@ -36,6 +36,7 @@ class AuthService: name: README Accuracy trigger: src/**/* safety: README.md +compare_to: base --- Source code changed. Please verify README.md is accurate. @@ -54,6 +55,7 @@ name: Source/Test Pairing set: - src/{path}.py - tests/{path}_test.py +compare_to: base --- Source and test files should change together. @@ -70,6 +72,7 @@ name: API Documentation pair: trigger: api/{path}.py expects: docs/api/{path}.md +compare_to: base --- API changes require documentation updates. @@ -88,6 +91,7 @@ name: Python Formatting trigger: "**/*.py" action: command: ruff format {file} +compare_to: prompt --- Automatically formats Python files using ruff. @@ -145,6 +149,7 @@ name: Source/Test Pairing set: - src/{path}.py - tests/{path}_test.py +compare_to: base --- ``` @@ -176,6 +181,7 @@ name: API Documentation pair: trigger: api/{module}/{name}.py expects: docs/api/{module}/{name}.md +compare_to: base --- ``` @@ -183,11 +189,13 @@ Can specify multiple expected patterns: ```yaml --- +name: API Documentation pair: trigger: api/{path}.py expects: - docs/api/{path}.md - schemas/{path}.json +compare_to: base --- ``` @@ -224,6 +232,7 @@ safety: "*.pyi" action: command: ruff format {file} run_for: each_match +compare_to: prompt --- ``` @@ -385,19 +394,19 @@ action: --- ``` -### compare_to (optional) +### compare_to (required) Determines the baseline for detecting file changes. | Value | Description | |-------|-------------| -| `base` (default) | Compare to merge-base with default branch | +| `base` | Compare to merge-base with default branch | | `default_tip` | Compare to current tip of default branch | | `prompt` | Compare to state at last prompt submission | ```yaml --- -compare_to: prompt +compare_to: base --- ``` @@ -412,6 +421,7 @@ name: Test Coverage set: - src/{path}.py - tests/{path}_test.py +compare_to: base --- Source code was modified without corresponding test updates. @@ -434,6 +444,7 @@ pair: expects: - docs/api/{module}/{endpoint}.md - openapi/{module}.yaml +compare_to: base --- API endpoint changed. Please update: - Documentation: {expected_files} @@ -453,6 +464,7 @@ safety: action: command: black {file} run_for: each_match +compare_to: prompt --- Formats Python files using Black. @@ -472,6 +484,7 @@ set: - backend/api/{feature}/models.py - frontend/src/api/{feature}.ts - frontend/src/components/{feature}/**/* +compare_to: base --- Feature files should be updated together across the stack. @@ -494,6 +507,7 @@ trigger: safety: - pyproject.toml - CHANGELOG.md +compare_to: base --- Code changes detected. Before merging, ensure: - Version is bumped in pyproject.toml (if needed) diff --git a/src/deepwork/core/rules_parser.py b/src/deepwork/core/rules_parser.py index 1de83a6c..c9bd6f00 100644 --- a/src/deepwork/core/rules_parser.py +++ b/src/deepwork/core/rules_parser.py @@ -40,7 +40,6 @@ class ActionType(Enum): # Valid compare_to values COMPARE_TO_VALUES = frozenset({"base", "default_tip", "prompt"}) -DEFAULT_COMPARE_TO = "base" @dataclass @@ -69,6 +68,11 @@ class Rule: # Detection mode (exactly one must be set) detection_mode: DetectionMode + + # Common options (required) + compare_to: str # Required: "base", "default_tip", or "prompt" + + # Detection mode details (optional, depends on mode) triggers: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode safety: list[str] = field(default_factory=list) # For TRIGGER_SAFETY mode set_patterns: list[str] = field(default_factory=list) # For SET mode @@ -79,9 +83,6 @@ class Rule: instructions: str = "" # For PROMPT action (markdown body) command_action: CommandAction | None = None # For COMMAND action - # Common options - compare_to: str = DEFAULT_COMPARE_TO - @classmethod def from_frontmatter( cls, @@ -166,8 +167,8 @@ def from_frontmatter( if not markdown_body.strip(): raise RulesParseError(f"Rule '{name}' with prompt action requires markdown body") - # Get compare_to - compare_to = frontmatter.get("compare_to", DEFAULT_COMPARE_TO) + # Get compare_to (required field) + compare_to = frontmatter["compare_to"] return cls( name=name, diff --git a/src/deepwork/schemas/rules_schema.py b/src/deepwork/schemas/rules_schema.py index 3112dd0f..b9674eb0 100644 --- a/src/deepwork/schemas/rules_schema.py +++ b/src/deepwork/schemas/rules_schema.py @@ -15,7 +15,7 @@ RULES_FRONTMATTER_SCHEMA: dict[str, Any] = { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "required": ["name"], + "required": ["name", "compare_to"], "properties": { "name": { "type": "string", @@ -80,7 +80,6 @@ "compare_to": { "type": "string", "enum": ["base", "default_tip", "prompt"], - "default": "base", "description": "Baseline for detecting file changes", }, }, diff --git a/src/deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md b/src/deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md index 6f7cb3d2..38f90c51 100644 --- a/src/deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md +++ b/src/deepwork/standard_jobs/deepwork_rules/rules/skill-md-validation.md @@ -1,6 +1,7 @@ --- name: SKILL.md Validation trigger: "**/SKILL.md" +compare_to: base --- A SKILL.md file has been created or modified. Please validate that it follows the required format: diff --git a/tests/unit/test_rules_parser.py b/tests/unit/test_rules_parser.py index 4aedea67..cabb8e3f 100644 --- a/tests/unit/test_rules_parser.py +++ b/tests/unit/test_rules_parser.py @@ -58,6 +58,7 @@ def test_fires_when_trigger_matches(self) -> None: triggers=["src/**/*.py"], safety=[], instructions="Check it", + compare_to="base", ) changed_files = ["src/main.py", "README.md"] @@ -73,6 +74,7 @@ def test_does_not_fire_when_no_trigger_match(self) -> None: triggers=["src/**/*.py"], safety=[], instructions="Check it", + compare_to="base", ) changed_files = ["test/main.py", "README.md"] @@ -88,6 +90,7 @@ def test_does_not_fire_when_safety_matches(self) -> None: triggers=["app/config/**/*"], safety=["docs/install_guide.md"], instructions="Update docs", + compare_to="base", ) changed_files = ["app/config/settings.py", "docs/install_guide.md"] @@ -103,6 +106,7 @@ def test_fires_when_trigger_matches_but_safety_doesnt(self) -> None: triggers=["app/config/**/*"], safety=["docs/install_guide.md"], instructions="Update docs", + compare_to="base", ) changed_files = ["app/config/settings.py", "app/main.py"] @@ -118,6 +122,7 @@ def test_multiple_safety_patterns(self) -> None: triggers=["src/auth/**/*"], safety=["SECURITY.md", "docs/security_review.md"], instructions="Security review", + compare_to="base", ) # Should not fire if any safety file is changed @@ -144,6 +149,7 @@ def test_returns_fired_rules(self) -> None: triggers=["src/**/*"], safety=[], instructions="Do 1", + compare_to="base", ), Rule( name="Rule 2", @@ -152,6 +158,7 @@ def test_returns_fired_rules(self) -> None: triggers=["test/**/*"], safety=[], instructions="Do 2", + compare_to="base", ), ] changed_files = ["src/main.py", "test/test_main.py"] @@ -172,6 +179,7 @@ def test_skips_promised_rules(self) -> None: triggers=["src/**/*"], safety=[], instructions="Do 1", + compare_to="base", ), Rule( name="Rule 2", @@ -180,6 +188,7 @@ def test_skips_promised_rules(self) -> None: triggers=["src/**/*"], safety=[], instructions="Do 2", + compare_to="base", ), ] changed_files = ["src/main.py"] @@ -200,6 +209,7 @@ def test_returns_empty_when_no_rules_fire(self) -> None: triggers=["src/**/*"], safety=[], instructions="Do 1", + compare_to="base", ), ] changed_files = ["test/test_main.py"] @@ -223,6 +233,7 @@ def test_loads_rules_from_directory(self, temp_dir: Path) -> None: """--- name: Test Rule trigger: "src/**/*" +compare_to: base --- Please check the source files. """ @@ -246,6 +257,7 @@ def test_loads_multiple_rules(self, temp_dir: Path) -> None: """--- name: Rule 1 trigger: "src/**/*" +compare_to: base --- Instructions for rule 1. """ @@ -254,6 +266,7 @@ def test_loads_multiple_rules(self, temp_dir: Path) -> None: """--- name: Rule 2 trigger: "test/**/*" +compare_to: base --- Instructions for rule 2. """ @@ -294,6 +307,7 @@ def test_loads_rule_with_set_detection_mode(self, temp_dir: Path) -> None: set: - src/{path}.py - tests/{path}_test.py +compare_to: base --- Source and test files should change together. """ @@ -318,6 +332,7 @@ def test_loads_rule_with_pair_detection_mode(self, temp_dir: Path) -> None: pair: trigger: src/api/{name}.py expects: docs/api/{name}.md +compare_to: base --- API code requires documentation. """ @@ -345,6 +360,7 @@ def test_loads_rule_with_command_action(self, temp_dir: Path) -> None: action: command: "ruff format {file}" run_for: each_match +compare_to: prompt --- """ ) @@ -372,6 +388,7 @@ def test_both_changed_no_fire(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["src/foo.py", "tests/foo_test.py"] @@ -386,6 +403,7 @@ def test_only_source_fires(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["src/foo.py"] @@ -402,6 +420,7 @@ def test_only_test_fires(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update source", + compare_to="base", ) changed_files = ["tests/foo_test.py"] @@ -418,6 +437,7 @@ def test_nested_both_no_fire(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["src/a/b.py", "tests/a/b_test.py"] @@ -432,6 +452,7 @@ def test_nested_only_source_fires(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["src/a/b.py"] @@ -447,6 +468,7 @@ def test_unrelated_file_no_fire(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["docs/readme.md"] @@ -461,6 +483,7 @@ def test_source_plus_unrelated_fires(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["src/foo.py", "docs/readme.md"] @@ -475,6 +498,7 @@ def test_both_plus_unrelated_no_fire(self) -> None: detection_mode=DetectionMode.SET, set_patterns=["src/{path}.py", "tests/{path}_test.py"], instructions="Update tests", + compare_to="base", ) changed_files = ["src/foo.py", "tests/foo_test.py", "docs/readme.md"] @@ -497,6 +521,7 @@ def test_all_three_no_fire(self) -> None: "migrations/{name}.sql", ], instructions="Update all related files", + compare_to="base", ) changed_files = ["models/user.py", "schemas/user.py", "migrations/user.sql"] @@ -515,6 +540,7 @@ def test_two_of_three_fires(self) -> None: "migrations/{name}.sql", ], instructions="Update all related files", + compare_to="base", ) changed_files = ["models/user.py", "schemas/user.py"] @@ -534,6 +560,7 @@ def test_one_of_three_fires(self) -> None: "migrations/{name}.sql", ], instructions="Update all related files", + compare_to="base", ) changed_files = ["models/user.py"] @@ -555,6 +582,7 @@ def test_different_names_fire_both(self) -> None: "migrations/{name}.sql", ], instructions="Update all related files", + compare_to="base", ) changed_files = ["models/user.py", "schemas/order.py"] @@ -580,6 +608,7 @@ def test_both_changed_no_fire(self) -> None: expects=["docs/api/{path}.md"], ), instructions="Update API docs", + compare_to="base", ) changed_files = ["api/users.py", "docs/api/users.md"] @@ -597,6 +626,7 @@ def test_only_trigger_fires(self) -> None: expects=["docs/api/{path}.md"], ), instructions="Update API docs", + compare_to="base", ) changed_files = ["api/users.py"] @@ -616,6 +646,7 @@ def test_only_expected_no_fire(self) -> None: expects=["docs/api/{path}.md"], ), instructions="Update API docs", + compare_to="base", ) changed_files = ["docs/api/users.md"] @@ -633,6 +664,7 @@ def test_trigger_plus_unrelated_fires(self) -> None: expects=["docs/api/{path}.md"], ), instructions="Update API docs", + compare_to="base", ) changed_files = ["api/users.py", "README.md"] @@ -650,6 +682,7 @@ def test_expected_plus_unrelated_no_fire(self) -> None: expects=["docs/api/{path}.md"], ), instructions="Update API docs", + compare_to="base", ) changed_files = ["docs/api/users.md", "README.md"] @@ -671,6 +704,7 @@ def test_all_three_no_fire(self) -> None: expects=["docs/api/{path}.md", "openapi/{path}.yaml"], ), instructions="Update API docs and OpenAPI", + compare_to="base", ) changed_files = ["api/users.py", "docs/api/users.md", "openapi/users.yaml"] @@ -688,6 +722,7 @@ def test_trigger_plus_one_expect_fires(self) -> None: expects=["docs/api/{path}.md", "openapi/{path}.yaml"], ), instructions="Update API docs and OpenAPI", + compare_to="base", ) changed_files = ["api/users.py", "docs/api/users.md"] @@ -706,6 +741,7 @@ def test_only_trigger_fires_missing_both(self) -> None: expects=["docs/api/{path}.md", "openapi/{path}.yaml"], ), instructions="Update API docs and OpenAPI", + compare_to="base", ) changed_files = ["api/users.py"] @@ -726,6 +762,7 @@ def test_both_expects_only_no_fire(self) -> None: expects=["docs/api/{path}.md", "openapi/{path}.yaml"], ), instructions="Update API docs and OpenAPI", + compare_to="base", ) changed_files = ["docs/api/users.md", "openapi/users.yaml"] diff --git a/tests/unit/test_schema_validation.py b/tests/unit/test_schema_validation.py index fc921ec8..c77fc7a0 100644 --- a/tests/unit/test_schema_validation.py +++ b/tests/unit/test_schema_validation.py @@ -16,6 +16,7 @@ def test_missing_name(self, tmp_path: Path) -> None: rule_file.write_text( """--- trigger: "src/**/*" +compare_to: base --- Instructions here. """ @@ -30,6 +31,7 @@ def test_missing_detection_mode(self, tmp_path: Path) -> None: rule_file.write_text( """--- name: Test Rule +compare_to: base --- Instructions here. """ @@ -38,6 +40,21 @@ def test_missing_detection_mode(self, tmp_path: Path) -> None: with pytest.raises(RulesParseError): parse_rule_file(rule_file) + def test_missing_compare_to(self, tmp_path: Path) -> None: + """SV-8.1.5: Missing compare_to field.""" + rule_file = tmp_path / "test.md" + rule_file.write_text( + """--- +name: Test Rule +trigger: "src/**/*" +--- +Instructions here. +""" + ) + + with pytest.raises(RulesParseError, match="compare_to"): + parse_rule_file(rule_file) + def test_missing_markdown_body(self, tmp_path: Path) -> None: """SV-8.1.3: Missing markdown body (for prompt action).""" rule_file = tmp_path / "test.md" @@ -45,6 +62,7 @@ def test_missing_markdown_body(self, tmp_path: Path) -> None: """--- name: Test Rule trigger: "src/**/*" +compare_to: base --- """ ) @@ -63,6 +81,7 @@ def test_set_requires_two_patterns(self, tmp_path: Path) -> None: name: Test Rule set: - src/{path}.py +compare_to: base --- Instructions here. """ @@ -86,6 +105,7 @@ def test_both_trigger_and_set(self, tmp_path: Path) -> None: set: - src/{path}.py - tests/{path}_test.py +compare_to: base --- Instructions here. """ @@ -104,6 +124,7 @@ def test_both_trigger_and_pair(self, tmp_path: Path) -> None: pair: trigger: api/{path}.py expects: docs/{path}.md +compare_to: base --- Instructions here. """ @@ -125,6 +146,7 @@ def test_all_detection_modes(self, tmp_path: Path) -> None: pair: trigger: api/{path}.py expects: docs/{path}.md +compare_to: base --- Instructions here. """ @@ -163,6 +185,7 @@ def test_invalid_run_for(self, tmp_path: Path) -> None: action: command: "ruff format {file}" run_for: first_match +compare_to: prompt --- """ ) @@ -182,6 +205,7 @@ def test_valid_trigger_safety_rule(self, tmp_path: Path) -> None: name: Test Rule trigger: "src/**/*" safety: README.md +compare_to: base --- Please check the code. """ @@ -191,6 +215,7 @@ def test_valid_trigger_safety_rule(self, tmp_path: Path) -> None: assert rule.name == "Test Rule" assert rule.triggers == ["src/**/*"] assert rule.safety == ["README.md"] + assert rule.compare_to == "base" def test_valid_set_rule(self, tmp_path: Path) -> None: """Valid set rule parses successfully.""" @@ -201,6 +226,7 @@ def test_valid_set_rule(self, tmp_path: Path) -> None: set: - src/{path}.py - tests/{path}_test.py +compare_to: base --- Source and test should change together. """ @@ -209,6 +235,7 @@ def test_valid_set_rule(self, tmp_path: Path) -> None: rule = parse_rule_file(rule_file) assert rule.name == "Source/Test Pairing" assert len(rule.set_patterns) == 2 + assert rule.compare_to == "base" def test_valid_pair_rule(self, tmp_path: Path) -> None: """Valid pair rule parses successfully.""" @@ -219,6 +246,7 @@ def test_valid_pair_rule(self, tmp_path: Path) -> None: pair: trigger: api/{module}.py expects: docs/api/{module}.md +compare_to: base --- API changes need documentation. """ @@ -229,6 +257,7 @@ def test_valid_pair_rule(self, tmp_path: Path) -> None: assert rule.pair_config is not None assert rule.pair_config.trigger == "api/{module}.py" assert rule.pair_config.expects == ["docs/api/{module}.md"] + assert rule.compare_to == "base" def test_valid_command_rule(self, tmp_path: Path) -> None: """Valid command rule parses successfully.""" @@ -240,6 +269,7 @@ def test_valid_command_rule(self, tmp_path: Path) -> None: action: command: "ruff format {file}" run_for: each_match +compare_to: prompt --- """ ) @@ -249,6 +279,7 @@ def test_valid_command_rule(self, tmp_path: Path) -> None: assert rule.command_action is not None assert rule.command_action.command == "ruff format {file}" assert rule.command_action.run_for == "each_match" + assert rule.compare_to == "prompt" def test_valid_compare_to_values(self, tmp_path: Path) -> None: """Valid compare_to values parse successfully.""" @@ -276,6 +307,7 @@ def test_multiple_triggers(self, tmp_path: Path) -> None: trigger: - src/**/*.py - lib/**/*.py +compare_to: base --- Instructions here. """ @@ -283,6 +315,7 @@ def test_multiple_triggers(self, tmp_path: Path) -> None: rule = parse_rule_file(rule_file) assert rule.triggers == ["src/**/*.py", "lib/**/*.py"] + assert rule.compare_to == "base" def test_multiple_safety_patterns(self, tmp_path: Path) -> None: """Multiple safety patterns as array parses successfully.""" @@ -294,6 +327,7 @@ def test_multiple_safety_patterns(self, tmp_path: Path) -> None: safety: - README.md - CHANGELOG.md +compare_to: base --- Instructions here. """ @@ -301,6 +335,7 @@ def test_multiple_safety_patterns(self, tmp_path: Path) -> None: rule = parse_rule_file(rule_file) assert rule.safety == ["README.md", "CHANGELOG.md"] + assert rule.compare_to == "base" def test_multiple_expects(self, tmp_path: Path) -> None: """Multiple expects patterns parses successfully.""" @@ -313,6 +348,7 @@ def test_multiple_expects(self, tmp_path: Path) -> None: expects: - docs/api/{module}.md - openapi/{module}.yaml +compare_to: base --- Instructions here. """ @@ -321,3 +357,4 @@ def test_multiple_expects(self, tmp_path: Path) -> None: rule = parse_rule_file(rule_file) assert rule.pair_config is not None assert rule.pair_config.expects == ["docs/api/{module}.md", "openapi/{module}.yaml"] + assert rule.compare_to == "base"