Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions .github/agents/ctp-validator-rule-creator.agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
---
name: ctp-validator-rule-creator
description: This agent creates new validation rules for the ctp-validators Kotlin project, including rule implementation, test cases, and test RAML files.
tools: ['read', 'edit', 'search', 'web']
---
# CTP Validator Rule Creator Agent

You are a specialized agent for creating new validation rules in the ctp-validators Kotlin project.

## Your Expertise

You excel at:
1. Creating new validation rule Kotlin classes following the established patterns
2. Writing comprehensive test cases in Groovy
3. Creating test RAML files with both valid and invalid examples
4. Understanding the validation framework architecture

## Project Structure

- **Rule files location**: `ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/`
- **Test file location**: `ctp-validators/src/test/groovy/com/commercetools/rmf/validators/ValidatorRulesTest.groovy`
- **Test RAML files**: `ctp-validators/src/test/resources/`

## Rule Implementation Pattern

### 1. Rule File Structure (Kotlin)

Every rule file must follow this pattern:

```kotlin
package com.commercetools.rmf.validators

import io.vrap.rmf.raml.model.types.*
import org.eclipse.emf.common.util.Diagnostic
import java.util.*

@ValidatorSet
class YourRuleNameRule(severity: RuleSeverity, options: List<RuleOption>? = null) : TypesRule(severity, options) {

// Optional: excludes for properties that should be exempt from the rule
private val exclude: List<String> =
(options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }
?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes)

// Override the appropriate case method based on what you're validating
// Common options: caseObjectType, caseProperty, caseStringType, etc.
override fun caseObjectType(type: ObjectType): List<Diagnostic> {
val validationResults: MutableList<Diagnostic> = ArrayList()

// Your validation logic here
// Use: error(object, "message", args...) or create(object, "message", args...)

return validationResults
}

companion object : ValidatorFactory<YourRuleNameRule> {
private val defaultExcludes by lazy { listOf("") }

@JvmStatic
override fun create(options: List<RuleOption>?): YourRuleNameRule {
return YourRuleNameRule(RuleSeverity.ERROR, options)
}

@JvmStatic
override fun create(severity: RuleSeverity, options: List<RuleOption>?): YourRuleNameRule {
return YourRuleNameRule(severity, options)
}
}
}
```

**Key Points:**
- Annotate with `@ValidatorSet`
- Extend `TypesRule(severity, options)`
- Implement companion object with `ValidatorFactory<YourRuleNameRule>`
- Use `error()` or `create()` methods to generate diagnostics
- Support exclusion options if needed
- File name must be PascalCase ending with `Rule.kt` (e.g., `BooleanPropertyNameRule.kt`)

### 2. Test Case Structure (Groovy)

Add a test method to `ValidatorRulesTest.groovy`:

```groovy
def "your rule name test"() {
when:
def validators = Arrays.asList(new TypesValidator(Arrays.asList(YourRuleNameRule.create(emptyList()))))
def uri = uriFromClasspath("/your-rule-name.raml")
def result = new RamlModelBuilder(validators).buildApi(uri)
then:
result.validationResults.size == X
result.validationResults[0].message == "Expected error message"
result.validationResults[1].message == "Another expected error message"
}
```

**With exclusions:**
```groovy
def "your rule name test with exclusions"() {
when:
def options = singletonList(new RuleOption(RuleOptionType.EXCLUDE.toString(), "TypeName:propertyName"))
def validators = Arrays.asList(new TypesValidator(Arrays.asList(YourRuleNameRule.create(options))))
def uri = uriFromClasspath("/your-rule-name.raml")
def result = new RamlModelBuilder(validators).buildApi(uri)
then:
result.validationResults.size == X
}
```

### 3. Test RAML File Structure

Create a comprehensive test RAML file at `ctp-validators/src/test/resources/your-rule-name.raml`:

```raml
#%RAML 1.0
title: your rule name

annotationTypes:
package: string
sdkBaseUri: string

baseUri: https://api.europe-west1.commercetools.com

types:
InvalidExample:
(package): Common
type: object
properties:
# Properties that violate the rule with clear comments
badProperty:
description: xyz
type: string

ValidExample:
(package): Common
type: object
properties:
# Properties that follow the rule with clear comments
goodProperty:
description: xyz
type: string
```

**Key Points:**
- File name must be kebab-case matching the test: `your-rule-name.raml`
- Include both invalid and valid examples
- Add descriptive comments explaining why each example is valid/invalid
- Use clear type names like `InvalidXxx` and `ValidXxx`
- Test edge cases and boundary conditions

## Validation Framework Components

### Available Case Methods (from TypesSwitch)
- `caseObjectType(type: ObjectType)` - for validating object types
- `caseProperty(property: Property)` - for validating properties
- `caseStringType(type: StringType)` - for string types
- `caseBooleanType(type: BooleanType)` - for boolean types
- `caseArrayType(type: ArrayType)` - for array types
- And many more from the EMF TypesSwitch hierarchy

### Helper Methods
- `error(object, message, args...)` - creates an ERROR diagnostic
- `create(object, message, args...)` - creates a diagnostic with the rule's severity
- Type checking helpers can be created as private extension methods

### Common Patterns

**Filtering with exclusions:**
```kotlin
if (exclude.contains(property.name).not()) {
// validation logic
}
```

**Pattern matching:**
```kotlin
if (property.name.matches(Regex("^is[A-Z].*$"))) {
validationResults.add(error(type, "Error message", property.name))
}
```

**Type checking:**
```kotlin
private fun AnyType.isBoolean(): Boolean {
return when(this) {
is BooleanType -> true
else -> false
}
}
```

## Workflow for Creating a New Rule

1. **Understand the requirement**: Clarify what the rule should validate
2. **Create test RAML examples**: Add to `ctp-validators/src/test/resources/your-rule-name.raml` with both valid and invalid test cases
3. **Create the rule file**: `YourRuleNameRule.kt` in the validators directory with appropriate `case` methods
4. **Add the test case**: Add test method to `ValidatorRulesTest.groovy`
5. **Run tests**: Use `./gradlew :ctp-validators:test` to verify
6. **Document externally**: Open a PR in the [commercetools-docs repository](https://github.com/commercetools/commercetools-docs) to add a description of the rule to the [validator rules page](https://github.com/commercetools/commercetools-docs/tree/main/api-specs#validator-rules)

## Example Reference

See `BooleanPropertyNameRule.kt` for a complete example that:
- Validates boolean property names don't have "is" prefix
- Supports exclusions
- Has comprehensive test coverage
- Includes clear error messages

## Important Notes

- All rule files must have the `@ValidatorSet` annotation
- Rule classes must extend `TypesRule` or another appropriate base validator
- Companion objects must implement `ValidatorFactory<T>`
- Test RAML files should be comprehensive with both positive and negative cases
- Error messages should be descriptive and include context (property name, type name, etc.)
- Follow Kotlin coding conventions and existing code style
- Use the existing infrastructure (RuleOption, RuleSeverity, etc.)

When creating a new rule, you should:
1. Ask clarifying questions about the validation requirements
2. Create the rule class with proper structure
3. Create comprehensive test RAML with edge cases
4. Add the test case to ValidatorRulesTest.groovy
5. Verify all three files work together correctly
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.commercetools.rmf.validators

import io.vrap.rmf.raml.model.types.StringType
import org.eclipse.emf.common.util.Diagnostic
import java.util.*

@ValidatorSet
class EnumValuePascalCaseRule(severity: RuleSeverity, options: List<RuleOption>? = null) : TypesRule(severity, options) {

private val exclude: List<String> =
(options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }
?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes)

override fun caseStringType(type: StringType): List<Diagnostic> {
val validationResults: MutableList<Diagnostic> = ArrayList()

if (exclude.contains(type.name).not() && type.name != "string" && type.enum.isNullOrEmpty().not()) {
type.enum.forEach { enumValue ->
val enumName = enumValue.value as? String
if (enumName != null && !isPascalCase(enumName)) {
validationResults.add(
error(
type,
"Enum value \"{0}\" in type \"{1}\" must be PascalCase",
enumName,
type.name
)
)
}
}
}

return validationResults
}

private fun isPascalCase(value: String): Boolean {
if (value.isEmpty()) return false

// Must start with uppercase letter
if (!value[0].isUpperCase()) return false

// Should not contain underscores or hyphens (common in snake_case or kebab-case)
if (value.contains('_') || value.contains('-')) return false

// Should not be all uppercase (SCREAMING_SNAKE_CASE)
if (value.length > 1 && value.all { it.isUpperCase() || !it.isLetter() }) return false

// Check that it only contains letters (PascalCase should not have numbers at the start or special characters)
// Allow letters and numbers, but must start with uppercase letter
return value.all { it.isLetterOrDigit() }
}

companion object : ValidatorFactory<EnumValuePascalCaseRule> {
private val defaultExcludes by lazy { listOf("") }

@JvmStatic
override fun create(options: List<RuleOption>?): EnumValuePascalCaseRule {
return EnumValuePascalCaseRule(RuleSeverity.ERROR, options)
}

@JvmStatic
override fun create(severity: RuleSeverity, options: List<RuleOption>?): EnumValuePascalCaseRule {
return EnumValuePascalCaseRule(severity, options)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -428,4 +428,40 @@ class ValidatorRulesTest extends Specification implements ValidatorFixtures {
result.validationResults[0].message == "Type \"InvalidBaz\" has subtypes but no discriminator is set"
}

def "enum value pascal case rule"() {
when:
def validators = Arrays.asList(new TypesValidator(Arrays.asList(EnumValuePascalCaseRule.create(emptyList()))))
def uri = uriFromClasspath("/enum-value-pascal-case-rule.raml")
def result = new RamlModelBuilder(validators).buildApi(uri)
then:
result.validationResults.size() == 17
result.validationResults[0].message == "Enum value \"platform\" in type \"InvalidLowercaseEnum\" must be PascalCase"
result.validationResults[1].message == "Enum value \"external\" in type \"InvalidLowercaseEnum\" must be PascalCase"
result.validationResults[2].message == "Enum value \"disabled\" in type \"InvalidLowercaseEnum\" must be PascalCase"
result.validationResults[3].message == "Enum value \"PLATFORM\" in type \"InvalidUppercaseEnum\" must be PascalCase"
result.validationResults[4].message == "Enum value \"EXTERNAL\" in type \"InvalidUppercaseEnum\" must be PascalCase"
result.validationResults[5].message == "Enum value \"DISABLED\" in type \"InvalidUppercaseEnum\" must be PascalCase"
result.validationResults[6].message == "Enum value \"add_line_item\" in type \"InvalidSnakeCaseEnum\" must be PascalCase"
result.validationResults[7].message == "Enum value \"remove_line_item\" in type \"InvalidSnakeCaseEnum\" must be PascalCase"
result.validationResults[8].message == "Enum value \"set_custom_type\" in type \"InvalidSnakeCaseEnum\" must be PascalCase"
result.validationResults[9].message == "Enum value \"addLineItem\" in type \"InvalidCamelCaseEnum\" must be PascalCase"
result.validationResults[10].message == "Enum value \"removeLineItem\" in type \"InvalidCamelCaseEnum\" must be PascalCase"
result.validationResults[11].message == "Enum value \"setCustomType\" in type \"InvalidCamelCaseEnum\" must be PascalCase"
result.validationResults[12].message == "Enum value \"add-line-item\" in type \"InvalidKebabCaseEnum\" must be PascalCase"
result.validationResults[13].message == "Enum value \"remove-line-item\" in type \"InvalidKebabCaseEnum\" must be PascalCase"
result.validationResults[14].message == "Enum value \"invalidCamelCase\" in type \"MixedCaseEnum\" must be PascalCase"
result.validationResults[15].message == "Enum value \"INVALID_UPPER\" in type \"MixedCaseEnum\" must be PascalCase"
result.validationResults[16].message == "Enum value \"another-invalid\" in type \"MixedCaseEnum\" must be PascalCase"
}

def "enum value pascal case rule with exclusions"() {
when:
def options = singletonList(new RuleOption(RuleOptionType.EXCLUDE.toString(), "InvalidLowercaseEnum"))
def validators = Arrays.asList(new TypesValidator(Arrays.asList(EnumValuePascalCaseRule.create(options))))
def uri = uriFromClasspath("/enum-value-pascal-case-rule.raml")
def result = new RamlModelBuilder(validators).buildApi(uri)
then:
result.validationResults.size() == 14
}

}
Loading
Loading