Skip to content

Add RequiredTaskHeader cop to enforce task annotation blocks in Rake files#8

Closed
nagooen wants to merge 2 commits intomasterfrom
ES-49595_spring_clean_rake_tasks
Closed

Add RequiredTaskHeader cop to enforce task annotation blocks in Rake files#8
nagooen wants to merge 2 commits intomasterfrom
ES-49595_spring_clean_rake_tasks

Conversation

@nagooen
Copy link
Copy Markdown

@nagooen nagooen commented Dec 17, 2025

Jira Ticket

ES-49595

Why are we making this change? Why are we doing it this way?

Introducing a custom rubo-coprule, to help enforce the habit of labelling and spring cleaning rake tasks. Violations will trigger for new rake tasks that do not include the required headers.

A violation will also occur for rake tasks that have the header and the prescribed date has passed.

Valid Examples

Temporary Task (with cleanup date)

# Task: my_task
#   Type: one-off
#   Created: 2025-01-15
#   Ownership: @bettercaring/payments
#   Cleanup Card: ES-123
#   Cleanup Date: 2026-12-31
task :my_task do
end

Indefinite Task (without cleanup date)

# Task: my_task
#   Type: operational
#   Created: 2025-01-15
#   Ownership: @bettercaring/payments
task :my_task do
end

Operational Task with Cleanup Date

# Task: my_task
#   Type: operational
#   Created: 2025-01-15
#   Ownership: @bettercaring/payments
#   Cleanup Card: ES-456
#   Cleanup Date: 2026-12-31
task :my_task do
end

Namespaced Task

namespace :example do
  # Task: example:my_task
  #   Type: operational
  #   Created: 2025-01-15
  #   Ownership: @bettercaring/payments
  task :my_task do
  end
end

…files

This commit introduces a new RuboCop cop, `Mable/RequiredTaskHeader`, which ensures that each task in a Rake file has a corresponding annotation block above it. The cop checks for required fields such as Type, Created, Ownership, Cleanup Card, and Cleanup Date, and validates their formats. Additionally, it includes tests to verify the functionality and correctness of the new cop.
…sk type

This update introduces conditional validation for the 'Cleanup Card' and 'Cleanup Date' fields in the `Mable/RequiredTaskHeader` cop. For 'one-off' tasks, both fields are required and must not be 'Operational'. For 'operational' tasks, these fields are optional but must adhere to specific validation rules if provided. The commit also includes corresponding tests to ensure the new validation logic works as intended.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a custom RuboCop cop Mable/RequiredTaskHeader to enforce documentation standards for Rake tasks by requiring annotation blocks with metadata fields including Type, Created, Ownership, Cleanup Card, and Cleanup Date.

Key Changes:

  • New custom cop with modular architecture (task extraction, annotation parsing, and validation)
  • Comprehensive test coverage with 30+ test scenarios covering valid/invalid annotations
  • Configuration added to default.yml with the cop enabled but non-blocking

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
lib/rubocop/cop/mable/required_task_header/required_task_header.rb Main cop implementation with message constants and file exclusion logic
lib/rubocop/cop/mable/required_task_header/annotation_validator.rb Validation logic for annotation fields including type-specific rules and date expiration checks
lib/rubocop/cop/mable/required_task_header/annotation_parser.rb Parser for extracting annotation blocks and field values from comments
lib/rubocop/cop/mable/required_task_header/task_extractor.rb AST traversal logic for finding tasks and building fully-qualified names with namespaces
lib/rubocop/cop/mable/required_task_header/validation_rules.rb Configuration of validation rules for each required field
lib/rubocop/cop/mable_cops.rb Registration of the new cop in the cops loader
spec/rubocop/cop/mable/required_task_header_spec.rb Comprehensive test suite covering valid annotations, missing fields, invalid values, and edge cases
config/default.yml Cop configuration with description and version information

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +117 to +126
elsif cleanup_date == 'Operational'
example = RequiredTaskHeader::Messages::EXAMPLE_WITH_CLEANUP
context[:cop].add_offense(
context[:node],
message: format("Task '%<task>s' has invalid Cleanup Date. For 'one-off' tasks, it cannot be 'Operational'. " \
"Example:\n%<example>s",
task: context[:full_task_name],
example: example)
)
end
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error message is hardcoded inline rather than using a predefined constant from the Messages module. This is inconsistent with the pattern used elsewhere in the codebase where all other messages use constants like MSG_INVALID_CLEANUP_CARD, MSG_MISSING_CLEANUP_DATE, etc. Consider defining a new constant in the Messages module (e.g., MSG_ONE_OFF_OPERATIONAL_CLEANUP_DATE) for consistency and maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +163
if cleanup_card.is_a?(String) && cleanup_card.strip.empty?
context[:cop].add_offense(
context[:node],
message: format("Task '%<task>s' has invalid Cleanup Card. Must be a card ID or 'Operational'. " \
"Example (with cleanup):\n%<example_with>s\n" \
"Example (without cleanup):\n%<example_without>s",
task: context[:full_task_name],
example_with: RequiredTaskHeader::Messages::EXAMPLE_WITH_CLEANUP,
example_without: RequiredTaskHeader::Messages::EXAMPLE_WITHOUT_CLEANUP)
)
end
end

# If cleanup_date is provided and not "Operational", it must be a valid date
if cleanup_date && cleanup_date != 'Operational'
unless valid_date_format?(cleanup_date)
context[:cop].add_offense(
context[:node],
message: format("Task '%<task>s' has invalid Cleanup Date. Must be YYYY-MM-DD or 'Operational'. " \
"Example (with cleanup):\n%<example_with>s\n" \
"Example (without cleanup):\n%<example_without>s",
task: context[:full_task_name],
example_with: RequiredTaskHeader::Messages::EXAMPLE_WITH_CLEANUP,
example_without: RequiredTaskHeader::Messages::EXAMPLE_WITHOUT_CLEANUP)
)
end
end
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These error messages are hardcoded inline rather than using predefined constants from the Messages module. This is inconsistent with the pattern used elsewhere in the codebase. Consider defining constants in the Messages module for these operational task validation messages to maintain consistency and make the messages easier to update and reuse.

Copilot uses AI. Check for mistakes.
Comment on lines +421 to +447
context 'when one-off task has Operational cleanup card' do
let(:code) do
<<~RUBY
# Task: my_task
# Type: one-off
# Created: 2025-01-15
# Ownership: @bettercaring/payments
# Cleanup Card: Operational
# Cleanup Date: 2025-07-15
task :my_task do
end
RUBY
end

it 'registers an offense for invalid cleanup card' do
expect_offense(<<~RUBY, 'test.rake')
# Task: my_task
# Type: one-off
# Created: 2025-01-15
# Ownership: @bettercaring/payments
# Cleanup Card: Operational
# Cleanup Date: 2025-07-15
task :my_task do
^^^^^^^^^^^^^ Mable/RequiredTaskHeader: Task 'my_task' has invalid Cleanup Card. For 'one-off' tasks, it cannot be 'Operational'
RUBY
end
end
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup date '2025-07-15' in this test has already passed (current date is December 17, 2025). This means the cop will likely trigger two offenses: one for the invalid cleanup card being 'Operational' for a one-off task, and another for the cleanup date having passed. However, the test only expects one offense. Consider using a future date (e.g., '2026-07-15') to avoid triggering the date expiration check, or update the test to expect both offenses.

Copilot uses AI. Check for mistakes.
# Created: 2025-01-15
# Ownership: @bettercaring/payments
# Cleanup Card: ES-123
# Cleanup Date: 2025-07-15
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example cleanup date '2025-07-15' is in the past (current date is December 17, 2025). Using a past date in documentation examples could be confusing since the cop validates that cleanup dates haven't passed. Consider updating to a future date (e.g., '2026-07-15') to provide a more accurate example that won't trigger the expiration validation.

Copilot uses AI. Check for mistakes.
# # Created: 2025-01-15
# # Ownership: @bettercaring/payments
# # Cleanup Card: ES-123
# # Cleanup Date: 2025-07-15
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example cleanup date '2025-07-15' is in the past (current date is December 17, 2025). Consider updating to a future date (e.g., '2026-07-15') to provide a more accurate example that won't trigger the expiration validation in the cop.

Copilot uses AI. Check for mistakes.
Comment on lines +129 to +164
def self.validate_operational_cleanup_fields(context, annotation)
cleanup_card = annotation[:cleanup_card]
cleanup_date = annotation[:cleanup_date]

# For operational tasks, cleanup_card and cleanup_date are optional
# But if provided, they must be valid (either "Operational" or actual values)
# If cleanup_card is provided and not "Operational", it must not be empty
if cleanup_card && cleanup_card != 'Operational'
if cleanup_card.is_a?(String) && cleanup_card.strip.empty?
context[:cop].add_offense(
context[:node],
message: format("Task '%<task>s' has invalid Cleanup Card. Must be a card ID or 'Operational'. " \
"Example (with cleanup):\n%<example_with>s\n" \
"Example (without cleanup):\n%<example_without>s",
task: context[:full_task_name],
example_with: RequiredTaskHeader::Messages::EXAMPLE_WITH_CLEANUP,
example_without: RequiredTaskHeader::Messages::EXAMPLE_WITHOUT_CLEANUP)
)
end
end

# If cleanup_date is provided and not "Operational", it must be a valid date
if cleanup_date && cleanup_date != 'Operational'
unless valid_date_format?(cleanup_date)
context[:cop].add_offense(
context[:node],
message: format("Task '%<task>s' has invalid Cleanup Date. Must be YYYY-MM-DD or 'Operational'. " \
"Example (with cleanup):\n%<example_with>s\n" \
"Example (without cleanup):\n%<example_without>s",
task: context[:full_task_name],
example_with: RequiredTaskHeader::Messages::EXAMPLE_WITH_CLEANUP,
example_without: RequiredTaskHeader::Messages::EXAMPLE_WITHOUT_CLEANUP)
)
end
end
end
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic for operational tasks is incomplete. According to tests at lines 234-286 in the spec file, when an operational task provides one of cleanup_card or cleanup_date, it must provide both. However, this method only validates that provided values are valid - it doesn't check if one field is present without the other. Add logic to verify that if either cleanup_card or cleanup_date is provided (and not 'Operational'), then both must be provided.

Copilot uses AI. Check for mistakes.
@nagooen nagooen requested review from JosephA91 and jywei December 17, 2025 21:56
@nagooen nagooen self-assigned this Dec 17, 2025
@nagooen nagooen added the help wanted Extra attention is needed label Dec 17, 2025
Copy link
Copy Markdown
Contributor

@spheric spheric left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review

Complexity: MEDIUM | Verdict: CHANGES REQUESTED

Found 11 potential issues (0 critical, 3 high, 5 medium, 3 low)

Summary

This PR implements a well-structured RuboCop cop for enforcing task annotation metadata in Rake files. The code follows good separation of concerns by splitting functionality into focused modules (parser, validator, extractor, rules).

Key Issues Requiring Changes

  1. Control Flow Logic (High): The validate_field method's boolean return values and the next if usage create confusing control flow that could lead to missed validations.

  2. Duplicate Validation Logic (High): The "Operational" value handling for cleanup_date exists in both skip_validation? and the validator lambda, creating potential inconsistency.

  3. File Loading Pattern (Medium): require_relative inside the class body is non-idiomatic and fragile.

Positive Aspects

  • Clean module separation following Single Responsibility Principle
  • Good use of frozen string literals
  • Comprehensive message formatting with examples
  • Proper namespace handling for nested rake tasks

🤖 Reviewed by AI Code Review Agent

end

def self.validate_fields(node, full_task_name, annotation, cop)
context = { node: node, full_task_name: full_task_name, cop: cop, annotation: annotation }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High - Logic Bug

The validate_fields method iterates through VALIDATION_RULES but uses next if validate_field(...). The return value logic appears inverted - validate_field returns true when there's an error (missing field) and false otherwise. This means it skips to next field on error instead of continuing validation.

Suggested fix: Review the boolean logic. If check_missing_field returns true when field is missing and offense was added, the next if will skip remaining validation for that field - which may be intentional. Add comments to clarify intent, or restructure to make control flow clearer.


context[:cop].add_offense(
context[:node],
message: format(rules[:missing_msg], task: context[:full_task_name])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High - Logic Bug

The skip_validation? method returns true when field == :cleanup_date && value == 'Operational', but this logic is also duplicated in the validator lambda in validation_rules.rb. This creates confusing dual validation paths.

Suggested fix: Consolidate the 'Operational' value handling in one place. Either handle it entirely in the validator lambda or entirely in skip_validation?, not both.


def self.collect_annotation_lines(task_line, processed_source)
lines = processed_source.raw_source.lines
annotation_lines = []
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High - Logic Bug

Off-by-one edge case: The loop (task_line - 1).downto(1) never processes line index 0 since it stops at 1. If a task is on line 1, the method returns nil prematurely at line 11.

Suggested fix: Add a guard for task_line == 1 case explicitly, or document this as expected behavior. Consider if tasks on line 2 with annotation on line 1 are handled correctly.

require_relative 'validation_rules'
require_relative 'annotation_validator'

def on_new_investigation
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Code Quality

Using require_relative inside the class definition after Messages module is defined creates a tight coupling between file load order and class constant availability. This is fragile and non-idiomatic Ruby.

Suggested fix: Move all require_relative statements to the top of the file after frozen_string_literal. Define Messages in a separate file if needed, or restructure to avoid circular dependency issues.

module Mable
class RequiredTaskHeader
module AnnotationValidator
def self.validate(node, full_task_name, annotation, cop)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Code Quality

The validate method receives cop as a parameter and passes it through a context hash. This creates tight coupling and makes the module difficult to test in isolation.

Suggested fix: Consider returning a collection of validation errors/offenses instead of directly calling cop.add_offense. This would make the validator testable in isolation and follow better separation of concerns.

if task_type == 'one-off'
validate_one_off_cleanup_fields(context, annotation)
elsif task_type == 'operational'
validate_operational_cleanup_fields(context, annotation)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Code Quality

The check cleanup_card.is_a?(String) && cleanup_card.strip.empty? is redundant since cleanup_card from the parsed annotation will always be a String (from match[1].strip in annotation_parser.rb). The strip is also redundant as values are already stripped during parsing.

Suggested fix: Simplify to cleanup_card.empty? since values are already stripped strings or nil.


annotation_lines
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Performance

processed_source.raw_source.lines is called once per task when collect_annotation_lines is invoked. For files with many tasks, this repeatedly allocates the same array of lines.

Suggested fix: Cache the lines array at the investigation level (in on_new_investigation) and pass it through, or use processed_source.lines if available.

next unless task_name

full_task_name = build_full_task_name(node, task_name)
tasks[full_task_name] = node unless tasks.key?(full_task_name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Logic Bug

The check tasks.key?(full_task_name) prevents duplicate task names from being processed, but silently ignores duplicates. If a file has duplicate task definitions (which is likely a bug), users won't be warned.

Suggested fix: Consider adding a warning or processing all tasks with the same name, not just the first one found.

return false if annotation_lines.empty?

annotation_lines.any? { |l| annotation_start?(l.strip) }
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low - Code Quality

The regex pattern ^#\\s+ requires one or more spaces after # for Type, Created, etc., but ^#\\s* allows zero or more for Task. This inconsistency could cause valid annotations to not be parsed if indentation varies.

Suggested fix: Consider using consistent patterns or documenting the required format clearly.

Description: "Enforces that each task in a Rake file has an annotation block directly above it with required metadata (Type, Created, Ownership, Cleanup Card, Cleanup Date)"
Enabled: true
SafeAutoCorrect: false
VersionAdded: "0.1.9" No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low - Code Quality

File is missing newline at end of file.

Suggested fix: Add a newline at the end of the file to follow POSIX conventions and avoid diff noise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Connectors help wanted Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants