+ * 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 extends IReportConstraint> constraints, + @NonNull IDefinitionNodeItem, ?> item, + @NonNull DynamicContext dynamicContext) throws ConstraintValidationException { + for (IReportConstraint constraint : constraints) { + assert constraint != null; + + try { + ISequence extends IDefinitionNodeItem, ?>> 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 extends INodeItem> 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
+ * 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
+ * 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:
+ *
+ * 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
+ * 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 extends IExpectConstraint> getExpectConstraints();
+ /**
+ * Get the collection of report constraints, if any.
+ *
+ * @return the constraints or an empty list
+ */
+ @NonNull
+ List extends IReportConstraint> 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
+ * 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:
+ *
+ * These tests verify:
+ *
+ * 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
+ * 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
+ *
+ *
+ *
+ *
+ * @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
+ *
+ */
+@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.
+ *