diff --git a/.claude/skills/metaschema-constraints-authoring.md b/.claude/skills/metaschema-constraints-authoring.md index 4387ac187..6e74380f6 100644 --- a/.claude/skills/metaschema-constraints-authoring.md +++ b/.claude/skills/metaschema-constraints-authoring.md @@ -12,7 +12,8 @@ This skill guides the creation of Metaschema constraints for validating document | Constraint | Allowed On | Purpose | |------------|------------|---------| | `allowed-values` | flag, field, assembly | Restrict values to enumerated set | -| `expect` | flag, field, assembly | Assert Metapath condition is true | +| `expect` | flag, field, assembly | Assert Metapath condition is true (fires when FALSE) | +| `report` | flag, field, assembly | Report when Metapath condition is true (fires when TRUE) | | `matches` | flag, field, assembly | Validate against regex and/or datatype | | `has-cardinality` | assembly only | Enforce occurrence counts | | `index` | assembly only | Create named index with composite keys | @@ -148,6 +149,58 @@ constraint: ``` +### report + +Reports a finding when a Metapath expression evaluates to `true`. This is the inverse of `expect`: `expect` fires when the test is FALSE (assertion failed), while `report` fires when the test is TRUE (condition detected). + +**Use cases:** +- Deprecation warnings (element is deprecated) +- Informational notices (condition detected that user should know about) +- Style suggestions (valid but not preferred) +- Audit findings (specific pattern detected) + +**Attributes:** +- `test`: Required - Metapath boolean expression +- `target`: Required for fields/assemblies +- `message`: Optional - custom message with Metapath templates +- `level`: Typically `INFORMATIONAL` or `WARNING` (not `ERROR`) + +**YAML Example:** +```yaml +constraint: + rules: + - object-type: report + id: deprecated-element + level: WARNING + target: "." + test: "exists(@deprecated)" + message: "This element is deprecated. Consider using the replacement." + - object-type: report + id: large-collection + level: INFORMATIONAL + target: "./items" + test: "count(./item) > 100" + message: "Collection has { count(./item) } items, which may impact performance." +``` + +**XML Example:** +```xml + + + This element is deprecated. Consider using the replacement. + + + Collection has { count(./item) } items, which may impact performance. + + +``` + +**expect vs report:** +| Constraint | Fires When | Typical Level | Use Case | +|------------|------------|---------------|----------| +| `expect` | test is FALSE | ERROR | Validation failures | +| `report` | test is TRUE | WARNING/INFORMATIONAL | Detected conditions | + ### matches Validates values against datatype and/or regex patterns. diff --git a/CLAUDE.md b/CLAUDE.md index c3c08dff8..160bf7785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,6 +299,7 @@ PRDs are stored in `PRDs/-/` with: | PRD | Description | Completed | |-----|-------------|-----------| +| `PRDs/20251228-targeted-report-constraint/` | Add constraint processing support for TargetedReportConstraint (issue #592) | 2025-12-29 | | `PRDs/20251228-validation-errors/` | Validation error message improvements (#595, #596, #205) | 2025-12-28 | | `PRDs/20251224-codegen-quality/` | Code generator Javadoc and quality improvements | 2025-12-28 | | `PRDs/20251217-cli-processor-refactor/` | CLI processor refactoring (issue #252) | 2025-12-21 | diff --git a/PRDs/20251228-targeted-report-constraint/PRD.md b/PRDs/20251228-targeted-report-constraint/PRD.md new file mode 100644 index 000000000..9e510e8a2 --- /dev/null +++ b/PRDs/20251228-targeted-report-constraint/PRD.md @@ -0,0 +1,245 @@ +# PRD: Add Constraint Processing Support for TargetedReportConstraint + +## Document Information + +| Field | Value | +|-------|-------| +| **PRD ID** | REPORT-592 | +| **Status** | In Review | +| **Author** | David Waltermire | +| **Created** | 2025-12-28 | +| **Last Updated** | 2025-12-28 | +| **GitHub Issue** | [#592](https://github.com/metaschema-framework/metaschema-java/issues/592) | +| **Milestone** | v3.0.0 Milestone 2 | + +--- + +## 1. Overview + +### 1.1 Problem Statement + +The `TargetedReportConstraint` binding class was added in PR #589 as part of the Metapath-based targeting for binding configurations. However, the constraint processing system has not been updated to handle this new constraint type. + +Currently, the constraint loader (`ConstraintBindingSupport`) and validation infrastructure do not: +- Recognize and parse `TargetedReportConstraint` instances +- Evaluate the target Metapath expression to identify target nodes +- Apply the report constraint logic to matched targets + +This leaves the `TargetedReportConstraint` binding class orphaned - it exists but cannot be used in constraint validation. + +### 1.2 Goals + +1. Create a core `IReportConstraint` interface following the existing constraint interface pattern +2. Implement `DefaultReportConstraint` with builder support +3. Update `ConstraintBindingSupport` to parse `TargetedReportConstraint` from binding configuration +4. Integrate report constraints into the validation pipeline +5. Add comprehensive unit tests for the new constraint type + +### 1.3 Non-Goals + +- Modifying the Metaschema module definition (already complete) +- Changing the generated binding class `TargetedReportConstraint.java` (already generated correctly) +- Adding CLI commands for report constraints +- Adding schema generation support for report constraints + +### 1.4 Success Metrics + +| Metric | Current | Target | +|--------|---------|--------| +| TargetedReportConstraint recognized by loader | No | Yes | +| Report constraints validated at runtime | No | Yes | +| Unit test coverage for new code | 0% | 80%+ | +| All existing tests pass | Yes | Yes | + +--- + +## 2. Background + +### 2.1 Current State + +The constraint processing system supports these targeted constraint types: +- `TargetedAllowedValuesConstraint` +- `TargetedExpectConstraint` +- `TargetedMatchesConstraint` +- `TargetedIndexConstraint` +- `TargetedIndexHasKeyConstraint` +- `TargetedIsUniqueConstraint` +- `TargetedHasCardinalityConstraint` + +Each targeted constraint type has: +1. A binding class (generated from Metaschema module) +2. A core interface (e.g., `IExpectConstraint`) +3. A default implementation (e.g., `DefaultExpectConstraint`) +4. Loader support in `ConstraintBindingSupport` +5. Validation logic in `DefaultConstraintValidator` +6. Visitor pattern method in `IConstraintVisitor` + +The `TargetedReportConstraint` binding class exists but lacks items 2-6. + +### 2.2 Technical Context + +**Report vs Expect Semantics:** + +The report constraint is semantically different from expect: +- **Expect constraint:** Fails validation if the test expression evaluates to FALSE +- **Report constraint:** Generates an informational finding when the test expression evaluates to TRUE + +This means report constraints produce findings/reports but do not cause validation failures. They are used for informational purposes, warnings, or advisory messages. + +**Key Files:** + +| Component | Location | +|-----------|----------| +| Binding class | `databind/.../binding/TargetedReportConstraint.java` | +| Constraint interfaces | `core/.../model/constraint/` | +| Constraint implementations | `core/.../model/constraint/impl/` | +| Constraint loader | `databind/.../impl/ConstraintBindingSupport.java` | +| Constraint validator | `core/.../constraint/DefaultConstraintValidator.java` | +| Visitor interface | `core/.../constraint/IConstraintVisitor.java` | + +--- + +## 3. Requirements + +### 3.1 Functional Requirements + +#### FR-1: IReportConstraint Interface +Create an `IReportConstraint` interface that: +- Extends `IConfigurableMessageConstraint` +- Provides access to the test expression via `getTest(): IMetapathExpression` +- Follows the same pattern as `IExpectConstraint` + +#### FR-2: DefaultReportConstraint Implementation +Create a `DefaultReportConstraint` class that: +- Implements `IReportConstraint` +- Provides a builder via `IReportConstraint.builder()` +- Supports all standard constraint properties (id, level, target, message, remarks) + +#### FR-3: Constraint Loading Support +Update `ConstraintBindingSupport` to: +- Handle `TargetedReportConstraint` in all `parse()` method overloads +- Create `IReportConstraint` instances via a new `newReport()` factory method + +#### FR-4: Validation Pipeline Integration +Update the validation system to: +- Call report constraint validation at appropriate points +- Generate findings when report test expressions evaluate to TRUE +- Default level is INFORMATIONAL (unlike expect's default of ERROR) +- Map Kind based on severity: ERROR/CRITICAL → FAIL, all other levels → INFORMATIONAL + +#### FR-5: Visitor Pattern Support +Update `IConstraintVisitor` to: +- Include `visitReportConstraint()` method +- Update all visitor implementations + +#### FR-6: Skill Documentation Updates +Update Claude Code skills to document the new constraint type: +- `.claude/skills/metaschema-constraints-authoring.md` - Add `report` constraint type with syntax and examples +- `.claude/skills/metaschema-java-library.md` - Add `IReportConstraint` interface if constraint interfaces are documented + +### 3.2 Non-Functional Requirements + +#### NFR-1: API Consistency +The new constraint types must follow established patterns for naming, structure, and builder usage consistent with existing constraints like `IExpectConstraint`. + +#### NFR-2: Test Coverage +All new code must have unit tests following TDD practices with minimum 80% coverage. + +#### NFR-3: Documentation +All public interfaces and methods must have complete Javadoc per project standards. + +--- + +## 4. Implementation Phases + +This is a single-phase implementation as all components are interdependent. + +### Phase 1: Complete TargetedReportConstraint Support + +Implement all components in a single cohesive PR: +1. Core interface and implementation +2. Constraint loading support +3. Validation integration +4. Comprehensive unit tests + +See [Implementation Plan](./implementation-plan.md) for detailed breakdown. + +--- + +## 5. Testing Strategy + +### 5.1 Test Approach + +All development follows TDD: +1. Write failing tests for each new component +2. Implement the component to pass tests +3. Refactor while maintaining green tests + +### 5.2 Verification Checklist + +- [ ] `IReportConstraint` interface created with appropriate methods +- [ ] `DefaultReportConstraint` implementation with builder +- [ ] `ConstraintBindingSupport.parse()` handles `TargetedReportConstraint` +- [ ] `newReport()` factory method creates valid constraints +- [ ] Report constraints generate findings when test is TRUE +- [ ] Report constraints default to INFORMATIONAL level +- [ ] Report constraints at ERROR/CRITICAL level cause validation failures (Kind.FAIL) +- [ ] Visitor pattern updated with `visitReportConstraint()` +- [ ] `metaschema-constraints-authoring.md` skill updated with `report` constraint +- [ ] `metaschema-java-library.md` skill updated if applicable +- [ ] All new code has Javadoc +- [ ] All unit tests pass +- [ ] Build succeeds with `mvn clean install -PCI -Prelease` + +--- + +## 6. Risks and Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Report semantics differ from expect | Medium | Low | Clear documentation, separate validation method | +| Breaking existing constraint handling | High | Low | Comprehensive test coverage, incremental changes | +| Missing visitor implementations | Low | Low | No current implementations exist; only interface needs update | + +--- + +## 7. Design Decisions + +### DD-1: Report Constraint Severity and Kind Mapping + +**Decision:** Report constraints support all severity levels with INFORMATIONAL as the default. + +| Configured Level | Kind when test=TRUE | +|------------------|---------------------| +| INFORMATIONAL (default) | INFORMATIONAL | +| DEBUG | INFORMATIONAL | +| WARNING | INFORMATIONAL | +| ERROR | FAIL | +| CRITICAL | FAIL | + +**Rationale:** Report constraints are fundamentally about "reporting" when a condition is detected. At WARNING level and below, findings remain informational since no validation failure should occur. Only ERROR/CRITICAL elevate to FAIL, indicating the detected condition is serious enough to fail validation. This differs from expect constraints where WARNING produces Kind.PASS (a successful but cautionary result). + +### DD-2: Semantic Distinction from Expect + +**Decision:** Report and expect are opposite assertions: + +| Constraint | Meaning | Generates finding when... | +|------------|---------|---------------------------| +| Expect | "This MUST be true" | Test = FALSE | +| Report | "This MUST NOT be true" | Test = TRUE | + +Both can cause validation failures at ERROR/CRITICAL level. + +### DD-3: Finding Differentiation + +**Decision:** No special field needed to distinguish report from expect findings. + +Consumers can use `instanceof IReportConstraint` vs `instanceof IExpectConstraint` on the finding's constraint object if differentiation is needed. This follows the existing pattern for other constraint types. + +--- + +## 8. Related Documents + +- [Implementation Plan](./implementation-plan.md) +- [PR #589 - Metapath-based targeting for binding configurations](https://github.com/metaschema-framework/metaschema-java/pull/589) +- [Issue #592 - Add constraint processing support for TargetedReportConstraint](https://github.com/metaschema-framework/metaschema-java/issues/592) diff --git a/PRDs/20251228-targeted-report-constraint/implementation-plan.md b/PRDs/20251228-targeted-report-constraint/implementation-plan.md new file mode 100644 index 000000000..998dad11b --- /dev/null +++ b/PRDs/20251228-targeted-report-constraint/implementation-plan.md @@ -0,0 +1,267 @@ +# Implementation Plan: TargetedReportConstraint Support + +This document details the implementation for adding constraint processing support for `TargetedReportConstraint`. + +--- + +## Prerequisites + +- Build the project to ensure all generated sources exist: `mvn install -DskipTests` +- Understand the difference between report and expect semantics (see PRD) + +--- + +## Test-Driven Development Requirement + +**All functional code changes must follow TDD:** + +1. Write or update tests first to capture expected behavior +2. Verify tests fail with existing implementation +3. Make the code changes +4. Verify tests pass after changes + +--- + +## Phase 1: Complete TargetedReportConstraint Support + +### PR 1: Add IReportConstraint Interface and Implementation + +| Attribute | Value | +|-----------|-------| +| **Files Changed** | 25 | +| **Risk Level** | Medium | +| **Dependencies** | None | +| **Target Branch** | develop | +| **Status** | Complete | +| **Pull Request** | [#598](https://github.com/metaschema-framework/metaschema-java/pull/598) | + +#### Files to Create + +| File | Purpose | +|------|---------| +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IReportConstraint.java` | Report constraint interface with `getTest()` method | +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/impl/DefaultReportConstraint.java` | Default implementation with builder | +| `core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ReportConstraintTest.java` | Unit tests for report constraint | + +#### Files to Modify + +| File | Changes | +|------|---------| +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintVisitor.java` | Add `visitReportConstraint()` method | +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IValueConstrained.java` | Add `getReportConstraints()` and `addConstraint(IReportConstraint)` methods | +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IFeatureValueConstrained.java` | Add delegation for report constraints | +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValueConstraintSet.java` | Add storage and retrieval for report constraints | +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractTargetedConstraints.java` | Add `getReportConstraints()` forwarding in `applyTo()` | +| `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java` | Add `validateReport()` method and integrate into validation flow | +| `databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/impl/ConstraintBindingSupport.java` | Add `TargetedReportConstraint` handling to `parse()` methods and `newReport()` factory | +| `databind/src/test/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoaderTest.java` | Add test for loading TargetedReportConstraint | +| `.claude/skills/metaschema-constraints-authoring.md` | Add `report` constraint type documentation | +| `.claude/skills/metaschema-java-library.md` | Add `IReportConstraint` interface (if applicable) | + +#### Implementation Approach + +##### Step 1: Create Core Interface (TDD) + +1. **Write test first** - Create `ReportConstraintTest.java` with tests for: + - Builder creates valid constraint + - Test expression is retrievable + - Constraint properties (id, level, message) are accessible + - Visitor pattern works correctly + +2. **Watch tests fail** - Verify compilation errors due to missing classes + +3. **Create `IReportConstraint.java`** modeled after `IExpectConstraint`: + ```java + public interface IReportConstraint extends IConfigurableMessageConstraint { + @NonNull + IMetapathExpression getTest(); + + @NonNull + static Builder builder() { + return new Builder(); + } + + final class Builder extends AbstractConfigurableMessageConstraintBuilder { + // Builder implementation + } + } + ``` + +4. **Create `DefaultReportConstraint.java`** in `impl/` package + +5. **Run tests** - Verify they pass + +##### Step 2: Update Constraint Interfaces (TDD) + +1. **Write tests** for constraint storage/retrieval in `ValueConstraintSet` + +2. **Update `IValueConstrained.java`**: + - Add `List getReportConstraints()` + - Add `void addConstraint(@NonNull IReportConstraint constraint)` + +3. **Update `IFeatureValueConstrained.java`**: + - Add delegation methods for report constraints + +4. **Update `ValueConstraintSet.java`**: + - Add `List reportConstraints` field + - Implement getter and add methods + +5. **Update `AbstractTargetedConstraints.java`**: + - Add `getReportConstraints().forEach(definition::addConstraint)` in `applyTo()` + +##### Step 3: Update Visitor Pattern + +1. **Update `IConstraintVisitor.java`**: + ```java + R visitReportConstraint(@NonNull IReportConstraint constraint, T state); + ``` + +2. **Update `IReportConstraint.java`** to implement `accept()`: + ```java + @Override + default R accept(IConstraintVisitor visitor, T state) { + return visitor.visitReportConstraint(this, state); + } + ``` + + **Note:** There are currently no implementations of `IConstraintVisitor` in the codebase. The interface is prepared for future visitor pattern usage. Only the interface itself needs the new method. + +##### Step 4: Update Constraint Loader (TDD) + +1. **Write test** in `BindingConstraintLoaderTest.java` for loading `TargetedReportConstraint` + +2. **Update `ConstraintBindingSupport.java`**: + - Add import for `TargetedReportConstraint` + - Add case for `TargetedReportConstraint` in `parse(IValueConstrained, ...)` method + - Add case for `TargetedReportConstraint` in `parse(IValueTargetedConstraintsBase, ...)` method + - Add case for `TargetedReportConstraint` in `parse(IModelConstrained, ...)` method + - Add factory method: + ```java + @NonNull + private static IReportConstraint newReport( + @NonNull TargetedReportConstraint obj, + @NonNull ISource source) { + // Build constraint from binding object + } + ``` + +##### Step 5: Update Validation Pipeline (TDD) + +1. **Write test** for report constraint validation in `DefaultConstraintValidatorTest.java` + +2. **Update `DefaultConstraintValidator.java`**: + - Add `validateReport()` method similar to `validateExpect()` but: + - Generate finding when test is TRUE (opposite of expect) + - Use constraint's configured level (default: INFORMATIONAL) + - Kind mapping: ERROR/CRITICAL → FAIL, all others → INFORMATIONAL + - Add `validateReport()` call to all three validation entry points: + - `validateFlag()` (line ~190) - add after other constraint validations + - `validateField()` (line ~217) - add after other constraint validations + - `validateAssembly()` (line ~243) - add after other constraint validations + +##### Step 6: Update Skill Documentation + +Update Claude Code skills to document the new constraint type: + +1. **Update `.claude/skills/metaschema-constraints-authoring.md`**: + - Add `report` to Constraint Types Overview table + - Add new section for `report` constraint with: + - Purpose: Report/fail when Metapath condition is TRUE (opposite of expect) + - Attributes: `test`, `target`, `message`, `level` (default: INFORMATIONAL) + - YAML and XML syntax examples + - Semantic distinction from `expect` + +2. **Update `.claude/skills/metaschema-java-library.md`** (if constraint interfaces are documented): + - Add `IReportConstraint` interface + - Document relationship to `IExpectConstraint` + +##### Step 7: Final Verification + +1. Run full test suite: `mvn test` +2. Run full build with checks: `mvn clean install -PCI -Prelease` +3. Verify all tests pass and no new warnings + +#### Acceptance Criteria + +- [x] `IReportConstraint` interface created extending `IConfigurableMessageConstraint` +- [x] `IReportConstraint.getTest()` returns the test Metapath expression +- [x] `DefaultReportConstraint` implementation with working builder +- [x] `IConstraintVisitor.visitReportConstraint()` method added +- [x] `IValueConstrained` has `getReportConstraints()` and `addConstraint(IReportConstraint)` +- [x] `ValueConstraintSet` stores and retrieves report constraints +- [x] `AbstractTargetedConstraints.applyTo()` forwards report constraints +- [x] `ConstraintBindingSupport.parse()` handles `TargetedReportConstraint` in all overloads +- [x] `ConstraintBindingSupport.newReport()` factory method creates valid constraints +- [x] `DefaultConstraintValidator.validateReport()` generates findings when test is TRUE +- [x] Report constraints default to INFORMATIONAL level +- [x] Report constraints at ERROR/CRITICAL cause validation failures (Kind.FAIL) +- [x] `metaschema-constraints-authoring.md` skill updated with `report` constraint +- [x] `metaschema-java-library.md` skill updated if applicable +- [x] Unit tests for `IReportConstraint` builder and behavior +- [x] Unit tests for constraint loading +- [x] Unit tests for constraint validation +- [x] All new code has complete Javadoc +- [x] All tests pass: `mvn test` +- [x] Build succeeds: `mvn clean install -PCI -Prelease` + +--- + +## Key Design Decisions + +### Report vs Expect Semantics + +| Aspect | Expect Constraint | Report Constraint | +|--------|-------------------|-------------------| +| Meaning | "This MUST be true" | "This MUST NOT be true" | +| Generates finding when | Test = FALSE | Test = TRUE | +| Default level | ERROR | INFORMATIONAL | +| Kind at ERROR/CRITICAL | FAIL | FAIL | +| Kind at WARNING and below | Based on level | INFORMATIONAL | + +Both expect and report can cause validation failures when configured at ERROR or CRITICAL level. + +### Kind Mapping for Report Constraints + +| Configured Level | Kind | +|------------------|------| +| INFORMATIONAL (default) | INFORMATIONAL | +| DEBUG | INFORMATIONAL | +| WARNING | INFORMATIONAL | +| ERROR | FAIL | +| CRITICAL | FAIL | + +### Interface Hierarchy + +```text +IConstraint +└── IConfigurableMessageConstraint + ├── IExpectConstraint (existing) + └── IReportConstraint (new - follows same pattern) +``` + +--- + +## PR Summary Table + +| PR | Description | Files | Risk | Dependencies | Status | +|----|-------------|-------|------|--------------|--------| +| [#598](https://github.com/metaschema-framework/metaschema-java/pull/598) | Add IReportConstraint interface and full implementation | 25 | Medium | None | Complete | + +**Total PRs**: 1 +**Total Files Changed**: 25 + +--- + +## Verification Commands + +```bash +# Run all tests +mvn test + +# Run specific test classes +mvn -pl core test -Dtest=ReportConstraintTest +mvn -pl databind test -Dtest=BindingConstraintLoaderTest + +# Full CI build +mvn clean install -PCI -Prelease +``` diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/MetaschemaModelConstants.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/MetaschemaModelConstants.java index ffddb288a..fa325e4b7 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/MetaschemaModelConstants.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/MetaschemaModelConstants.java @@ -12,6 +12,7 @@ import gov.nist.secauto.metaschema.core.model.constraint.IIndexConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IIndexHasKeyConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IMatchesConstraint; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IUniqueConstraint; import gov.nist.secauto.metaschema.core.qname.IEnhancedQName; @@ -116,6 +117,13 @@ public final class MetaschemaModelConstants { public static final IEnhancedQName EXPECT_CONSTRAINT_QNAME = IEnhancedQName.of(MetaschemaConstants.METASCHEMA_NAMESPACE, "expect"); + /** + * The name of an {@link IReportConstraint} constraint in the Metaschema model. + */ + @NonNull + public static final IEnhancedQName REPORT_CONSTRAINT_QNAME + = IEnhancedQName.of(MetaschemaConstants.METASCHEMA_NAMESPACE, "report"); + /** * The name of an {@link IIndexConstraint} constraint in the Metaschema model. */ diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractConstraintValidationHandler.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractConstraintValidationHandler.java index 9b4b1b521..198854f92 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractConstraintValidationHandler.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractConstraintValidationHandler.java @@ -310,6 +310,40 @@ protected String newExpectViolationMessage( : constraint.generateMessage(target, dynamicContext); } + /** + * Construct a new message for the provided report {@code constraint} applied to + * the {@code node}. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + * + * @param constraint + * the constraint the requested message pertains to + * @param node + * the item the constraint targeted + * @param target + * the target matching the constraint + * @param dynamicContext + * the Metapath dynamic execution context to use for Metapath + * evaluation + * @return the new message + * @throws ConstraintValidationException + * if the custom message contains a Metapath expression that is + * invalid or if the expression failed to evaluate + */ + @NonNull + protected String newReportViolationMessage( + @NonNull IReportConstraint constraint, + @NonNull INodeItem node, + @NonNull INodeItem target, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException { + return constraint.getMessage() == null + ? ObjectUtils.notNull(String.format("Report constraint '%s' matched the data at path '%s'", + constraint.getTest().getPath(), + toPath(target))) + : constraint.generateMessage(target, dynamicContext); + } + /** * Construct a new violation message for the provided {@code constraint} applied * to the {@code node}. diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractTargetedConstraints.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractTargetedConstraints.java index 8725a2067..f0326cdd6 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractTargetedConstraints.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/AbstractTargetedConstraints.java @@ -85,6 +85,7 @@ protected void applyTo(@NonNull IValueConstrained definition) { getMatchesConstraints().forEach(definition::addConstraint); getIndexHasKeyConstraints().forEach(definition::addConstraint); getExpectConstraints().forEach(definition::addConstraint); + getReportConstraints().forEach(definition::addConstraint); } @Override diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java index 42d858faa..df46fcd10 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java @@ -188,6 +188,7 @@ protected void validateFlag( try { validateExpect(definition.getExpectConstraints(), item, dynamicContext); + validateReport(definition.getReportConstraints(), item, dynamicContext); validateAllowedValues(definition.getAllowedValuesConstraints(), item, dynamicContext); validateIndexHasKey(definition.getIndexHasKeyConstraints(), item, dynamicContext); validateMatches(definition.getMatchesConstraints(), item, dynamicContext); @@ -215,6 +216,7 @@ protected void validateField( try { validateExpect(definition.getExpectConstraints(), item, dynamicContext); + validateReport(definition.getReportConstraints(), item, dynamicContext); validateAllowedValues(definition.getAllowedValuesConstraints(), item, dynamicContext); validateIndexHasKey(definition.getIndexHasKeyConstraints(), item, dynamicContext); validateMatches(definition.getMatchesConstraints(), item, dynamicContext); @@ -241,6 +243,7 @@ protected void validateAssembly( try { validateExpect(definition.getExpectConstraints(), item, dynamicContext); + validateReport(definition.getReportConstraints(), item, dynamicContext); validateAllowedValues(definition.getAllowedValuesConstraints(), item, dynamicContext); validateIndexHasKey(definition.getIndexHasKeyConstraints(), item, dynamicContext); validateMatches(definition.getMatchesConstraints(), item, dynamicContext); @@ -770,6 +773,86 @@ private void validateExpect( } } + /** + * Evaluates the provided collection of report {@code constraints} in the + * context of the {@code item}. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + * + * @param constraints + * the constraints to execute + * @param item + * the focus of Metapath evaluation + * @param dynamicContext + * the Metapath dynamic execution context to use for Metapath + * evaluation + * @throws ConstraintValidationException + * if an unexpected error occurred while validating a constraint + */ + private void validateReport( + @NonNull List constraints, + @NonNull IDefinitionNodeItem item, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException { + for (IReportConstraint constraint : constraints) { + assert constraint != null; + + try { + ISequence> targets = constraint.matchTargets(item, dynamicContext); + validateReport(constraint, item, targets, dynamicContext); + } catch (MetapathException ex) { + handleError(constraint, item, ex, dynamicContext); + } + } + } + + /** + * Evaluates the provided report {@code constraint} against each of the + * {@code targets}. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + * + * @param constraint + * the constraint to execute + * @param node + * the original focus of Metapath evaluation for identifying the + * targets + * @param targets + * the focus of Metapath evaluation for evaluating any constraint + * Metapath clauses + * @param dynamicContext + * the Metapath dynamic execution context to use for Metapath + * evaluation + * @throws ConstraintValidationException + * if an unexpected error occurred while validating a constraint + */ + private void validateReport( + @NonNull IReportConstraint constraint, + @NonNull INodeItem node, + @NonNull ISequence targets, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException { + IMetapathExpression test = constraint.getTest(); + IConstraintValidationHandler handler = getConstraintValidationHandler(); + for (INodeItem item : targets) { + assert item != null; + + if (item.hasValue()) { + try { + ISequence result = test.evaluate(item, dynamicContext); + // Report constraints fire when test is TRUE (opposite of expect) + if (FnBoolean.fnBoolean(result).toBoolean()) { + handler.handleReportViolation(constraint, node, item, dynamicContext); + } else { + handlePass(constraint, node, item, dynamicContext); + } + } catch (MetapathException ex) { + handleError(constraint, item, ex, dynamicContext); + } + } + } + } + /** * Evaluates the provided collection of {@code constraints} in the context of * the {@code item}. diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java index 9ed7a5717..60eda4ec9 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java @@ -11,6 +11,7 @@ import gov.nist.secauto.metaschema.core.metapath.item.ISequence; import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem; import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding.Kind; import gov.nist.secauto.metaschema.core.model.validation.IValidationResult; import gov.nist.secauto.metaschema.core.util.CollectionUtil; @@ -221,6 +222,20 @@ public void handleExpectViolation( .build()); } + @Override + public void handleReportViolation( + @NonNull IReportConstraint constraint, + @NonNull INodeItem node, + @NonNull INodeItem target, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException { + addFinding(ConstraintValidationFinding.builder(constraint, node) + .severity(constraint.getLevel()) + .kind(toKind(constraint.getLevel())) + .target(target) + .message(newReportViolationMessage(constraint, node, target, dynamicContext)) + .build()); + } + @Override public void handleAllowedValuesViolation( @NonNull List failedConstraints, diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraint.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraint.java index 0f370ce26..2367ac1ae 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraint.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraint.java @@ -41,7 +41,9 @@ enum Type { /** Constraint verifying index key references. */ INDEX_HAS_KEY("index-has-key"), /** Constraint validating pattern matching. */ - MATCHES("matches"); + MATCHES("matches"), + /** Constraint reporting a condition. */ + REPORT("report"); @NonNull private final String name; diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintValidationHandler.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintValidationHandler.java index d7b749826..65641c5f4 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintValidationHandler.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintValidationHandler.java @@ -307,6 +307,33 @@ void handleExpectViolation( @NonNull INodeItem target, @NonNull DynamicContext dynamicContext) throws ConstraintValidationException; + /** + * Handle a report test finding. + *

+ * This happens when the report test expression evaluates to true. Unlike expect + * constraints which generate violations when false, report constraints generate + * findings when true. + * + * @param constraint + * the constraint that was evaluated + * @param node + * the node used as the evaluation focus to determine constraint + * targets + * @param target + * the target of evaluation + * @param dynamicContext + * the Metapath dynamic execution context to use for Metapath + * evaluation + * @throws ConstraintValidationException + * if the constraint has a custom message that contains a Metapath + * expression that is invalid or if the expression failed to evaluate + */ + void handleReportViolation( + @NonNull IReportConstraint constraint, + @NonNull INodeItem node, + @NonNull INodeItem target, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException; + /** * Handle an allowed values constraint violation. * diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintVisitor.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintVisitor.java index 9f42e371f..52dc113b9 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintVisitor.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IConstraintVisitor.java @@ -101,4 +101,16 @@ public interface IConstraintVisitor { * @return the visitation result */ R visitUniqueConstraint(@NonNull IUniqueConstraint constraint, T state); + + /** + * Implementation of this method support visitation of an + * {@link IReportConstraint}. + * + * @param constraint + * the constraint to visit + * @param state + * a state object passed to the visitor + * @return the visitation result + */ + R visitReportConstraint(@NonNull IReportConstraint constraint, T state); } diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IFeatureValueConstrained.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IFeatureValueConstrained.java index a8e802e1c..742e5398b 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IFeatureValueConstrained.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IFeatureValueConstrained.java @@ -61,6 +61,11 @@ default List getExpectConstraints() { return getConstraintSupport().getExpectConstraints(); } + @Override + default List getReportConstraints() { + return getConstraintSupport().getReportConstraints(); + } + @Override default void addConstraint(IAllowedValuesConstraint constraint) { getConstraintSupport().addConstraint(constraint); @@ -80,4 +85,9 @@ default void addConstraint(IIndexHasKeyConstraint constraint) { default void addConstraint(@NonNull IExpectConstraint constraint) { getConstraintSupport().addConstraint(constraint); } + + @Override + default void addConstraint(@NonNull IReportConstraint constraint) { + getConstraintSupport().addConstraint(constraint); + } } diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IReportConstraint.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IReportConstraint.java new file mode 100644 index 000000000..c5a661142 --- /dev/null +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IReportConstraint.java @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import gov.nist.secauto.metaschema.core.metapath.IMetapathExpression; +import gov.nist.secauto.metaschema.core.model.constraint.impl.DefaultReportConstraint; +import gov.nist.secauto.metaschema.core.util.ObjectUtils; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Represents a rule reporting a condition when a Metaschema assembly, field, or + * flag data instance matches a Metapath-based test. + *

+ * Unlike {@link IExpectConstraint} which generates a finding when the test is + * FALSE, a report constraint generates a finding when the test is TRUE. This is + * useful for: + *

    + *
  • Reporting deprecated usage patterns
  • + *
  • Flagging known issues or limitations
  • + *
  • Providing informational messages about content
  • + *
+ *

+ * A custom message can be used to indicate what a matching condition signifies. + * The default severity level is {@link Level#INFORMATIONAL}. + * + * @since 2.0.0 + */ +public interface IReportConstraint extends IConfigurableMessageConstraint { + /** + * The default severity level for report constraints. + */ + @NonNull + Level DEFAULT_LEVEL = Level.INFORMATIONAL; + + @Override + default Type getType() { + return Type.REPORT; + } + + /** + * Get the test to use to identify reportable conditions in selected nodes. + *

+ * A finding is generated when this test evaluates to {@code true}. + * + * @return the test metapath expression to use + */ + @NonNull + IMetapathExpression getTest(); + + @Override + default R accept(IConstraintVisitor visitor, T state) { + return visitor.visitReportConstraint(this, state); + } + + /** + * Create a new constraint builder. + * + * @return the builder + */ + @NonNull + static Builder builder() { + return new Builder(); + } + + /** + * Provides a builder pattern for constructing a new {@link IReportConstraint}. + */ + final class Builder + extends AbstractConfigurableMessageConstraintBuilder { + private IMetapathExpression test; + private boolean levelSet; + + private Builder() { + // disable construction + } + + /** + * Use the provided test to identify reportable conditions in selected nodes. + *

+ * A finding is generated when this test evaluates to {@code true}. + * + * @param test + * the test metapath expression to use + * @return this builder + */ + @NonNull + public Builder test(@NonNull IMetapathExpression test) { + this.test = test; + return this; + } + + @Override + protected Builder getThis() { + return this; + } + + /** + * {@inheritDoc} + *

+ * For report constraints, the default level is {@link Level#INFORMATIONAL} if + * no level is explicitly set. + */ + @Override + @NonNull + public Builder level(@NonNull Level level) { + this.levelSet = true; + return super.level(level); + } + + @Override + @NonNull + protected Level getLevel() { + return levelSet ? super.getLevel() : DEFAULT_LEVEL; + } + + @Override + protected void validate() { + super.validate(); + + ObjectUtils.requireNonNull(getTest()); + } + + private IMetapathExpression getTest() { + return test; + } + + @Override + protected IReportConstraint newInstance() { + return new DefaultReportConstraint( + getId(), + getFormalName(), + getDescription(), + ObjectUtils.notNull(getSource()), + getLevel(), + getTarget(), + getProperties(), + ObjectUtils.requireNonNull(getTest()), + getMessage(), + getRemarks()); + } + } +} diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IValueConstrained.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IValueConstrained.java index b912003b1..1c8d7e48d 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IValueConstrained.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/IValueConstrained.java @@ -74,6 +74,14 @@ public interface IValueConstrained { @NonNull List getExpectConstraints(); + /** + * Get the collection of report constraints, if any. + * + * @return the constraints or an empty list + */ + @NonNull + List getReportConstraints(); + /** * Add a new let expression. * @@ -115,4 +123,12 @@ public interface IValueConstrained { * the constraint to add */ void addConstraint(@NonNull IExpectConstraint constraint); + + /** + * Add a new constraint. + * + * @param constraint + * the constraint to add + */ + void addConstraint(@NonNull IReportConstraint constraint); } diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/LoggingConstraintValidationHandler.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/LoggingConstraintValidationHandler.java index ec8f9d7d6..4443c18cc 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/LoggingConstraintValidationHandler.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/LoggingConstraintValidationHandler.java @@ -11,6 +11,7 @@ import gov.nist.secauto.metaschema.core.metapath.item.ISequence; import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem; import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.util.ObjectUtils; import org.apache.logging.log4j.LogBuilder; @@ -296,6 +297,27 @@ public void handleExpectViolation( } } + @Override + public void handleReportViolation( + @NonNull IReportConstraint constraint, + @NonNull INodeItem node, + @NonNull INodeItem target, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException { + Level level = constraint.getLevel(); + if (isLogged(level)) { + logMessage( + level, + constraint.getId(), + target, + newReportViolationMessage( + constraint, + node, + target, + dynamicContext), + NO_EXCEPTION); + } + } + @Override public void handleAllowedValuesViolation( List failedConstraints, diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValueConstraintSet.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValueConstraintSet.java index b62b8ce23..b1d936b97 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValueConstraintSet.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValueConstraintSet.java @@ -42,6 +42,8 @@ public class ValueConstraintSet implements IValueConstrained { private final List indexHasKeyConstraints = new LinkedList<>(); @NonNull private final List expectConstraints = new LinkedList<>(); + @NonNull + private final List reportConstraints = new LinkedList<>(); /** * The lock used to manage adjustments to the contents of this constraint set. */ @@ -155,6 +157,17 @@ public List getExpectConstraints() { } } + @Override + public List getReportConstraints() { + Lock readLock = instanceLock.readLock(); + readLock.lock(); + try { + return CollectionUtil.unmodifiableList(reportConstraints); + } finally { + readLock.unlock(); + } + } + @Override public final void addConstraint(@NonNull IAllowedValuesConstraint constraint) { Lock writeLock = instanceLock.writeLock(); @@ -202,4 +215,16 @@ public final void addConstraint(@NonNull IExpectConstraint constraint) { writeLock.unlock(); } } + + @Override + public final void addConstraint(@NonNull IReportConstraint constraint) { + Lock writeLock = instanceLock.writeLock(); + writeLock.lock(); + try { + constraints.add(constraint); + reportConstraints.add(constraint); + } finally { + writeLock.unlock(); + } + } } diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/impl/DefaultReportConstraint.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/impl/DefaultReportConstraint.java new file mode 100644 index 000000000..610ceb435 --- /dev/null +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/impl/DefaultReportConstraint.java @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint.impl; + +import gov.nist.secauto.metaschema.core.datatype.markup.MarkupLine; +import gov.nist.secauto.metaschema.core.datatype.markup.MarkupMultiline; +import gov.nist.secauto.metaschema.core.metapath.IMetapathExpression; +import gov.nist.secauto.metaschema.core.metapath.item.atomic.IBooleanItem; +import gov.nist.secauto.metaschema.core.model.IAttributable; +import gov.nist.secauto.metaschema.core.model.ISource; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; + +import java.util.Map; +import java.util.Set; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Represents a report constraint. + *

+ * A report constraint generates a finding when the associated test evaluates to + * {@link IBooleanItem#TRUE} against the target. This is the opposite behavior + * of an expect constraint, which generates a finding when the test evaluates to + * {@code FALSE}. + *

+ * Report constraints are useful for: + *

    + *
  • Flagging deprecated patterns or values
  • + *
  • Reporting known issues or limitations
  • + *
  • Providing informational messages about content characteristics
  • + *
+ * + * @since 2.0.0 + */ +public final class DefaultReportConstraint + extends AbstractConfigurableMessageConstraint + implements IReportConstraint { + @NonNull + private final IMetapathExpression test; + + /** + * Construct a new report constraint. + * + * @param id + * the optional identifier for the constraint + * @param formalName + * the constraint's formal name or {@code null} if not provided + * @param description + * the constraint's semantic description or {@code null} if not + * provided + * @param source + * information about the constraint source + * @param level + * the significance of a violation of this constraint + * @param target + * the Metapath expression identifying the nodes the constraint targets + * @param properties + * a collection of associated properties + * @param test + * a Metapath expression that is evaluated against the target node to + * determine if a condition should be reported; a finding is generated + * when this evaluates to {@code true} + * @param message + * an optional message to emit when the constraint condition is matched + * @param remarks + * optional remarks describing the intent of the constraint + */ + @SuppressWarnings("PMD.ExcessiveParameterList") + public DefaultReportConstraint( + @Nullable String id, + @Nullable String formalName, + @Nullable MarkupLine description, + @NonNull ISource source, + @NonNull Level level, + @NonNull IMetapathExpression target, + @NonNull Map> properties, + @NonNull IMetapathExpression test, + @Nullable String message, + @Nullable MarkupMultiline remarks) { + super(id, formalName, description, source, level, target, properties, message, remarks); + this.test = test; + } + + @Override + public IMetapathExpression getTest() { + return test; + } +} diff --git a/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ReportConstraintTest.java b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ReportConstraintTest.java new file mode 100644 index 000000000..ca6b8fa5e --- /dev/null +++ b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ReportConstraintTest.java @@ -0,0 +1,652 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import gov.nist.secauto.metaschema.core.metapath.DynamicContext; +import gov.nist.secauto.metaschema.core.metapath.IMetapathExpression; +import gov.nist.secauto.metaschema.core.metapath.StaticContext; +import gov.nist.secauto.metaschema.core.metapath.format.IPathFormatter; +import gov.nist.secauto.metaschema.core.metapath.item.atomic.IStringItem; +import gov.nist.secauto.metaschema.core.metapath.item.node.IFlagNodeItem; +import gov.nist.secauto.metaschema.core.model.IFlagDefinition; +import gov.nist.secauto.metaschema.core.model.ISource; +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level; +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Type; +import gov.nist.secauto.metaschema.core.qname.IEnhancedQName; +import gov.nist.secauto.metaschema.core.testsupport.mocking.MockNodeItemFactory; +import gov.nist.secauto.metaschema.core.util.CollectionUtil; +import gov.nist.secauto.metaschema.core.util.ObjectUtils; + +import org.junit.jupiter.api.Test; + +import java.net.URI; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Unit tests for the {@link IReportConstraint} interface and its builder. + *

+ * These tests verify: + *

    + *
  • Builder creates valid constraint
  • + *
  • Test expression is retrievable via getTest()
  • + *
  • Constraint properties (id, level, message) are accessible
  • + *
  • Visitor pattern works correctly (visitReportConstraint)
  • + *
  • Default level is INFORMATIONAL
  • + *
  • Validation generates findings when test is TRUE (opposite of expect)
  • + *
  • Validation does not generate findings when test is FALSE
  • + *
+ */ +@SuppressWarnings("PMD.TooManyStaticImports") +class ReportConstraintTest { + @NonNull + private static final String TEST_SOURCE = "https://example.com/test"; + @NonNull + private static final String NS = ObjectUtils.notNull(URI.create("http://example.com/ns").toASCIIString()); + + @NonNull + private static IEnhancedQName qname(@NonNull String name) { + return IEnhancedQName.of(NS, name); + } + + /** + * Test that the builder creates a valid constraint with test expression. + */ + @Test + void testBuilderCreatesValidConstraint() { + ISource source = ISource.externalSource(TEST_SOURCE); + IMetapathExpression test = IMetapathExpression.compile("string-length(.) > 100"); + + IReportConstraint constraint = IReportConstraint.builder() + .source(source) + .test(test) + .build(); + + assertNotNull(constraint, "Constraint should not be null"); + } + + /** + * Test that getTest() returns the test Metapath expression. + */ + @Test + void testGetTestReturnsExpression() { + ISource source = ISource.externalSource(TEST_SOURCE); + IMetapathExpression test = IMetapathExpression.compile("contains(., 'deprecated')"); + + IReportConstraint constraint = IReportConstraint.builder() + .source(source) + .test(test) + .build(); + + assertSame(test, constraint.getTest(), "getTest() should return the same expression"); + } + + /** + * Test that constraint properties (id, level, message) are accessible. + */ + @Test + void testConstraintPropertiesAreAccessible() { + ISource source = ISource.externalSource(TEST_SOURCE); + IMetapathExpression test = IMetapathExpression.compile(". = 'deprecated'"); + String constraintId = "report-001"; + String constraintMessage = "This value is deprecated"; + Level constraintLevel = Level.WARNING; + + IReportConstraint constraint = IReportConstraint.builder() + .source(source) + .test(test) + .identifier(constraintId) + .message(constraintMessage) + .level(constraintLevel) + .build(); + + assertEquals(constraintId, constraint.getId(), "getId() should return the constraint id"); + assertEquals(constraintMessage, constraint.getMessage(), "getMessage() should return the message"); + assertEquals(constraintLevel, constraint.getLevel(), "getLevel() should return the configured level"); + } + + /** + * Test that the visitor pattern works correctly with visitReportConstraint. + */ + @Test + void testVisitorPatternWorksCorrectly() { + ISource source = ISource.externalSource(TEST_SOURCE); + IMetapathExpression test = IMetapathExpression.compile("true()"); + + IReportConstraint constraint = IReportConstraint.builder() + .source(source) + .test(test) + .build(); + + // Create a test visitor that tracks if visitReportConstraint was called + TestConstraintVisitor visitor = new TestConstraintVisitor(); + + Boolean result = constraint.accept(visitor, null); + + assertEquals(Boolean.TRUE, result, "Visitor should return true"); + assertEquals(1, visitor.getVisitReportCount(), + "visitReportConstraint should be called exactly once"); + } + + /** + * Test that the default level is INFORMATIONAL. + */ + @Test + void testDefaultLevelIsInformational() { + ISource source = ISource.externalSource(TEST_SOURCE); + IMetapathExpression test = IMetapathExpression.compile("true()"); + + IReportConstraint constraint = IReportConstraint.builder() + .source(source) + .test(test) + .build(); + + assertEquals(Level.INFORMATIONAL, constraint.getLevel(), + "Default level should be INFORMATIONAL"); + } + + /** + * Test that the constraint type is REPORT. + */ + @Test + void testConstraintTypeIsReport() { + ISource source = ISource.externalSource(TEST_SOURCE); + IMetapathExpression test = IMetapathExpression.compile("true()"); + + IReportConstraint constraint = IReportConstraint.builder() + .source(source) + .test(test) + .build(); + + assertEquals(Type.REPORT, constraint.getType(), "getType() should return REPORT"); + } + + /** + * Test that building without a test expression throws an exception. + */ + @Test + void testBuilderWithoutTestThrowsException() { + ISource source = ISource.externalSource(TEST_SOURCE); + + IReportConstraint.Builder builder = IReportConstraint.builder() + .source(source); + + assertThrows(NullPointerException.class, builder::build, + "Building without test should throw NullPointerException"); + } + + /** + * Test that building without a source throws an exception. + */ + @Test + void testBuilderWithoutSourceThrowsException() { + IMetapathExpression test = IMetapathExpression.compile("true()"); + + IReportConstraint.Builder builder = IReportConstraint.builder() + .test(test); + + assertThrows(NullPointerException.class, builder::build, + "Building without source should throw NullPointerException"); + } + + // ========================================================================= + // Validation Pipeline Tests + // Report constraints generate findings when test is TRUE (opposite of expect) + // ========================================================================= + + /** + * Test that report constraint generates a finding when test evaluates to TRUE. + *

+ * This is the opposite of expect constraints, which generate findings when test + * evaluates to FALSE. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportConstraintGeneratesFindingWhenTestIsTrue() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + // Create a flag with value "deprecated-value" + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("deprecated-value")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + // Create report constraint with test that evaluates to TRUE + // This should generate a finding because report fires on TRUE + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .test(IMetapathExpression.compile("contains(., 'deprecated')")) + .message("This value is deprecated") + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + assertAll( + () -> assertTrue(handler.isPassing(), + "Validation should pass because default level is INFORMATIONAL"), + () -> assertThat("should have 1 finding", handler.getFindings(), hasSize(1)), + () -> assertThat("finding should be for the flag node", handler.getFindings(), + hasItem(hasProperty("node", is(flag)))), + () -> assertThat("finding should have the custom message", handler.getFindings(), + hasItem(hasProperty("message", is("This value is deprecated"))))); + } + + /** + * Test that report constraint does NOT generate a finding when test evaluates + * to FALSE. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportConstraintNoFindingWhenTestIsFalse() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + // Create a flag with value "normal-value" + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("normal-value")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + // Create report constraint with test that evaluates to FALSE + // This should NOT generate a finding because report only fires on TRUE + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .test(IMetapathExpression.compile("contains(., 'deprecated')")) + .message("This value is deprecated") + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + assertAll( + () -> assertTrue(handler.isPassing(), "Validation should pass"), + () -> assertThat("should have no findings", handler.getFindings(), hasSize(0))); + } + + /** + * Test that report constraint with ERROR level causes validation failure. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportConstraintWithErrorLevel() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("forbidden-value")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + // Create report constraint with ERROR level + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .level(Level.ERROR) + .test(IMetapathExpression.compile("contains(., 'forbidden')")) + .message("Forbidden value detected") + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + assertAll( + () -> assertFalse(handler.isPassing(), + "Validation should fail because report with ERROR level fired"), + () -> assertThat("should have 1 finding", handler.getFindings(), hasSize(1)), + () -> assertThat("finding should have ERROR severity", handler.getFindings(), + hasItem(hasProperty("severity", is(Level.ERROR))))); + } + + /** + * Test that report constraint with WARNING level does not cause validation + * failure but still records the finding. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportConstraintWithWarningLevel() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("warning-value")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + // Create report constraint with WARNING level + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .level(Level.WARNING) + .test(IMetapathExpression.compile("contains(., 'warning')")) + .message("Warning: check this value") + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + assertAll( + () -> assertTrue(handler.isPassing(), + "Validation should pass because WARNING level does not fail validation"), + () -> assertThat("should have 1 finding", handler.getFindings(), hasSize(1)), + () -> assertThat("finding should have WARNING severity", handler.getFindings(), + hasItem(hasProperty("severity", is(Level.WARNING))))); + } + + /** + * Test that report constraint with CRITICAL level causes validation failure. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportConstraintWithCriticalLevel() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("critical-issue")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + // Create report constraint with CRITICAL level + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .level(Level.CRITICAL) + .test(IMetapathExpression.compile("contains(., 'critical')")) + .message("Critical issue detected!") + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + assertAll( + () -> assertFalse(handler.isPassing(), + "Validation should fail because CRITICAL level report fired"), + () -> assertThat("should have 1 finding", handler.getFindings(), hasSize(1)), + () -> assertThat("finding should have CRITICAL severity", handler.getFindings(), + hasItem(hasProperty("severity", is(Level.CRITICAL))))); + } + + /** + * Test that report constraint uses the custom message in finding. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportConstraintCustomMessage() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("test")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + String customMessage = "This is a custom report message"; + + // Create report constraint with custom message + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .test(IMetapathExpression.compile("true()")) + .message(customMessage) + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + assertAll( + () -> assertThat("should have 1 finding", handler.getFindings(), hasSize(1)), + () -> assertThat("finding should have the custom message", handler.getFindings(), + hasItem(hasProperty("message", is(customMessage))))); + } + + /** + * Test report vs expect semantics - verify they are opposites. + *

+ * Report fires when test is TRUE. Expect fails when test is FALSE. Same + * expression should produce opposite outcomes. + * + * @throws ConstraintValidationException + * if an error occurred during validation + */ + @SuppressWarnings("null") + @Test + void testReportAndExpectAreOpposites() throws ConstraintValidationException { + MockNodeItemFactory itemFactory = new MockNodeItemFactory(); + + // Value that makes "contains(., 'deprecated')" return TRUE + IFlagNodeItem flag = itemFactory.flag(qname("value"), IStringItem.valueOf("deprecated-value")); + + IFlagDefinition flagDefinition = mock(IFlagDefinition.class); + + ISource source = mock(ISource.class); + + // Report with test that evaluates to TRUE - should fire + IReportConstraint reportConstraint = IReportConstraint.builder() + .source(source) + .level(Level.ERROR) + .test(IMetapathExpression.compile("contains(., 'deprecated')")) + .message("Report: deprecated detected") + .build(); + + // Expect with same test that evaluates to TRUE - should NOT fire + IExpectConstraint expectConstraint = IExpectConstraint.builder() + .source(source) + .level(Level.ERROR) + .test(IMetapathExpression.compile("contains(., 'deprecated')")) + .message("Expect: deprecated NOT detected") + .build(); + + doReturn(flagDefinition).when(flag).getDefinition(); + doReturn("flag/path").when(flag).toPath(any(IPathFormatter.class)); + + doReturn(CollectionUtil.emptyMap()).when(flagDefinition).getLetExpressions(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getAllowedValuesConstraints(); + doReturn(CollectionUtil.singletonList(expectConstraint)).when(flagDefinition).getExpectConstraints(); + doReturn(CollectionUtil.singletonList(reportConstraint)).when(flagDefinition).getReportConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getMatchesConstraints(); + doReturn(CollectionUtil.emptyList()).when(flagDefinition).getIndexHasKeyConstraints(); + + StaticContext staticContext = StaticContext.instance(); + doReturn(staticContext).when(source).getStaticContext(); + + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + DynamicContext dynamicContext = new DynamicContext(staticContext); + validator.validate(flag, dynamicContext); + validator.finalizeValidation(dynamicContext); + + // Report fires when TRUE (has finding), Expect passes when TRUE (no finding) + // So only Report should generate a finding + assertAll( + () -> assertFalse(handler.isPassing(), + "Validation should fail because Report with ERROR level fired"), + () -> assertThat("should have exactly 1 finding (from Report, not Expect)", + handler.getFindings(), hasSize(1)), + () -> assertThat("finding message should be from Report constraint", + handler.getFindings(), + hasItem(hasProperty("message", is("Report: deprecated detected"))))); + } + + /** + * A test visitor implementation to verify the visitor pattern. + */ + private static final class TestConstraintVisitor implements IConstraintVisitor { + private int visitReportCount; + + int getVisitReportCount() { + return visitReportCount; + } + + @Override + public Boolean visitAllowedValues(@NonNull IAllowedValuesConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitCardinalityConstraint(@NonNull ICardinalityConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitExpectConstraint(@NonNull IExpectConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitMatchesConstraint(@NonNull IMatchesConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitIndexConstraint(@NonNull IIndexConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitIndexHasKeyConstraint(@NonNull IIndexHasKeyConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitUniqueConstraint(@NonNull IUniqueConstraint constraint, Void state) { + return Boolean.FALSE; + } + + @Override + public Boolean visitReportConstraint(@NonNull IReportConstraint constraint, Void state) { + visitReportCount++; + return Boolean.TRUE; + } + } +} diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGenerator.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGenerator.java index 631ae0b8c..361a8cfeb 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGenerator.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGenerator.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.core.model.constraint.IConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IExpectConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IIndexConstraint; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IIndexHasKeyConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IKeyField; import gov.nist.secauto.metaschema.core.model.constraint.ILet; @@ -45,6 +46,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.KeyField; import gov.nist.secauto.metaschema.databind.model.annotations.Let; import gov.nist.secauto.metaschema.databind.model.annotations.Matches; +import gov.nist.secauto.metaschema.databind.model.annotations.Report; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import org.apache.logging.log4j.LogBuilder; @@ -144,6 +146,7 @@ public static void buildValueConstraints( applyIndexHasKeyConstraints(annotation, definition.getIndexHasKeyConstraints()); applyMatchesConstraints(annotation, definition.getMatchesConstraints()); applyExpectConstraints(annotation, definition.getExpectConstraints()); + applyReportConstraints(annotation, definition.getReportConstraints()); builder.addMember("valueConstraints", "$L", annotation.build()); } @@ -166,9 +169,10 @@ public static void buildValueConstraints( List indexHasKey = definition.getIndexHasKeyConstraints(); List matches = definition.getMatchesConstraints(); List expects = definition.getExpectConstraints(); + List reports = definition.getReportConstraints(); if (!lets.isEmpty() || !allowedValues.isEmpty() || !indexHasKey.isEmpty() || !matches.isEmpty() - || !expects.isEmpty()) { + || !expects.isEmpty() || !reports.isEmpty()) { AnnotationSpec.Builder annotation = AnnotationSpec.builder(ValueConstraints.class); assert annotation != null; @@ -177,6 +181,7 @@ public static void buildValueConstraints( applyIndexHasKeyConstraints(annotation, indexHasKey); applyMatchesConstraints(annotation, matches); applyExpectConstraints(annotation, expects); + applyReportConstraints(annotation, reports); builder.addMember("valueConstraints", "$L", annotation.build()); } @@ -370,6 +375,31 @@ private static void applyExpectConstraints( } } + private static void applyReportConstraints( + @NonNull AnnotationSpec.Builder annotation, + @NonNull List constraints) { + for (IReportConstraint constraint : constraints) { + assert constraint != null; + + AnnotationSpec.Builder constraintAnnotation = AnnotationSpec.builder(Report.class); + + buildConstraint(Report.class, constraintAnnotation, constraint); + + constraintAnnotation.addMember("test", "$S", constraint.getTest().getPath()); + + if (constraint.getMessage() != null) { + constraintAnnotation.addMember("message", "$S", constraint.getMessage()); + } + + MarkupMultiline remarks = constraint.getRemarks(); + if (remarks != null) { + constraintAnnotation.addMember("remarks", "$S", remarks.toMarkdown()); + } + + annotation.addMember("report", "$L", constraintAnnotation.build()); + } + } + private static void applyIndexConstraints( @NonNull AnnotationSpec.Builder annotation, @NonNull List constraints) { diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/Report.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/Report.java new file mode 100644 index 000000000..66db5144a --- /dev/null +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/Report.java @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.databind.model.annotations; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint; +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * This annotation defines a report condition in the context of the containing + * annotation. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + */ +@Documented +@Retention(RUNTIME) +@Target(ANNOTATION_TYPE) +public @interface Report { + /** + * An optional identifier for the constraint, which must be unique to only this + * constraint. + * + * @return the identifier if provided or an empty string otherwise + */ + @SuppressWarnings("PMD.ShortMethodName") + @NonNull + String id() default ""; + + /** + * An optional formal name for the constraint. + * + * @return the formal name if provided or an empty string otherwise + */ + @NonNull + String formalName() default ""; + + /** + * An optional description of the constraint. + * + * @return the description if provided or an empty string otherwise + */ + @NonNull + String description() default ""; + + /** + * The significance of a violation of this constraint. + *

+ * The default level for report constraints is {@link Level#INFORMATIONAL}, + * which differs from expect constraints that default to {@link Level#ERROR}. + * + * @return the level + */ + @NonNull + Level level() default IConstraint.Level.INFORMATIONAL; + + /** + * An optional metapath that points to the target flag or field value that the + * constraint applies to. If omitted the target will be ".", which means the + * target is the value of the {@link BoundFlag}, {@link BoundField} or + * {@link BoundFieldValue} annotation the constraint appears on. In the prior + * case, this annotation may only appear on a {@link BoundField} if the field + * has no flags, which results in a {@link BoundField} annotation on a field + * instance with a scalar, data type value. + * + * @return the target metapath + */ + @NonNull + String target() default "."; + + /** + * An optional set of properties associated with this constraint. + * + * @return the properties or an empty array with no properties + */ + Property[] properties() default {}; + + /** + * A metapath that is expected to evaluate to {@code true} when a finding should + * be reported. + *

+ * This is the opposite of expect constraints - report constraints fire when the + * test is true. + * + * @return a metapath expression + */ + @NonNull + String test(); + + /** + * The message to emit when the constraint is violated. + * + * @return the message or an empty string otherwise + */ + @NonNull + String message() default ""; + + /** + * Any remarks about the constraint, encoded as an escaped Markdown string. + * + * @return an encoded markdown string or an empty string if no remarks are + * provided + */ + @NonNull + String remarks() default ""; +} diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/ValueConstraints.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/ValueConstraints.java index 5eff46ca5..94b497aef 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/ValueConstraints.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/annotations/ValueConstraints.java @@ -65,4 +65,17 @@ */ @NonNull Expect[] expect() default {}; + + /** + * Get the report constraints for the type or field this annotation is applied + * to. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + * + * @return the report constraints or an empty array if no report constraints are + * defined + */ + @NonNull + Report[] report() default {}; } diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintFactory.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintFactory.java index acba81cd0..b312ea0b8 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintFactory.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintFactory.java @@ -21,6 +21,7 @@ import gov.nist.secauto.metaschema.core.model.constraint.IExpectConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IIndexConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IIndexHasKeyConstraint; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IKeyField; import gov.nist.secauto.metaschema.core.model.constraint.ILet; import gov.nist.secauto.metaschema.core.model.constraint.IMatchesConstraint; @@ -37,6 +38,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.Let; import gov.nist.secauto.metaschema.databind.model.annotations.Matches; import gov.nist.secauto.metaschema.databind.model.annotations.ModelUtil; +import gov.nist.secauto.metaschema.databind.model.annotations.Report; import gov.nist.secauto.metaschema.databind.model.annotations.NullJavaTypeAdapter; import gov.nist.secauto.metaschema.databind.model.annotations.Property; @@ -305,6 +307,37 @@ static IExpectConstraint newExpectConstraint(@NonNull Expect constraint, @NonNul return builder.build(); } + /** + * Create a new report constraint from the provided annotation. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + * + * @param constraint + * the annotation containing the constraint configuration + * @param source + * the source of the constraint + * @return a new report constraint + */ + @NonNull + static IReportConstraint newReportConstraint(@NonNull Report constraint, @NonNull ISource source) { + IReportConstraint.Builder builder = IReportConstraint.builder(); + applyId(builder, constraint.id()); + applyFormalName(builder, constraint.formalName()); + applyDescription(builder, constraint.description()); + builder + .source(source) + .level(constraint.level()); + applyTarget(builder, metapath(constraint.target(), source)); + applyProperties(builder, constraint.properties()); + applyMessage(builder, constraint.message()); + applyRemarks(builder, constraint.remarks()); + + builder.test(metapath(constraint.test(), source)); + + return builder.build(); + } + @Nullable static Integer toCardinality(int value) { return value < 0 ? null : value; diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintSupport.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintSupport.java index 836bdba99..ce5fabf8d 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintSupport.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/impl/ConstraintSupport.java @@ -53,6 +53,9 @@ public static void parse( // NOPMD - intentional Arrays.stream(valueAnnotation.expect()) .map(annotation -> ConstraintFactory.newExpectConstraint(annotation, source)) .forEachOrdered(set::addConstraint); + Arrays.stream(valueAnnotation.report()) + .map(annotation -> ConstraintFactory.newReportConstraint(annotation, source)) + .forEachOrdered(set::addConstraint); } } diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedExpectConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedExpectConstraint.java index 1f7bbb546..ed06c4fa1 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedExpectConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedExpectConstraint.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.util.LinkedList; import java.util.List; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; @@ -36,7 +37,8 @@ formalName = "Expect Condition Constraint", name = "targeted-expect-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedExpectConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedExpectConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedHasCardinalityConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedHasCardinalityConstraint.java index 808f711de..d2d25be1f 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedHasCardinalityConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedHasCardinalityConstraint.java @@ -29,6 +29,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.math.BigInteger; import java.util.LinkedList; import java.util.List; @@ -39,7 +40,8 @@ formalName = "Targeted Cardinality Constraint", name = "targeted-has-cardinality-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedHasCardinalityConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedHasCardinalityConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexConstraint.java index aee1b72d8..7cbb92dff 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexConstraint.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.util.LinkedList; import java.util.List; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; @@ -36,7 +37,8 @@ formalName = "Targeted Index Constraint", name = "targeted-index-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedIndexConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedIndexConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexHasKeyConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexHasKeyConstraint.java index 186fe574b..eff4c5745 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexHasKeyConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIndexHasKeyConstraint.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.util.LinkedList; import java.util.List; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; @@ -36,7 +37,8 @@ formalName = "Targeted Index Has Key Constraint", name = "targeted-index-has-key-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedIndexHasKeyConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedIndexHasKeyConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIsUniqueConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIsUniqueConstraint.java index 03b9b7ea3..be5ef77ad 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIsUniqueConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedIsUniqueConstraint.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.util.LinkedList; import java.util.List; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; @@ -36,7 +37,8 @@ formalName = "Targeted Unique Constraint", name = "targeted-is-unique-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedIsUniqueConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedIsUniqueConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedMatchesConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedMatchesConstraint.java index 47ae8e31c..a0c88b321 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedMatchesConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedMatchesConstraint.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.util.LinkedList; import java.util.List; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; @@ -36,7 +37,8 @@ formalName = "Value Matches Constraint", name = "targeted-matches-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedMatchesConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedMatchesConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedReportConstraint.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedReportConstraint.java index b3550b07d..7f3e06aab 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedReportConstraint.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/binding/TargetedReportConstraint.java @@ -27,6 +27,7 @@ import gov.nist.secauto.metaschema.databind.model.annotations.MetaschemaAssembly; import gov.nist.secauto.metaschema.databind.model.annotations.ValueConstraints; import gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase; +import gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase; import java.util.LinkedList; import java.util.List; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; @@ -36,7 +37,8 @@ formalName = "Report Condition Constraint", name = "targeted-report-constraint", moduleClass = MetaschemaModelModule.class) -public class TargetedReportConstraint implements IBoundObject, IConfigurableMessageConstraintBase { +public class TargetedReportConstraint + implements IBoundObject, ITargetedConstraintBase, IConfigurableMessageConstraintBase { private final IMetaschemaData __metaschemaData; @BoundFlag( diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/impl/ConstraintBindingSupport.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/impl/ConstraintBindingSupport.java index 766dfb648..ba6339418 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/impl/ConstraintBindingSupport.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/impl/ConstraintBindingSupport.java @@ -24,6 +24,7 @@ import gov.nist.secauto.metaschema.core.model.constraint.ILet; import gov.nist.secauto.metaschema.core.model.constraint.IMatchesConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IModelConstrained; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IUniqueConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IValueConstrained; import gov.nist.secauto.metaschema.core.util.ObjectUtils; @@ -47,6 +48,7 @@ import gov.nist.secauto.metaschema.databind.model.metaschema.binding.TargetedIndexHasKeyConstraint; import gov.nist.secauto.metaschema.databind.model.metaschema.binding.TargetedIsUniqueConstraint; import gov.nist.secauto.metaschema.databind.model.metaschema.binding.TargetedMatchesConstraint; +import gov.nist.secauto.metaschema.databind.model.metaschema.binding.TargetedReportConstraint; import java.math.BigInteger; import java.util.List; @@ -128,6 +130,9 @@ public static void parse( } else if (ruleObj instanceof TargetedMatchesConstraint) { IMatchesConstraint constraint = newMatches((TargetedMatchesConstraint) ruleObj, source); constraintSet.addConstraint(constraint); + } else if (ruleObj instanceof TargetedReportConstraint) { + IReportConstraint constraint = newReport((TargetedReportConstraint) ruleObj, source); + constraintSet.addConstraint(constraint); } } } @@ -171,6 +176,9 @@ public static void parse( } else if (ruleObj instanceof TargetedIsUniqueConstraint) { IUniqueConstraint constraint = newUnique((TargetedIsUniqueConstraint) ruleObj, source); constraintSet.addConstraint(constraint); + } else if (ruleObj instanceof TargetedReportConstraint) { + IReportConstraint constraint = newReport((TargetedReportConstraint) ruleObj, source); + constraintSet.addConstraint(constraint); } } } @@ -268,6 +276,29 @@ private static IExpectConstraint newExpect( return builder.build(); } + /** + * Create a new report constraint from a parsed binding object. + *

+ * Report constraints generate findings when their test expression evaluates to + * {@code true}, which is the opposite of expect constraints. + * + * @param obj + * the parsed constraint binding object + * @param source + * the source of the constraint + * @return the new report constraint + */ + @NonNull + private static IReportConstraint newReport( + @NonNull TargetedReportConstraint obj, + @NonNull ISource source) { + IReportConstraint.Builder builder = IReportConstraint.builder() + .test(metapath(ObjectUtils.requireNonNull(obj.getTest()), source)); + applyConfigurableCommonValues(obj, obj.getTarget(), source, builder); + + return builder.build(); + } + @NonNull private static > T handleKeyConstraints( @NonNull List keys, diff --git a/databind/src/main/metaschema-bindings/metaschema-model-bindings.xml b/databind/src/main/metaschema-bindings/metaschema-model-bindings.xml index a6ffece9a..90e09d4b6 100644 --- a/databind/src/main/metaschema-bindings/metaschema-model-bindings.xml +++ b/databind/src/main/metaschema-bindings/metaschema-model-bindings.xml @@ -79,36 +79,43 @@ + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase + gov.nist.secauto.metaschema.databind.model.metaschema.ITargetedConstraintBase gov.nist.secauto.metaschema.databind.model.metaschema.IConfigurableMessageConstraintBase diff --git a/databind/src/test/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGeneratorTest.java b/databind/src/test/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGeneratorTest.java index 6ad0290f0..c8385b867 100644 --- a/databind/src/test/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGeneratorTest.java +++ b/databind/src/test/java/gov/nist/secauto/metaschema/databind/codegen/impl/AnnotationGeneratorTest.java @@ -63,6 +63,8 @@ void letAssignmentTest() { will(returnValue(List.of())); allowing(flag).getExpectConstraints(); will(returnValue(List.of())); + allowing(flag).getReportConstraints(); + will(returnValue(List.of())); } }); diff --git a/databind/src/test/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoaderTest.java b/databind/src/test/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoaderTest.java index d862390f4..a73cb4cc9 100644 --- a/databind/src/test/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoaderTest.java +++ b/databind/src/test/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoaderTest.java @@ -7,19 +7,33 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; +import gov.nist.secauto.metaschema.core.metapath.StaticContext; import gov.nist.secauto.metaschema.core.model.IAssemblyDefinition; import gov.nist.secauto.metaschema.core.model.IConstraintLoader; +import gov.nist.secauto.metaschema.core.model.ISource; import gov.nist.secauto.metaschema.core.model.MetaschemaException; +import gov.nist.secauto.metaschema.core.model.constraint.AssemblyConstraintSet; +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint; import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet; +import gov.nist.secauto.metaschema.core.model.constraint.IModelConstrained; +import gov.nist.secauto.metaschema.core.model.constraint.IReportConstraint; import gov.nist.secauto.metaschema.core.qname.IEnhancedQName; import gov.nist.secauto.metaschema.databind.IBindingContext; +import gov.nist.secauto.metaschema.databind.io.IBoundLoader; import gov.nist.secauto.metaschema.databind.model.IBoundModule; +import gov.nist.secauto.metaschema.databind.model.metaschema.binding.AssemblyConstraints; +import gov.nist.secauto.metaschema.databind.model.metaschema.binding.MetaschemaMetaConstraints; +import gov.nist.secauto.metaschema.databind.model.metaschema.binding.TargetedReportConstraint; +import gov.nist.secauto.metaschema.databind.model.metaschema.impl.ConstraintBindingSupport; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -75,4 +89,453 @@ void testValid() throws MetaschemaException, IOException { () -> assertEquals(1, level3 == null ? 0 : level3.getLetExpressions().size(), "level 3 let"), () -> assertEquals(1, level3 == null ? 0 : level3.getExpectConstraints().size(), "level 3 expect")); } + + @Test + void testReportConstraintLoading() throws MetaschemaException, IOException { + IBindingContext bindingContext = IBindingContext.newInstance(); + IConstraintLoader loader = new BindingConstraintLoader(bindingContext); + + List constraints = loader.load( + Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml")); + assertEquals(1, constraints.size(), "should load exactly one constraint set"); + + // Get the constraint set and verify it has the context with report constraint + IConstraintSet constraintSet = constraints.get(0); + assertNotNull(constraintSet, "constraint set should not be null"); + + // Now test that it applies to level1 + Path compileDir = Paths.get("target/generated-test-modules/meta-constraints-report/"); + Files.createDirectories(compileDir); + + bindingContext = IBindingContext.builder() + .compilePath(compileDir) + .constraintSet(constraints) + .build(); + + IBindingMetaschemaModule metaschema = bindingContext.loadMetaschema( + Paths.get("src/test/resources/content/constraints/meta-constraints/metaschema.xml")); + IBoundModule module = bindingContext.registerModule(metaschema); + + final IAssemblyDefinition level1 + = module.getAssemblyDefinitionByName(IEnhancedQName.of(NS, "level1").getIndexPosition()); + + assertNotNull(level1, "level1 assembly should exist"); + + // Debug: print all constraints for level1 + List allConstraints = level1.getConstraints(); + StringBuilder debug = new StringBuilder(); + debug.append("All constraints on level1: ").append(allConstraints.size()).append("\n"); + for (IConstraint c : allConstraints) { + debug.append(" - ").append(c.getClass().getSimpleName()) + .append(": ").append(c.getId()) + .append(" (type: ").append(c.getType()).append(")\n"); + } + + // Verify report constraints were loaded + List reportConstraints = level1.getReportConstraints(); + debug.append("Report constraints on level1: ").append(reportConstraints.size()); + + // Use assertEquals with debug message to ensure we see the output + assertEquals(1, reportConstraints.size(), + "should have exactly one report constraint. Debug info:\n" + debug); + assertAll( + () -> assertNotNull(reportConstraints, "report constraints list should not be null"), + () -> assertFalse(reportConstraints.isEmpty(), "should have at least one report constraint"), + () -> assertEquals(1, reportConstraints.size(), "should have exactly one report constraint"), + () -> assertEquals("level1-report", reportConstraints.get(0).getId(), "constraint should have correct id")); + } + + /** + * Diagnostic test to trace through each step of the constraint loading process. + * This helps identify where report constraints might be lost. + */ + @Test + void testTraceReportConstraintLoading() throws IOException { + StringBuilder trace = new StringBuilder(); + trace.append("=== Tracing Report Constraint Loading ===\n\n"); + + // Step 1: Parse YAML directly to binding object + trace.append("STEP 1: Parse YAML to binding object\n"); + IBindingContext bindingContext = IBindingContext.newInstance(); + IBoundLoader loader = bindingContext.newBoundLoader(); + URI resourceUri = Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml") + .toUri(); + + Object constraintsDocument = loader.load(resourceUri); + trace.append(" Document type: ").append(constraintsDocument.getClass().getName()).append("\n"); + assertEquals(MetaschemaMetaConstraints.class, constraintsDocument.getClass(), + "Should parse to MetaschemaMetaConstraints"); + + MetaschemaMetaConstraints metaConstraints = (MetaschemaMetaConstraints) constraintsDocument; + + // Step 2: Check contexts + trace.append("\nSTEP 2: Check parsed contexts\n"); + List contexts + = metaConstraints.getContexts(); + trace.append(" Number of contexts: ").append(contexts == null ? "null" : contexts.size()).append("\n"); + assertNotNull(contexts, "Contexts should not be null"); + assertFalse(contexts.isEmpty(), "Should have at least one context"); + + // Step 3: Check first context's constraints + trace.append("\nSTEP 3: Check first context's constraints\n"); + gov.nist.secauto.metaschema.databind.model.metaschema.binding.MetapathContext firstContext = contexts.get(0); + trace.append(" First context metapaths: "); + if (firstContext.getMetapaths() != null) { + for (var mp : firstContext.getMetapaths()) { + trace.append(mp.getTarget()).append(" "); + } + } + trace.append("\n"); + + AssemblyConstraints assemblyConstraints = firstContext.getConstraints(); + trace.append(" Constraints object: ").append(assemblyConstraints == null ? "null" : "present").append("\n"); + assertNotNull(assemblyConstraints, "Constraints should not be null"); + + // Step 4: Check rules in constraints + trace.append("\nSTEP 4: Check rules in AssemblyConstraints\n"); + List rules = assemblyConstraints.getRules(); + trace.append(" Number of rules: ").append(rules == null ? "null" : rules.size()).append("\n"); + assertNotNull(rules, "Rules should not be null"); + + if (rules != null && !rules.isEmpty()) { + for (int i = 0; i < rules.size(); i++) { + Object rule = rules.get(i); + trace.append(" Rule ").append(i).append(": "); + if (rule == null) { + trace.append("null\n"); + } else { + trace.append(rule.getClass().getName()).append("\n"); + if (rule instanceof TargetedReportConstraint) { + TargetedReportConstraint report = (TargetedReportConstraint) rule; + trace.append(" ID: ").append(report.getId()).append("\n"); + trace.append(" Test: ").append(report.getTest()).append("\n"); + trace.append(" Target: ").append(report.getTarget()).append("\n"); + } else if (rule instanceof IConstraintBase) { + IConstraintBase constraint = (IConstraintBase) rule; + trace.append(" ID: ").append(constraint.getId()).append("\n"); + } + } + } + } else { + trace.append(" WARNING: No rules found!\n"); + } + + // Step 5: Check if TargetedReportConstraint is in the rules + trace.append("\nSTEP 5: Look for TargetedReportConstraint\n"); + boolean foundReport = false; + for (Object rule : rules) { + if (rule instanceof TargetedReportConstraint) { + foundReport = true; + trace.append(" FOUND TargetedReportConstraint!\n"); + break; + } + } + if (!foundReport) { + trace.append(" NOT FOUND - TargetedReportConstraint missing from rules\n"); + } + + // Output trace for debugging + System.out.println(trace); + + // The assertion that matters + assertEquals(1, rules.size(), "Should have exactly one rule. Trace:\n" + trace); + assertEquals(TargetedReportConstraint.class, rules.get(0).getClass(), + "Rule should be TargetedReportConstraint. Trace:\n" + trace); + } + + /** + * Test that ConstraintBindingSupport.parse() correctly converts + * TargetedReportConstraint to IReportConstraint and adds it to the + * AssemblyConstraintSet. + */ + @Test + void testConstraintBindingSupportParsesReportConstraint() throws IOException { + StringBuilder trace = new StringBuilder(); + trace.append("=== Testing ConstraintBindingSupport.parse() ===\n\n"); + + // Step 1: Parse YAML directly to binding object + IBindingContext bindingContext = IBindingContext.newInstance(); + IBoundLoader loader = bindingContext.newBoundLoader(); + URI resourceUri = Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml") + .toUri(); + + MetaschemaMetaConstraints metaConstraints = (MetaschemaMetaConstraints) loader.load(resourceUri); + var contexts = metaConstraints.getContexts(); + assertFalse(contexts.isEmpty(), "Should have at least one context"); + + var firstContext = contexts.get(0); + AssemblyConstraints assemblyConstraints = firstContext.getConstraints(); + assertNotNull(assemblyConstraints, "Should have assembly constraints"); + + trace.append("YAML parsed successfully. Rules count: ").append(assemblyConstraints.getRules().size()).append("\n"); + + // Step 2: Create AssemblyConstraintSet and parse constraints into it + StaticContext staticContext = StaticContext.builder() + .baseUri(resourceUri) + .useWildcardWhenNamespaceNotDefaulted(true) + .build(); + ISource source = ISource.externalSource(staticContext, false); + + AssemblyConstraintSet constraintSet = new AssemblyConstraintSet(source); + + trace.append("Before ConstraintBindingSupport.parse():\n"); + trace.append(" expect constraints: ").append(constraintSet.getExpectConstraints().size()).append("\n"); + trace.append(" report constraints: ").append(constraintSet.getReportConstraints().size()).append("\n"); + trace.append(" all constraints: ").append(constraintSet.getConstraints().size()).append("\n"); + + // Parse using ConstraintBindingSupport + ConstraintBindingSupport.parse(constraintSet, assemblyConstraints, source); + + trace.append("\nAfter ConstraintBindingSupport.parse():\n"); + trace.append(" expect constraints: ").append(constraintSet.getExpectConstraints().size()).append("\n"); + trace.append(" report constraints: ").append(constraintSet.getReportConstraints().size()).append("\n"); + trace.append(" all constraints: ").append(constraintSet.getConstraints().size()).append("\n"); + + // List all constraints with their types + for (IConstraint c : constraintSet.getConstraints()) { + trace.append(" - ").append(c.getClass().getSimpleName()).append(": id=").append(c.getId()).append("\n"); + } + + // Report constraints specifically + trace.append("\nReport constraints details:\n"); + for (IReportConstraint rc : constraintSet.getReportConstraints()) { + trace.append(" - id=").append(rc.getId()) + .append(", test=").append(rc.getTest().getPath()) + .append(", target=").append(rc.getTarget().getPath()).append("\n"); + } + + System.out.println(trace); + + // Assertions + assertEquals(1, constraintSet.getReportConstraints().size(), + "Should have exactly one report constraint. Trace:\n" + trace); + assertEquals("level1-report", constraintSet.getReportConstraints().get(0).getId(), + "Report constraint should have correct id"); + } + + /** + * Test the full BindingConstraintLoader.load() path to verify report + * constraints are in the returned IConstraintSet. + */ + @Test + void testBindingConstraintLoaderReturnsReportConstraints() throws MetaschemaException, IOException { + StringBuilder trace = new StringBuilder(); + trace.append("=== Testing BindingConstraintLoader.load() ===\n\n"); + + IBindingContext bindingContext = IBindingContext.newInstance(); + IConstraintLoader loader = new BindingConstraintLoader(bindingContext); + + List constraintSets = loader.load( + Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml")); + + trace.append("Constraint sets loaded: ").append(constraintSets.size()).append("\n\n"); + assertEquals(1, constraintSets.size(), "Should have exactly one constraint set"); + + IConstraintSet constraintSet = constraintSets.get(0); + trace.append("Constraint set type: ").append(constraintSet.getClass().getName()).append("\n"); + + // For MetaConstraintSet, we need to check the contexts + if (constraintSet instanceof gov.nist.secauto.metaschema.core.model.constraint.MetaConstraintSet) { + trace.append("MetaConstraintSet detected - need to check contexts\n"); + // Can't directly access contexts, but we can test the behavior + } + + // Now test applying constraints to see if they work + Path compileDir = Paths.get("target/generated-test-modules/meta-constraints-report-trace/"); + Files.createDirectories(compileDir); + + bindingContext = IBindingContext.builder() + .compilePath(compileDir) + .constraintSet(constraintSets) + .build(); + + IBindingMetaschemaModule metaschema = bindingContext.loadMetaschema( + Paths.get("src/test/resources/content/constraints/meta-constraints/metaschema.xml")); + + trace.append("\nBefore registerModule:\n"); + IAssemblyDefinition level1Before + = metaschema.getAssemblyDefinitionByName(IEnhancedQName.of(NS, "level1").getIndexPosition()); + if (level1Before != null) { + trace.append(" level1 report constraints: ").append(level1Before.getReportConstraints().size()).append("\n"); + trace.append(" level1 expect constraints: ").append(level1Before.getExpectConstraints().size()).append("\n"); + } + + IBoundModule module = bindingContext.registerModule(metaschema); + + trace.append("\nAfter registerModule:\n"); + IAssemblyDefinition level1 = module.getAssemblyDefinitionByName(IEnhancedQName.of(NS, "level1").getIndexPosition()); + assertNotNull(level1, "level1 should exist"); + + trace.append(" level1 all constraints: ").append(level1.getConstraints().size()).append("\n"); + trace.append(" level1 expect constraints: ").append(level1.getExpectConstraints().size()).append("\n"); + trace.append(" level1 report constraints: ").append(level1.getReportConstraints().size()).append("\n"); + + // List all constraints + for (IConstraint c : level1.getConstraints()) { + trace.append(" - ").append(c.getClass().getSimpleName()) + .append(": id=").append(c.getId()) + .append(", type=").append(c.getType()).append("\n"); + } + + System.out.println(trace); + + assertEquals(1, level1.getReportConstraints().size(), + "Should have exactly one report constraint on level1. Trace:\n" + trace); + } + + /** + * Test that expect constraints are preserved through the registerModule + * process. This verifies that constraints defined in the metaschema XML are + * properly embedded in generated code and available on the bound module. + */ + @Test + void testExpectConstraintPreservedThroughRegisterModule() throws MetaschemaException, IOException { + IBindingContext bindingContext = IBindingContext.newInstance(); + IConstraintLoader loader = new BindingConstraintLoader(bindingContext); + + List constraints = loader.load( + Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-valid.yaml")); + + Path compileDir = Paths.get("target/generated-test-modules/meta-constraints-trace-expect/"); + Files.createDirectories(compileDir); + + bindingContext = IBindingContext.builder() + .compilePath(compileDir) + .constraintSet(constraints) + .build(); + + IBindingMetaschemaModule metaschema = bindingContext.loadMetaschema( + Paths.get("src/test/resources/content/constraints/meta-constraints/metaschema.xml")); + + // Verify expect constraint exists before registerModule + IAssemblyDefinition level1Before + = metaschema.getAssemblyDefinitionByName(IEnhancedQName.of(NS, "level1").getIndexPosition()); + assertNotNull(level1Before, "level1 definition should exist before registerModule"); + assertEquals(1, level1Before.getExpectConstraints().size(), + "level1 should have 1 expect constraint before registerModule"); + + // Register the module + IBoundModule module = bindingContext.registerModule(metaschema); + + // Verify expect constraint exists after registerModule + IAssemblyDefinition level1After + = module.getAssemblyDefinitionByName(IEnhancedQName.of(NS, "level1").getIndexPosition()); + assertNotNull(level1After, "level1 definition should exist after registerModule"); + assertEquals(1, level1After.getExpectConstraints().size(), + "level1 should have 1 expect constraint after registerModule"); + } + + /** + * Parallel comparison test: Trace both expect and report constraints through + * the exact same loading path to identify where they diverge. + */ + @Test + void testCompareExpectVsReportYamlParsing() throws IOException { + StringBuilder comparison = new StringBuilder(); + comparison.append("=== Comparing Expect vs Report YAML Parsing ===\n\n"); + + IBindingContext bindingContext = IBindingContext.newInstance(); + IBoundLoader loader = bindingContext.newBoundLoader(); + + // Load both YAML files + URI expectUri = Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-valid.yaml") + .toUri(); + URI reportUri = Paths.get("src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml") + .toUri(); + + Object expectDoc = loader.load(expectUri); + Object reportDoc = loader.load(reportUri); + + comparison.append("Document types:\n"); + comparison.append(" Expect: ").append(expectDoc.getClass().getName()).append("\n"); + comparison.append(" Report: ").append(reportDoc.getClass().getName()).append("\n\n"); + + assertEquals(expectDoc.getClass(), reportDoc.getClass(), + "Both should parse to same document type"); + + MetaschemaMetaConstraints expectConstraints = (MetaschemaMetaConstraints) expectDoc; + MetaschemaMetaConstraints reportConstraints = (MetaschemaMetaConstraints) reportDoc; + + // Compare contexts + comparison.append("Contexts:\n"); + comparison.append(" Expect contexts: ").append( + expectConstraints.getContexts() == null ? "null" : expectConstraints.getContexts().size()).append("\n"); + comparison.append(" Report contexts: ").append( + reportConstraints.getContexts() == null ? "null" : reportConstraints.getContexts().size()).append("\n\n"); + + assertNotNull(expectConstraints.getContexts(), "Expect contexts should not be null"); + assertNotNull(reportConstraints.getContexts(), "Report contexts should not be null"); + assertFalse(expectConstraints.getContexts().isEmpty(), "Expect contexts should not be empty"); + assertFalse(reportConstraints.getContexts().isEmpty(), "Report contexts should not be empty"); + + // Compare first context's constraints + var expectFirstContext = expectConstraints.getContexts().get(0); + var reportFirstContext = reportConstraints.getContexts().get(0); + + comparison.append("First context metapaths:\n"); + comparison.append(" Expect: "); + for (var mp : expectFirstContext.getMetapaths()) { + comparison.append(mp.getTarget()).append(" "); + } + comparison.append("\n Report: "); + for (var mp : reportFirstContext.getMetapaths()) { + comparison.append(mp.getTarget()).append(" "); + } + comparison.append("\n\n"); + + // Compare rules + AssemblyConstraints expectAssemblyConstraints = expectFirstContext.getConstraints(); + AssemblyConstraints reportAssemblyConstraints = reportFirstContext.getConstraints(); + + comparison.append("AssemblyConstraints:\n"); + comparison.append(" Expect constraints: ").append( + expectAssemblyConstraints == null ? "null" : "present").append("\n"); + comparison.append(" Report constraints: ").append( + reportAssemblyConstraints == null ? "null" : "present").append("\n\n"); + + assertNotNull(expectAssemblyConstraints, "Expect AssemblyConstraints should not be null"); + assertNotNull(reportAssemblyConstraints, "Report AssemblyConstraints should not be null"); + + // Compare rules lists + List expectRules = expectAssemblyConstraints.getRules(); + List reportRules = reportAssemblyConstraints.getRules(); + + comparison.append("Rules:\n"); + comparison.append(" Expect rules count: ").append(expectRules == null ? "null" : expectRules.size()).append("\n"); + comparison.append(" Report rules count: ").append(reportRules == null ? "null" : reportRules.size()).append("\n"); + + if (expectRules != null && !expectRules.isEmpty()) { + comparison.append(" Expect rules types:\n"); + for (int i = 0; i < expectRules.size(); i++) { + Object rule = expectRules.get(i); + comparison.append(" [").append(i).append("] ").append( + rule == null ? "null" : rule.getClass().getSimpleName()).append("\n"); + } + } + + if (reportRules != null && !reportRules.isEmpty()) { + comparison.append(" Report rules types:\n"); + for (int i = 0; i < reportRules.size(); i++) { + Object rule = reportRules.get(i); + comparison.append(" [").append(i).append("] ").append( + rule == null ? "null" : rule.getClass().getSimpleName()).append("\n"); + } + } else { + comparison.append(" Report rules: EMPTY OR NULL - THIS IS THE PROBLEM!\n"); + } + + // Output comparison for debugging + System.out.println(comparison); + + // Key assertions + assertNotNull(expectRules, "Expect rules should not be null"); + assertNotNull(reportRules, "Report rules should not be null. Comparison:\n" + comparison); + assertFalse(expectRules.isEmpty(), "Expect rules should not be empty"); + assertFalse(reportRules.isEmpty(), + "Report rules should not be empty - YAML parsing failed! Comparison:\n" + comparison); + assertEquals(1, reportRules.size(), + "Should have exactly one report rule. Comparison:\n" + comparison); + assertEquals(TargetedReportConstraint.class, reportRules.get(0).getClass(), + "Rule should be TargetedReportConstraint. Comparison:\n" + comparison); + } } diff --git a/databind/src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml b/databind/src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml new file mode 100644 index 000000000..3c16abaa9 --- /dev/null +++ b/databind/src/test/resources/content/constraints/meta-constraints/meta-constraints-report.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=../../../../../../../../../../../metaschema-cli/target/generated-resources/metaschema/schema/json/metaschema-model_schema.json +metaschema-meta-constraints: + contexts: + - metapaths: + - target: "/level1" + constraints: + lets: + - var: level + expression: 'level1' + rules: + - object-type: report + id: level1-report + target: . + test: exists(@deprecated) + message: This element is deprecated. + level: INFORMATIONAL