SLING-10763 : Provide a framework to check artifact versions
diff --git a/pom.xml b/pom.xml
index 344d1a9..ed8b2f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,7 +18,7 @@
     </parent>
 
     <artifactId>org.apache.sling.feature.extension.apiregions</artifactId>
-    <version>1.3.11-SNAPSHOT</version>
+    <version>1.4.0</version>
     <name>Sling Featuremodel - API Regions Exension</name>
 
     <scm>
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/ArtifactRulesMergeHandler.java b/src/main/java/org/apache/sling/feature/extension/apiregions/ArtifactRulesMergeHandler.java
new file mode 100644
index 0000000..98a02f5
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/ArtifactRulesMergeHandler.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions;
+
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.builder.HandlerContext;
+import org.apache.sling.feature.builder.MergeHandler;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.ArtifactRules;
+
+/**
+ * Merge the artifact rules extension
+ */
+public class ArtifactRulesMergeHandler implements MergeHandler {
+
+    @Override
+    public boolean canMerge(final Extension extension) {
+        return ArtifactRules.EXTENSION_NAME.equals(extension.getName());
+    }
+
+    @Override
+    public void merge(final HandlerContext context, 
+        final Feature targetFeature, 
+        final Feature sourceFeature, 
+        final Extension targetExtension, 
+        final Extension sourceExtension) {
+
+        if ( targetExtension == null ) {
+            // no target available yet, just copy source
+            final ArtifactRules sourceRules = ArtifactRules.getArtifactRules(sourceExtension);
+            ArtifactRules.setArtifactRules(targetFeature, sourceRules);
+        } else {
+            final ArtifactRules sourceRules = ArtifactRules.getArtifactRules(sourceExtension);
+            final ArtifactRules targetRules = ArtifactRules.getArtifactRules(targetExtension);
+
+            // mode merging
+            if ( context.isInitialMerge() ) {
+                targetRules.setMode(sourceRules.getMode());
+            } else {
+                if ( targetRules.getMode().ordinal() > sourceRules.getMode().ordinal() ) {
+                    targetRules.setMode(sourceRules.getMode());
+                }
+            }
+
+            // merge - just add
+            targetRules.getBundleVersionRules().addAll(sourceRules.getBundleVersionRules());
+            
+            ArtifactRules.setArtifactRules(targetFeature, targetRules);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/analyser/CheckArtifactRules.java b/src/main/java/org/apache/sling/feature/extension/apiregions/analyser/CheckArtifactRules.java
new file mode 100644
index 0000000..0b24240
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/analyser/CheckArtifactRules.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.analyser;
+
+import org.apache.sling.feature.Artifact;
+import org.apache.sling.feature.analyser.task.AnalyserTask;
+import org.apache.sling.feature.analyser.task.AnalyserTaskContext;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.ArtifactRules;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.Mode;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.VersionRule;
+
+
+public class CheckArtifactRules implements AnalyserTask{
+
+    @Override
+    public String getId() {
+        return "artifact-rules";
+    }
+
+    @Override
+    public String getName() {
+        return "Artifact rules analyser task";
+    }
+
+	@Override
+	public void execute(final AnalyserTaskContext context) throws Exception {
+        final ArtifactRules rules = ArtifactRules.getArtifactRules(context.getFeature());
+        if ( rules == null ) {
+            context.reportExtensionWarning(ArtifactRules.EXTENSION_NAME, "Artifact rules are not specified, unable to validate feature");
+        } else {
+            for(final Artifact bundle : context.getFeature().getBundles()) {
+                for(final VersionRule rule : rules.getBundleVersionRules()) {
+                    if ( rule.getArtifactId() != null && rule.getArtifactId().isSame(bundle.getId())) {
+                        if ( ! rule.isAllowed(bundle.getId().getOSGiVersion())) {
+                            String msg = rule.getMessage();
+                            if ( msg == null ) {
+                                msg = "Bundle with version " + bundle.getId().getVersion() + " is not allowed.";
+                            }
+                            Mode m = rules.getMode();
+                            if ( rule.getMode() != null ) {
+                                m = rule.getMode();
+                            }
+                            if ( m == Mode.LENIENT ) {
+                                context.reportArtifactWarning(bundle.getId(), msg);
+                            } else {
+                                context.reportArtifactError(bundle.getId(), msg);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+	}
+}
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/ArtifactRules.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/ArtifactRules.java
new file mode 100755
index 0000000..945e674
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/ArtifactRules.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonException;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonValue;
+
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.ExtensionState;
+import org.apache.sling.feature.ExtensionType;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.extension.apiregions.api.config.AttributeableEntity;
+
+/**
+ * Artifact rules define additional rules for artifacts in a feature model.
+ * The rules are stored as an extension in the feature model.
+ * This class is not thread safe.
+ */
+public class ArtifactRules extends AttributeableEntity {
+
+    /** The name of the feature model extension. */
+    public static final String EXTENSION_NAME = "artifact-rules";
+
+    /**
+     * Get the artifact rules from the feature - if it exists.
+     * If the rules are updated, the containing feature is left untouched.
+     * {@link #setArtifactRules(Feature, ArtifactRules)} can be used to update
+     * the feature.
+     * @param feature The feature
+     * @return The rules or {@code null}.
+     * @throws IllegalArgumentException If the extension is wrongly formatted
+     */
+    public static ArtifactRules getArtifactRules(final Feature feature) {
+        final Extension ext = feature == null ? null : feature.getExtensions().getByName(EXTENSION_NAME);
+        return getArtifactRules(ext);
+    }
+
+    /**
+     * Get the artifact rules from the extension - if it exists.
+     * If the rules are updated, the containing feature is left untouched.
+     * {@link #setArtifactRules(Feature, ArtifactRules)} can be used to update
+     * the feature.
+     * @param ext The extension
+     * @return The rules or {@code null} if the extension is {@code null}.
+     * @throws IllegalArgumentException If the extension is wrongly formatted
+     */
+    public static ArtifactRules getArtifactRules(final Extension ext) {
+        if ( ext == null ) {
+            return null;
+        }
+        if ( ext.getType() != ExtensionType.JSON ) {
+            throw new IllegalArgumentException("Extension " + ext.getName() + " must have JSON type");
+        }
+        try {
+            final ArtifactRules result = new ArtifactRules();
+            result.fromJSONObject(ext.getJSONStructure().asJsonObject());
+            return result;
+        } catch ( final IOException ioe) {
+            throw new IllegalArgumentException(ioe.getMessage(), ioe);
+        }
+    }
+
+    /**
+     * Set the rules as an extension to the feature
+     * @param feature The feature
+     * @param rules The rules. If {@code null} the extension will be removed.
+     * @throws IllegalStateException If the feature has already an extension of a wrong type
+     * @throws IllegalArgumentException If the rules can't be serialized to JSON
+     */
+    public static void setArtifactRules(final Feature feature, final ArtifactRules rules) {
+        Extension ext = feature.getExtensions().getByName(EXTENSION_NAME);
+        if ( rules == null ) {
+            if ( ext != null ) {
+                feature.getExtensions().remove(ext);
+            }
+        } else {
+            if ( ext == null ) {
+                ext = new Extension(ExtensionType.JSON, EXTENSION_NAME, ExtensionState.OPTIONAL);
+                feature.getExtensions().add(ext);
+            }
+            try {
+                ext.setJSONStructure(rules.toJSONObject());
+            } catch ( final IOException ioe) {
+                throw new IllegalArgumentException(ioe);
+            }
+        }
+    }
+
+    /** The validation mode */
+    private Mode mode;
+
+    /** The version rules */
+    private final List<VersionRule> bundleVersionRules = new ArrayList<>();
+
+    /**
+     * Create a new rules object
+     */
+    public ArtifactRules() {
+        this.setDefaults();
+    }
+
+    @Override
+    protected void setDefaults() {
+        super.setDefaults();
+        this.setMode(Mode.STRICT);
+    }
+
+    /**
+     * Clear the object and reset to defaults
+     */
+    @Override
+    public void clear() {
+        super.clear();
+        this.getBundleVersionRules().clear();
+    }
+
+    /**
+     * Convert this object into JSON
+     *
+     * @return The json object builder
+     * @throws IOException If generating the JSON fails
+     */
+    @Override
+    public JsonObjectBuilder createJson() throws IOException {
+        final JsonObjectBuilder objBuilder = super.createJson();
+        if ( this.getMode() != Mode.STRICT ) {
+            objBuilder.add(InternalConstants.KEY_MODE, this.getMode().name());
+        }
+
+        if ( !this.getBundleVersionRules().isEmpty() ) {
+            final JsonArrayBuilder arrayBuilder = Json.createArrayBuilder();
+            for(final VersionRule rule : this.getBundleVersionRules()) {
+                arrayBuilder.add(rule.createJson());
+            }
+            objBuilder.add(InternalConstants.KEY_BUNDLE_VERSION_RULES, arrayBuilder);
+        }
+
+        return objBuilder;
+    }
+
+    /**
+	 * Extract the metadata from the JSON object.
+	 * This method first calls {@link #clear()}.
+     *
+	 * @param jsonObj The JSON Object
+	 * @throws IOException If JSON parsing fails
+	 */
+    @Override
+    public void fromJSONObject(final JsonObject jsonObj) throws IOException {
+        super.fromJSONObject(jsonObj);
+        try {
+			final String modeVal = this.getString(InternalConstants.KEY_MODE);
+			if ( modeVal != null ) {
+                this.setMode(Mode.valueOf(modeVal.toUpperCase()));
+			}
+
+            JsonValue val = this.getAttributes().remove(InternalConstants.KEY_BUNDLE_VERSION_RULES);
+            if ( val != null ) {
+                for(final JsonValue innerVal : val.asJsonArray()) {
+                    final VersionRule rule = new VersionRule();
+                    rule.fromJSONObject(innerVal.asJsonObject());
+                    this.getBundleVersionRules().add(rule);
+                }
+            }
+
+        } catch (final JsonException | IllegalArgumentException e) {
+            throw new IOException(e);
+        }
+    }
+
+    /**
+     * Get the validation mode.
+     * The default is {@link Mode#STRICT}
+     * @return The mode
+     */
+    public Mode getMode() {
+        return this.mode;
+    }
+
+    /**
+     * Set the validation mode
+     * @param value The validation mode
+     */
+    public void setMode(final Mode value) {
+        this.mode = value == null ? Mode.STRICT : value;
+    }
+
+    /**
+     * Return the list of version rules for bundles. The returned list is mutable.
+     * @return The list of rules, might be empty.
+     */
+    public List<VersionRule> getBundleVersionRules() {
+        return this.bundleVersionRules;
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/InternalConstants.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/InternalConstants.java
new file mode 100755
index 0000000..b158b4e
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/InternalConstants.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+/**
+ * Constants used in this implementation
+ */
+abstract class InternalConstants {
+
+    static final String KEY_MODE = "mode";
+
+    static final String KEY_ARTIFACT_ID = "artifact-id";
+
+    static final String KEY_ALLOWED_VERSION_RANGES = "allowed-version-ranges";
+
+    static final String KEY_DENIED_VERSION_RANGES = "denied-version-ranges";
+
+    static final String KEY_MESSAGE = "message";
+
+    static final String KEY_BUNDLE_VERSION_RULES = "bundle-version-rules";
+}
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/Mode.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/Mode.java
new file mode 100755
index 0000000..ce5a468
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/Mode.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+/**
+ * The  mode for the rules
+ */
+public enum Mode {
+
+    /** Default mode - If validation fails, issue an error. */
+    STRICT,
+
+    /** If validation fails, issue a warning (but use the invalid value). */
+    LENIENT
+}
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/VersionRule.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/VersionRule.java
new file mode 100755
index 0000000..1e4de5a
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/VersionRule.java
@@ -0,0 +1,269 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+import java.io.IOException;
+
+import javax.json.JsonException;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.extension.apiregions.api.config.AttributeableEntity;
+import org.osgi.framework.Version;
+import org.osgi.framework.VersionRange;
+
+/**
+ * A rule to validate the version of an artifact.
+ * This class is not thread safe.
+ */
+public class VersionRule extends AttributeableEntity {
+
+    /** The optional validation mode */
+    private Mode mode;
+
+    /** The artifact id. */
+    private ArtifactId artifactId;
+
+    /** The message */
+    private String message;
+
+    /** The allowed version ranges */
+    private VersionRange[] allowedVersionRanges;
+
+    /** The denied version ranges */
+    private VersionRange[] deniedVersionRanges;
+
+    /**
+     * Create a new rules object
+     */
+    public VersionRule() {
+        this.setDefaults();
+    }
+
+    /**
+     * Clear the object and reset to defaults
+     */
+    @Override
+    public void clear() {
+        super.clear();
+        this.setArtifactId(null);
+        this.setMode(null);
+        this.setMessage(null);
+        this.setAllowedVersionRanges(null);
+        this.setDeniedVersionRanges(null);
+    }
+
+    /**
+     * Convert this object into JSON
+     *
+     * @return The json object builder
+     * @throws IOException If generating the JSON fails
+     */
+    @Override
+    public JsonObjectBuilder createJson() throws IOException {
+        final JsonObjectBuilder objBuilder = super.createJson();
+        if ( this.getMode() != null ) {
+            objBuilder.add(InternalConstants.KEY_MODE, this.getMode().name());
+        }
+
+        if ( this.getArtifactId() != null ) {
+            objBuilder.add(InternalConstants.KEY_ARTIFACT_ID, this.getArtifactId().toMvnId());
+        }
+
+        this.setString(objBuilder, InternalConstants.KEY_MESSAGE, this.getMessage());
+
+        if ( this.getAllowedVersionRanges() != null && this.getAllowedVersionRanges().length > 0 ) {
+            final String[] arr = new String[this.getAllowedVersionRanges().length];
+            for(int i=0;i<this.getAllowedVersionRanges().length;i++) {
+                arr[i] = this.getAllowedVersionRanges()[i].toString();
+            }
+            this.setStringArray(objBuilder, InternalConstants.KEY_ALLOWED_VERSION_RANGES, arr);
+        }
+
+        if ( this.getDeniedVersionRanges() != null && this.getDeniedVersionRanges().length > 0 ) {
+            final String[] arr = new String[this.getDeniedVersionRanges().length];
+            for(int i=0;i<this.getDeniedVersionRanges().length;i++) {
+                arr[i] = this.getDeniedVersionRanges()[i].toString();
+            }
+            this.setStringArray(objBuilder, InternalConstants.KEY_DENIED_VERSION_RANGES, arr);
+        }
+
+        return objBuilder;
+    }
+
+    /**
+	 * Extract the metadata from the JSON object.
+	 * This method first calls {@link #clear()}.
+     *
+	 * @param jsonObj The JSON Object
+	 * @throws IOException If JSON parsing fails
+	 */
+    @Override
+    public void fromJSONObject(final JsonObject jsonObj) throws IOException {
+        super.fromJSONObject(jsonObj);
+        try {
+			String val = this.getString(InternalConstants.KEY_MODE);
+			if ( val != null ) {
+                this.setMode(Mode.valueOf(val.toUpperCase()));
+			}
+            
+            val = this.getString(InternalConstants.KEY_ARTIFACT_ID);
+            if ( val != null ) {
+                this.setArtifactId(ArtifactId.parse(val));
+            }
+
+            this.setMessage(this.getString(InternalConstants.KEY_MESSAGE));
+
+            String[] arr = this.getStringArray(InternalConstants.KEY_ALLOWED_VERSION_RANGES);
+            if ( arr != null && arr.length > 0 ) {
+                final VersionRange[] ranges = new VersionRange[arr.length];
+                for(int i=0;i<arr.length;i++) {
+                    try  {
+                        ranges[i] = new VersionRange(arr[i]);
+                    } catch ( final IllegalArgumentException iae) {
+                        throw new IOException("Illegal argument for allowed version range: " + arr[i]);
+                    }
+                }
+                this.setAllowedVersionRanges(ranges);
+            }
+
+            arr = this.getStringArray(InternalConstants.KEY_DENIED_VERSION_RANGES);
+            if ( arr != null && arr.length > 0 ) {
+                final VersionRange[] ranges = new VersionRange[arr.length];
+                for(int i=0;i<arr.length;i++) {
+                    try  {
+                        ranges[i] = new VersionRange(arr[i]);
+                    } catch ( final IllegalArgumentException iae) {
+                        throw new IOException("Illegal argument for allowed version range: " + arr[i]);
+                    }
+                }
+                this.setDeniedVersionRanges(ranges);
+            }
+        } catch (final JsonException | IllegalArgumentException e) {
+            throw new IOException(e);
+        }
+    }
+
+    /**
+     * Get the validation mode.
+     * The default is {@link Mode#STRICT}
+     * @return The mode
+     */
+    public Mode getMode() {
+        return this.mode;
+    }
+
+    /**
+     * Set the validation mode
+     * @param value The validation mode
+     */
+    public void setMode(final Mode value) {
+        this.mode = value;
+    }
+
+    /**
+     * Get the artifact id
+     * @return the artifactId
+     */
+    public ArtifactId getArtifactId() {
+        return artifactId;
+    }
+
+    /**
+     * Set the artifact id
+     * @param artifactId the artifactId to set
+     */
+    public void setArtifactId(final ArtifactId artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    /**
+     * The validation message
+     * @return the message
+     */
+    public String getMessage() {
+        return message;
+    }
+
+    /**
+     * Set the validation message
+     * @param message the message to set
+     */
+    public void setMessage(final String message) {
+        this.message = message;
+    }
+
+    /**
+     * The allowed version ranges
+     * @return the allowedVersions or {@code null}
+     */
+    public VersionRange[] getAllowedVersionRanges() {
+        return allowedVersionRanges;
+    }
+
+    /**
+     * Set the allowed version ranges
+     * @param allowedVersions the allowedVersions to set
+     */
+    public void setAllowedVersionRanges(final VersionRange[] allowedVersions) {
+        this.allowedVersionRanges = allowedVersions;
+    }
+
+    /**
+     * Get the denied version ranges
+     * @return the deniedVersions or {@code null}
+     */
+    public VersionRange[] getDeniedVersionRanges() {
+        return deniedVersionRanges;
+    }
+
+    /**
+     * Set the denied version ranges
+     * @param deniedVersions the deniedVersions to set
+     */
+    public void setDeniedVersionRanges(final VersionRange[] deniedVersions) {
+        this.deniedVersionRanges = deniedVersions;
+    }
+
+    /**
+     * Check if a version is allowed according to the rules
+     * @param artifactVersion The version
+     * @return {@code true} if it is allowed, {@code false} otherwise
+     */
+    public boolean isAllowed(final Version artifactVersion) {
+        boolean result = false;
+        if ( this.getAllowedVersionRanges() != null && this.getAllowedVersionRanges().length > 0 ) {
+            for(final VersionRange range : this.getAllowedVersionRanges()) {
+                if ( range.includes(artifactVersion) ) {
+                    result = true;
+                    break;
+                }
+            }
+            if ( result && this.getDeniedVersionRanges() != null ) {
+                for(final VersionRange range : this.getDeniedVersionRanges()) {
+                    if ( range.includes(artifactVersion) ) {
+                        result = false;
+                        break;
+                    }
+                }
+            }
+        }
+        return result;
+
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/package-info.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/package-info.java
new file mode 100755
index 0000000..3b169bc
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/artifacts/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+@org.osgi.annotation.versioning.Version("1.0.0")
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntity.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntity.java
index 43617f9..c3908f9 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntity.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntity.java
@@ -21,10 +21,14 @@
 import java.util.Map;
 
 import javax.json.Json;
+import javax.json.JsonArray;
+import javax.json.JsonArrayBuilder;
 import javax.json.JsonException;
 import javax.json.JsonObject;
 import javax.json.JsonObjectBuilder;
+import javax.json.JsonString;
 import javax.json.JsonValue;
+import javax.json.JsonValue.ValueType;
 
 import org.apache.felix.cm.json.Configurations;
 
@@ -33,14 +37,14 @@
  * This class is not thread safe.
  */
 public abstract class AttributeableEntity {
-	
+
 	/** The additional attributes */
 	private final Map<String, JsonValue> attributes = new LinkedHashMap<>();
 
     /**
      * Apply the non-null default values.
      */
-    void setDefaults() {
+    protected void setDefaults() {
         // nothing to do
     }
 
@@ -51,7 +55,7 @@
         this.setDefaults();
 		this.attributes.clear();
 	}
-	
+
    /**
      * Convert this object into JSON
      *
@@ -66,7 +70,7 @@
 	/**
 	 * Extract the metadata from the JSON object.
 	 * This method first calls {@link #clear()}
-     * 
+     *
 	 * @param jsonObj The JSON Object
 	 * @throws IOException If JSON parsing fails
 	 */
@@ -95,7 +99,7 @@
      * @return The json object builder
      * @throws IOException If generating the JSON fails
      */
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objectBuilder = Json.createObjectBuilder();
 
 		for(final Map.Entry<String, JsonValue> entry : this.getAttributes().entrySet()) {
@@ -104,13 +108,13 @@
 
 		return objectBuilder;
 	}
-	
+
 	/**
 	 * Helper method to get a string value from a JsonValue
 	 * @param jsonValue The json value
 	 * @return The string value or {@code null}.
 	 */
-	String getString(final JsonValue jsonValue) {
+    protected String getString(final JsonValue jsonValue) {
 		final Object obj = Configurations.convertToObject(jsonValue);
 		if ( obj != null ) {
 			return obj.toString();
@@ -123,7 +127,7 @@
 	 * @param attributeName The attribute name
 	 * @return The string value or {@code null}.
 	 */
-	String getString(final String attributeName) {
+    protected String getString(final String attributeName) {
 		final JsonValue val = this.getAttributes().remove(attributeName);
 		if ( val != null ) {
 			final Object obj = Configurations.convertToObject(val);
@@ -135,12 +139,39 @@
 	}
 
 	/**
+	 * Helper method to get a string array from an attribute
+	 * @param attributeName The attribute name
+	 * @return The string array or {@code null}.
+     * @since 1.6.0
+	 */
+    protected String[] getStringArray(final String attributeName) throws IOException {
+		final JsonValue val = this.getAttributes().remove(attributeName);
+		if ( val != null ) {
+            if ( val.getValueType() == ValueType.ARRAY ) {
+                final JsonArray array = val.asJsonArray();
+                final String[] result = new String[array.size()];
+                int i = 0;
+                for(final JsonValue v : array) {
+                    result[i] = Configurations.convertToObject(v).toString();
+                    i++;
+                }
+                return result;
+            } else if ( val.getValueType() == ValueType.STRING ) {
+                return new String[] {((JsonString)val).getString()};
+            } else {
+                throw new IOException("Invalid type for string array value " + attributeName + " : " + val.getValueType().name());
+            }
+		}
+		return null;
+	}
+
+    /**
 	 * Helper method to get a number value from an attribute
 	 * @param attributeName The attribute name
 	 * @return The string value or {@code null}.
      * @throws IOException If the attribute value is not of type boolean
 	 */
-	Number getNumber(final String attributeName) throws IOException {
+    protected Number getNumber(final String attributeName) throws IOException {
 		final JsonValue val = this.getAttributes().remove(attributeName);
 		if ( val != null ) {
 			final Object obj = Configurations.convertToObject(val);
@@ -154,20 +185,40 @@
 
     /**
      * Helper method to set a string value
+     * @param builder The json object builder
+     * @param attributeName The name of the attribute
+     * @param value The string value
      */
-    void setString(final JsonObjectBuilder builder, final String attributeName, final String value) {
+    protected void setString(final JsonObjectBuilder builder, final String attributeName, final String value) {
 		if ( value != null ) {
 			builder.add(attributeName, value);
 		}
 	}
 
-	/**
+    /**
+     * Helper method to set a string array
+     * @param builder The json object builder
+     * @param attributeName The name of the attribute
+     * @param value The string array
+     * @since 1.6.0
+     */
+    protected void setStringArray(final JsonObjectBuilder builder, final String attributeName, final String[] value) {
+		if ( value != null && value.length > 0 ) {
+            final JsonArrayBuilder jab = Json.createArrayBuilder();
+            for(final String v : value) {
+                jab.add(v);
+            }
+			builder.add(attributeName, jab);
+		}
+	}
+
+    /**
 	 * Helper method to get a integer value from an attribute
 	 * @param attributeName The attribute name
 	 * @param defaultValue default value
 	 * @return The integer value or the default value
 	 */
-	int getInteger(final String attributeName, final int defaultValue) {
+    protected int getInteger(final String attributeName, final int defaultValue) {
 		final String val = this.getString(attributeName);
 		if ( val != null ) {
 			return Integer.parseInt(val);
@@ -182,7 +233,7 @@
 	 * @return The boolean value or the default value
      * @throws IOException If the attribute value is not of type boolean
 	 */
-	boolean getBoolean(final String attributeName, final boolean defaultValue) throws IOException {
+    protected boolean getBoolean(final String attributeName, final boolean defaultValue) throws IOException {
 		final JsonValue val = this.getAttributes().remove(attributeName);
 		if ( val != null ) {
 			final Object obj = Configurations.convertToObject(val);
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurableEntity.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurableEntity.java
index 4e3b1f3..3535246 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurableEntity.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurableEntity.java
@@ -64,7 +64,7 @@
      */
     private final List<String> internalProperties = new ArrayList<>();
 
-    void setDefaults() {
+    protected void setDefaults() {
         super.setDefaults();
         this.setAllowAdditionalProperties(false);
         this.setRegion(Region.GLOBAL);
@@ -200,7 +200,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-	JsonObjectBuilder createJson() throws IOException {
+	protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objBuilder = super.createJson();
 
 		if ( !this.getPropertyDescriptions().isEmpty() ) {
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurationApi.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurationApi.java
index 5108580..efd3807 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurationApi.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/ConfigurationApi.java
@@ -42,10 +42,10 @@
  * This class is not thread safe.
  */
 public class ConfigurationApi extends AttributeableEntity {
-    
+
     /** The name of the api regions extension. */
     public static final String EXTENSION_NAME = "configuration-api";
-  
+
     /**
      * Get the configuration api from the feature - if it exists.
      * If the configuration api is updated, the containing feature is left untouched.
@@ -82,7 +82,7 @@
             throw new IllegalArgumentException(ioe.getMessage(), ioe);
         }
     }
-   
+
     /**
      * Set the configuration api as an extension to the feature
      * @param feature The feature
@@ -126,28 +126,29 @@
 
     /** The set of internal framework property names */
     private final Set<String> internalFrameworkProperties = new TreeSet<>();
-    
+
     /** The configuration region of this feature */
     private Region region;
 
     /** The cached region information for feature origins */
     private final Map<ArtifactId, Region> regionCache = new LinkedHashMap<>();
 
-    /** 
-     * The default validation mode. 
+    /**
+     * The default validation mode.
      * @since 1.2
      */
     private Mode mode;
-    
+
     public ConfigurationApi() {
         this.setDefaults();
     }
-    
-    void setDefaults() {
+
+    @Override
+    protected void setDefaults() {
         super.setDefaults();
         this.setMode(Mode.STRICT);
     }
-    
+
     /**
      * Clear the object and reset to defaults
      */
@@ -167,7 +168,7 @@
 	/**
 	 * Extract the metadata from the JSON object.
 	 * This method first calls {@link #clear()}.
-     * 
+     *
 	 * @param jsonObj The JSON Object
 	 * @throws IOException If JSON parsing fails
 	 */
@@ -177,7 +178,7 @@
         try {
 			final String typeVal = this.getString(InternalConstants.KEY_REGION);
 			if ( typeVal != null ) {
-                this.setRegion(Region.valueOf(typeVal.toUpperCase()));				
+                this.setRegion(Region.valueOf(typeVal.toUpperCase()));
 			}
 
             JsonValue val;
@@ -189,7 +190,7 @@
                     this.getConfigurationDescriptions().put(innerEntry.getKey(), cfg);
                 }
             }
-            
+
             val = this.getAttributes().remove(InternalConstants.KEY_FACTORIES);
             if ( val != null ) {
                 for(final Map.Entry<String, JsonValue> innerEntry : val.asJsonObject().entrySet()) {
@@ -232,14 +233,14 @@
             val = this.getAttributes().remove(InternalConstants.KEY_REGION_CACHE);
             if ( val != null ) {
                 for(final Map.Entry<String, JsonValue> innerEntry : val.asJsonObject().entrySet()) {
-                    this.getFeatureToRegionCache().put(ArtifactId.parse(innerEntry.getKey()), 
+                    this.getFeatureToRegionCache().put(ArtifactId.parse(innerEntry.getKey()),
                         Region.valueOf(getString(innerEntry.getValue()).toUpperCase()));
                 }
             }
-            
+
 			final String modeVal = this.getString(InternalConstants.KEY_MODE);
 			if ( modeVal != null ) {
-                this.setMode(Mode.valueOf(modeVal.toUpperCase()));				
+                this.setMode(Mode.valueOf(modeVal.toUpperCase()));
 			}
 
         } catch (final JsonException | IllegalArgumentException e) {
@@ -276,7 +277,8 @@
 	 * @return Mutable set of internal configuration pids
      * @deprecated Please use empty configuration descriptions via {@link #getConfigurationDescriptions()}
 	 */
-	public Set<String> getInternalConfigurations() {
+	@Deprecated
+    public Set<String> getInternalConfigurations() {
 		return internalConfigurations;
 	}
 
@@ -285,7 +287,8 @@
 	 * @return Mutable set of internal factory configuration pids
      * @deprecated Please use empty factory configuration descriptions via {@link #getFactoryConfigurationDescriptions()}
 	 */
-	public Set<String> getInternalFactoryConfigurations() {
+	@Deprecated
+    public Set<String> getInternalFactoryConfigurations() {
 		return internalFactories;
 	}
 
@@ -360,7 +363,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objBuilder = super.createJson();
         if ( this.getRegion() != null ) {
             objBuilder.add(InternalConstants.KEY_REGION, this.getRegion().name());
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/DescribableEntity.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/DescribableEntity.java
index a2bf0eb..a5c6496 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/DescribableEntity.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/DescribableEntity.java
@@ -172,7 +172,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objectBuilder = super.createJson();
 
 		this.setString(objectBuilder, InternalConstants.KEY_TITLE, this.getTitle());
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/FactoryConfigurationDescription.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/FactoryConfigurationDescription.java
index 71c5606..4f6ac08 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/FactoryConfigurationDescription.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/FactoryConfigurationDescription.java
@@ -43,7 +43,7 @@
         this.setDefaults();
     }
 
-    void setDefaults() {
+    protected void setDefaults() {
         super.setDefaults();
         this.getOperations().add(Operation.CREATE);
         this.getOperations().add(Operation.UPDATE);
@@ -117,7 +117,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objBuilder = super.createJson();
 		
 		if ( !this.getOperations().isEmpty() && this.getOperations().size() != 2 ) {
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Option.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Option.java
index 7df9908..8470387 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Option.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Option.java
@@ -79,7 +79,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
         final JsonObjectBuilder objectBuilder = super.createJson();
 
         this.setString(objectBuilder, InternalConstants.KEY_VALUE, this.getValue());
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/PropertyDescription.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/PropertyDescription.java
index 367e118..6ee4fcf 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/PropertyDescription.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/PropertyDescription.java
@@ -94,7 +94,7 @@
         this.setDefaults();
     }
 
-    void setDefaults() {
+    protected void setDefaults() {
         super.setDefaults();
 		this.setType(PropertyType.STRING);
         this.setCardinality(1);
@@ -194,7 +194,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objectBuilder = super.createJson();
 
 		if ( this.getType() != null && this.getType() != PropertyType.STRING ) {
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Range.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Range.java
index 7106cb6..2e90a0d 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Range.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/Range.java
@@ -102,7 +102,7 @@
      * @throws IOException If generating the JSON fails
      */
     @Override
-    JsonObjectBuilder createJson() throws IOException {
+    protected JsonObjectBuilder createJson() throws IOException {
 		final JsonObjectBuilder objectBuilder = super.createJson();
 
         if ( this.getMin() != null ) {
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/package-info.java b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/package-info.java
index 49fdfc7..0c44f4f 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/package-info.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/api/config/package-info.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-@org.osgi.annotation.versioning.Version("1.5.0")
+@org.osgi.annotation.versioning.Version("1.6.0")
 package org.apache.sling.feature.extension.apiregions.api.config;
 
 
diff --git a/src/main/resources/META-INF/services/org.apache.sling.feature.builder.MergeHandler b/src/main/resources/META-INF/services/org.apache.sling.feature.builder.MergeHandler
index caa2534..2867a76 100644
--- a/src/main/resources/META-INF/services/org.apache.sling.feature.builder.MergeHandler
+++ b/src/main/resources/META-INF/services/org.apache.sling.feature.builder.MergeHandler
@@ -1,2 +1,3 @@
 org.apache.sling.feature.extension.apiregions.APIRegionMergeHandler
 org.apache.sling.feature.extension.apiregions.ConfigurationApiMergeHandler
+org.apache.sling.feature.extension.apiregions.ArtifactRulesMergeHandler
diff --git a/src/test/java/org/apache/sling/feature/extension/apiregions/ArtifactRulesMergeHandlerTest.java b/src/test/java/org/apache/sling/feature/extension/apiregions/ArtifactRulesMergeHandlerTest.java
new file mode 100644
index 0000000..2a3a46c
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/extension/apiregions/ArtifactRulesMergeHandlerTest.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+ package org.apache.sling.feature.extension.apiregions;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.builder.BuilderContext;
+import org.apache.sling.feature.builder.FeatureBuilder;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.ArtifactRules;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.Mode;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.VersionRule;
+import org.junit.Test;
+
+public class ArtifactRulesMergeHandlerTest {
+
+    @Test public void testModeMerging() {
+        final Feature featureA = new Feature(ArtifactId.parse("g:a:1"));
+        final ArtifactRules rulesA = new ArtifactRules();
+        rulesA.setMode(Mode.LENIENT);
+        ArtifactRules.setArtifactRules(featureA, rulesA);
+        final Feature featureB = new Feature(ArtifactId.parse("g:b:1"));
+        final ArtifactRules rulesB = new ArtifactRules();
+        rulesB.setMode(Mode.STRICT);
+        ArtifactRules.setArtifactRules(featureB, rulesB);
+
+        final BuilderContext context = new BuilderContext(id -> null);
+        context.addMergeExtensions(new ArtifactRulesMergeHandler());
+        
+        final Feature result = FeatureBuilder.assemble(ArtifactId.parse("g:f:1"), context, featureA, featureB);
+        final ArtifactRules rules = ArtifactRules.getArtifactRules(result);
+        assertNotNull(rules);
+        assertEquals(Mode.STRICT, rules.getMode());
+    }
+
+    @Test public void testRuleMerging() {
+        final Feature featureA = new Feature(ArtifactId.parse("g:a:1"));
+        final ArtifactRules rulesA = new ArtifactRules();
+        final VersionRule vrA = new VersionRule();
+        rulesA.getBundleVersionRules().add(vrA);
+        ArtifactRules.setArtifactRules(featureA, rulesA);
+        final Feature featureB = new Feature(ArtifactId.parse("g:b:1"));
+        final ArtifactRules rulesB = new ArtifactRules();
+        final VersionRule vrB = new VersionRule();
+        rulesB.getBundleVersionRules().add(vrB);
+        ArtifactRules.setArtifactRules(featureB, rulesB);
+
+        final BuilderContext context = new BuilderContext(id -> null);
+        context.addMergeExtensions(new ArtifactRulesMergeHandler());
+        
+        final Feature result = FeatureBuilder.assemble(ArtifactId.parse("g:f:1"), context, featureA, featureB);
+        final ArtifactRules rules = ArtifactRules.getArtifactRules(result);
+        assertNotNull(rules);
+        assertEquals(2, rules.getBundleVersionRules().size());
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/feature/extension/apiregions/analyser/CheckArtifactRulesTest.java b/src/test/java/org/apache/sling/feature/extension/apiregions/analyser/CheckArtifactRulesTest.java
new file mode 100644
index 0000000..15cf8d1
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/extension/apiregions/analyser/CheckArtifactRulesTest.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.analyser;
+
+import static org.mockito.Mockito.when;
+
+import org.apache.sling.feature.Artifact;
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.analyser.task.AnalyserTaskContext;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.ArtifactRules;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.Mode;
+import org.apache.sling.feature.extension.apiregions.api.artifacts.VersionRule;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class CheckArtifactRulesTest {
+    
+    private CheckArtifactRules analyser = new CheckArtifactRules();
+
+    private AnalyserTaskContext newContext(final Feature f) {
+        final AnalyserTaskContext context = Mockito.mock(AnalyserTaskContext.class);
+
+        when(context.getFeature()).thenReturn(f);
+        
+        return context;
+    }
+
+    @Test public void testValidateFeatureNoRules() throws Exception {
+        final Feature f = new Feature(ArtifactId.parse("g:a:1"));
+
+        final AnalyserTaskContext context = newContext(f);
+        analyser.execute(context);
+
+        Mockito.verify(context, Mockito.never()).reportError(Mockito.anyString());
+        Mockito.verify(context, Mockito.atLeastOnce()).reportExtensionWarning(Mockito.eq(ArtifactRules.EXTENSION_NAME), Mockito.anyString());
+    }
+
+    @Test public void testValidateFeature() throws Exception {
+        final Feature f = new Feature(ArtifactId.parse("g:a:1"));
+        final Artifact bundle = new Artifact(ArtifactId.parse("g:b:1.1"));
+        f.getBundles().add(bundle);
+
+        final VersionRule r = new VersionRule();
+        r.setArtifactId(bundle.getId());
+        r.setMode(Mode.STRICT);
+        r.setMessage("foo");
+
+        final ArtifactRules rules = new ArtifactRules();
+        rules.getBundleVersionRules().add(r);
+        
+        ArtifactRules.setArtifactRules(f, rules);
+        final AnalyserTaskContext context = newContext(f);
+        analyser.execute(context);
+
+        Mockito.verify(context, Mockito.atLeastOnce()).reportArtifactError(Mockito.eq(bundle.getId()), Mockito.eq(r.getMessage()));
+    }
+}
diff --git a/src/test/java/org/apache/sling/feature/extension/apiregions/api/artifacts/ArtifactRulesTest.java b/src/test/java/org/apache/sling/feature/extension/apiregions/api/artifacts/ArtifactRulesTest.java
new file mode 100644
index 0000000..d769ac9
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/extension/apiregions/api/artifacts/ArtifactRulesTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import javax.json.Json;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.ExtensionState;
+import org.apache.sling.feature.ExtensionType;
+import org.apache.sling.feature.Feature;
+import org.junit.Test;
+
+public class ArtifactRulesTest {
+
+    @Test public void testNullFeature() {
+        assertNull(ArtifactRules.getArtifactRules((Feature)null));
+    }
+
+    @Test public void testNullExtension() {
+        assertNull(ArtifactRules.getArtifactRules((Extension)null));
+        final Feature f = new Feature(ArtifactId.parse("g:a:1.0"));
+        assertNull(ArtifactRules.getArtifactRules(f));
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testWrongExtensionType() {
+        final Feature f = new Feature(ArtifactId.parse("g:a:1.0"));
+        final Extension e = new Extension(ExtensionType.TEXT, ArtifactRules.EXTENSION_NAME, ExtensionState.OPTIONAL);
+        f.getExtensions().add(e);
+        ArtifactRules.getArtifactRules(f);
+    }
+
+    @Test public void testSetArtifactRules() {
+        final ArtifactRules rules = new ArtifactRules();
+        final Feature f = new Feature(ArtifactId.parse("g:a:1"));
+
+        assertNull(f.getExtensions().getByName(ArtifactRules.EXTENSION_NAME));
+
+        ArtifactRules.setArtifactRules(f, rules);
+        assertNotNull(f.getExtensions().getByName(ArtifactRules.EXTENSION_NAME));
+        assertNotNull(ArtifactRules.getArtifactRules(f));
+
+        ArtifactRules.setArtifactRules(f, null);
+        assertNull(f.getExtensions().getByName(ArtifactRules.EXTENSION_NAME));
+    }
+
+    @Test public void testClear() {
+        final ArtifactRules entity = new ArtifactRules();
+        entity.getAttributes().put("a", Json.createValue(5));
+        entity.setMode(Mode.LENIENT);
+        entity.getBundleVersionRules().add(new VersionRule());
+        entity.clear();
+        assertTrue(entity.getAttributes().isEmpty());
+        assertTrue(entity.getBundleVersionRules().isEmpty());
+        assertEquals(Mode.STRICT, entity.getMode());
+    }
+}
diff --git a/src/test/java/org/apache/sling/feature/extension/apiregions/api/artifacts/VersionRuleTest.java b/src/test/java/org/apache/sling/feature/extension/apiregions/api/artifacts/VersionRuleTest.java
new file mode 100644
index 0000000..dc4cb3b
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/extension/apiregions/api/artifacts/VersionRuleTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.feature.extension.apiregions.api.artifacts;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+
+import javax.json.Json;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.ExtensionState;
+import org.apache.sling.feature.ExtensionType;
+import org.junit.Test;
+import org.osgi.framework.Version;
+import org.osgi.framework.VersionRange;
+
+public class VersionRuleTest {
+
+    @Test public void testClear() {
+        final VersionRule entity = new VersionRule();
+        entity.getAttributes().put("a", Json.createValue(5));
+        entity.setAllowedVersionRanges(new VersionRange[] {new VersionRange("1.0")});
+        entity.setDeniedVersionRanges(new VersionRange[] {new VersionRange("3.0")});
+        entity.setMode(Mode.LENIENT);
+        entity.setMessage("msg");
+        entity.setArtifactId(ArtifactId.parse("g:a:1"));
+        entity.clear();
+        assertTrue(entity.getAttributes().isEmpty());
+        assertNull(entity.getAllowedVersionRanges());
+        assertNull(entity.getDeniedVersionRanges());
+        assertNull(entity.getMessage());
+        assertNull(entity.getArtifactId());
+        assertNull(entity.getMode());
+    }
+
+    @Test public void testFromJSONObject() throws IOException {
+        final Extension ext = new Extension(ExtensionType.JSON, "a", ExtensionState.OPTIONAL);
+        ext.setJSON("{ \"mode\" : \"LENIENT\", \"message\" : \"msg\", \"artifact-id\":\"g:a:1\"," 
+            + "\"allowed-version-ranges\":[\"1.0\"],\"denied-version-ranges\":[\"2.0\"]}");
+
+        final VersionRule entity = new VersionRule();
+        entity.fromJSONObject(ext.getJSONStructure().asJsonObject());
+        assertEquals(Mode.LENIENT, entity.getMode());
+        assertEquals("msg", entity.getMessage());
+        assertEquals(ArtifactId.parse("g:a:1"), entity.getArtifactId());
+        assertEquals(1, entity.getAllowedVersionRanges().length);
+        assertEquals(new VersionRange("1.0"), entity.getAllowedVersionRanges()[0]);
+        assertEquals(1, entity.getDeniedVersionRanges().length);
+        assertEquals(new VersionRange("2.0"), entity.getDeniedVersionRanges()[0]);
+    }
+
+    @Test public void testToJSONObject() throws IOException {
+        final VersionRule entity = new VersionRule();
+        entity.setMode(Mode.LENIENT);
+        entity.setMessage("msg");
+        entity.setArtifactId(ArtifactId.parse("g:a:1"));
+        entity.setAllowedVersionRanges(new VersionRange[] {new VersionRange("1.0.0")});
+        entity.setDeniedVersionRanges(new VersionRange[] {new VersionRange("2.0.0")});
+
+        final Extension ext = new Extension(ExtensionType.JSON, "a", ExtensionState.OPTIONAL);
+        ext.setJSON("{ \"mode\" : \"LENIENT\", \"artifact-id\":\"g:a:1\", \"message\" : \"msg\"," 
+            + "\"allowed-version-ranges\":[\"1.0.0\"],\"denied-version-ranges\":[\"2.0.0\"]}");
+
+        assertEquals(ext.getJSONStructure().asJsonObject(), entity.toJSONObject());
+    }
+
+    @Test public void testIsAllowedNoRanges() {
+        final VersionRule entity = new VersionRule();
+        assertFalse(entity.isAllowed(new Version("1.0")));
+        assertFalse(entity.isAllowed(new Version("1.3")));
+        assertFalse(entity.isAllowed(new Version("2.1")));
+    }
+
+    @Test public void testIsAllowedAllowedRange() {
+        final VersionRule entity = new VersionRule();
+        entity.setAllowedVersionRanges(new VersionRange[] {new VersionRange("[1.2, 2)")});
+        assertFalse(entity.isAllowed(new Version("1.0")));
+        assertTrue(entity.isAllowed(new Version("1.3")));
+        assertFalse(entity.isAllowed(new Version("2.1")));
+    }
+
+    @Test public void testIsAllowedAllowedDenied() {
+        final VersionRule entity = new VersionRule();
+        entity.setAllowedVersionRanges(new VersionRange[] {new VersionRange("[1.2, 2)")});
+        entity.setDeniedVersionRanges(new VersionRange[] {new VersionRange("[1.3.1,1.3.1]")});
+        assertFalse(entity.isAllowed(new Version("1.0")));
+        assertTrue(entity.isAllowed(new Version("1.3")));
+        assertFalse(entity.isAllowed(new Version("1.3.1")));
+        assertTrue(entity.isAllowed(new Version("1.3.2")));
+        assertFalse(entity.isAllowed(new Version("2.1")));
+    }
+}
diff --git a/src/test/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntityTest.java b/src/test/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntityTest.java
index 165a5ed..1679d38 100644
--- a/src/test/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntityTest.java
+++ b/src/test/java/org/apache/sling/feature/extension/apiregions/api/config/AttributeableEntityTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sling.feature.extension.apiregions.api.config;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -24,6 +25,8 @@
 import java.io.IOException;
 
 import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObjectBuilder;
 import javax.json.JsonValue;
 
 import org.apache.sling.feature.Extension;
@@ -74,6 +77,20 @@
         assertTrue(entity.getAttributes().isEmpty());
     }
 
+    @Test public void testGetStringArray() throws IOException {
+        final AE entity = new AE();
+        assertNull(entity.getStringArray("foo"));
+        entity.getAttributes().put("foo", Json.createValue("bar"));
+        assertArrayEquals(new String[] {"bar"}, entity.getStringArray("foo"));
+        assertTrue(entity.getAttributes().isEmpty());
+        final JsonArrayBuilder jab = Json.createArrayBuilder();
+        jab.add("a");
+        jab.add("b");
+        entity.getAttributes().put("foo", jab.build());
+        assertArrayEquals(new String[] {"a", "b"}, entity.getStringArray("foo"));
+        assertTrue(entity.getAttributes().isEmpty());
+    }
+
     @Test public void testGetBoolean() throws IOException {
         final AE entity = new AE();
         assertTrue(entity.getBoolean("foo", true));
@@ -122,4 +139,20 @@
             // this is expected
         }
     }
+
+    @Test public void testSetStringArray() {
+        final AE entity = new AE();
+
+        JsonObjectBuilder builder = Json.createObjectBuilder();
+        entity.setStringArray(builder, "foo", null);
+        assertEquals("{}", builder.build().toString());
+
+        builder = Json.createObjectBuilder();
+        entity.setStringArray(builder, "foo", new String[0]);
+        assertEquals("{}", builder.build().toString());
+
+        builder = Json.createObjectBuilder();
+        entity.setStringArray(builder, "foo", new String[] {"a", "b"});
+        assertEquals("{\"foo\":[\"a\",\"b\"]}", builder.build().toString());
+    }
 }
\ No newline at end of file