-
Notifications
You must be signed in to change notification settings - Fork 21
ADFA-3588: Introduce plugin API binary compatibility tracking and version contract #1265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.itsaky.androidide.plugins | ||
|
|
||
| @RequiresOptIn( | ||
| level = RequiresOptIn.Level.ERROR, | ||
| message = "This declaration is internal to the IDE and is not part of the plugin API contract. " + | ||
| "It may change or be removed at any time without a major version bump." | ||
| ) | ||
| @Retention(AnnotationRetention.BINARY) | ||
| @Target( | ||
| AnnotationTarget.CLASS, | ||
| AnnotationTarget.FUNCTION, | ||
| AnnotationTarget.PROPERTY, | ||
| AnnotationTarget.CONSTRUCTOR, | ||
| AnnotationTarget.TYPEALIAS, | ||
| AnnotationTarget.PROPERTY_GETTER, | ||
| AnnotationTarget.PROPERTY_SETTER, | ||
| ) | ||
| annotation class InternalPluginApi |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.itsaky.androidide.plugins | ||
|
|
||
| /** | ||
| * Plugin API contract version (SemVer). Bump major for binary-incompatible changes | ||
| * flagged by `:plugin-api:apiCheck`, minor for additive changes; then run | ||
| * `:plugin-api:apiDump` to refresh `plugin-api/api/plugin-api.api`. | ||
| */ | ||
| object PluginApiVersion { | ||
|
|
||
| const val CURRENT: String = "1.0.0" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.itsaky.androidide.plugins.manager.security | ||
|
|
||
| class PluginApiIncompatibleException( | ||
| val pluginId: String, | ||
| val requiredVersion: String, | ||
| val availableVersion: String, | ||
| val reason: Reason, | ||
| ) : Exception(buildMessage(pluginId, requiredVersion, availableVersion, reason)) { | ||
|
|
||
| enum class Reason { | ||
| MAJOR_MISMATCH, | ||
| REQUIRES_NEWER, | ||
| MALFORMED_VERSION, | ||
| } | ||
|
|
||
| private companion object { | ||
| fun buildMessage( | ||
| pluginId: String, | ||
| requiredVersion: String, | ||
| availableVersion: String, | ||
| reason: Reason, | ||
| ): String = when (reason) { | ||
| Reason.MAJOR_MISMATCH -> | ||
| "Plugin '$pluginId' targets plugin API $requiredVersion, " + | ||
| "but this IDE provides $availableVersion (incompatible major version)." | ||
| Reason.REQUIRES_NEWER -> | ||
| "Plugin '$pluginId' requires plugin API $requiredVersion, " + | ||
| "but this IDE provides $availableVersion. Update the IDE to use this plugin." | ||
| Reason.MALFORMED_VERSION -> | ||
| "Plugin '$pluginId' declares an invalid plugin API version: '$requiredVersion'." | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.itsaky.androidide.plugins.manager.security | ||
|
|
||
| internal object PluginApiVersionChecker { | ||
|
|
||
| private val SEMVER = Regex("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$") | ||
|
|
||
| fun isCompatible(required: String, current: String): Boolean { | ||
| val r = parse(required) ?: return false | ||
| val c = parse(current) ?: error("Invalid current plugin API version: '$current'") | ||
| return r.major == c.major && | ||
| (r.minor < c.minor || (r.minor == c.minor && r.patch <= c.patch)) | ||
| } | ||
|
|
||
| fun requireCompatible(pluginId: String, required: String, current: String) { | ||
| val r = parse(required) ?: throw PluginApiIncompatibleException( | ||
| pluginId = pluginId, | ||
| requiredVersion = required, | ||
| availableVersion = current, | ||
| reason = PluginApiIncompatibleException.Reason.MALFORMED_VERSION, | ||
| ) | ||
| val c = parse(current) ?: error("Invalid current plugin API version: '$current'") | ||
| if (r.major != c.major) { | ||
| throw PluginApiIncompatibleException( | ||
| pluginId = pluginId, | ||
| requiredVersion = required, | ||
| availableVersion = current, | ||
| reason = PluginApiIncompatibleException.Reason.MAJOR_MISMATCH, | ||
| ) | ||
| } | ||
| if (r.minor > c.minor || (r.minor == c.minor && r.patch > c.patch)) { | ||
| throw PluginApiIncompatibleException( | ||
| pluginId = pluginId, | ||
| requiredVersion = required, | ||
| availableVersion = current, | ||
| reason = PluginApiIncompatibleException.Reason.REQUIRES_NEWER, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private data class Version(val major: Int, val minor: Int, val patch: Int) | ||
|
|
||
| private fun parse(raw: String): Version? { | ||
| val match = SEMVER.matchEntire(raw.trim()) ?: return null | ||
| return Version( | ||
| major = match.groupValues[1].toInt(), | ||
| minor = match.groupValues[2].ifBlank { "0" }.toInt(), | ||
| patch = match.groupValues[3].ifBlank { "0" }.toInt(), | ||
| ) | ||
|
Comment on lines
+42
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.ktRepository: appdevforall/CodeOnTheGo Length of output: 2453 🏁 Script executed: find . -type f -name "*PluginApiVersionChecker*Test*" -o -name "*Test*PluginApiVersionChecker*"Repository: appdevforall/CodeOnTheGo Length of output: 179 🏁 Script executed: rg -l "PluginApiVersionChecker" --type kotlinRepository: appdevforall/CodeOnTheGo Length of output: 373 🏁 Script executed: cat -n ./plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionCheckerTest.ktRepository: appdevforall/CodeOnTheGo Length of output: 5711 Catch Input like 🔧 Targeted fix private fun parse(raw: String): Version? {
val match = SEMVER.matchEntire(raw.trim()) ?: return null
- return Version(
- major = match.groupValues[1].toInt(),
- minor = match.groupValues[2].ifBlank { "0" }.toInt(),
- patch = match.groupValues[3].ifBlank { "0" }.toInt(),
- )
+ return try {
+ Version(
+ major = match.groupValues[1].toInt(),
+ minor = match.groupValues[2].ifBlank { "0" }.toInt(),
+ patch = match.groupValues[3].ifBlank { "0" }.toInt(),
+ )
+ } catch (_: NumberFormatException) {
+ null
+ }
}🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| package com.itsaky.androidide.plugins.manager.security | ||
|
|
||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertThrows | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
|
|
||
| class PluginApiVersionCheckerTest { | ||
|
|
||
| @Test | ||
| fun `same version is compatible`() { | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `IDE with newer minor accepts plugin built against older minor`() { | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.5.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `IDE with newer patch accepts plugin built against older patch`() { | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.0.5")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `plugin requiring newer minor is incompatible`() { | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "1.5.0", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `plugin requiring newer patch within same minor is incompatible`() { | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.5", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `plugin built for older major is incompatible`() { | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "2.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `plugin built for newer major is incompatible`() { | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "2.0.0", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `major-only shorthand parses as major dot zero dot zero`() { | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1", current = "1.0.0")) | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1", current = "1.5.3")) | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "2", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `major-minor shorthand parses with patch zero`() { | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1.2", current = "1.2.0")) | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = "1.2", current = "1.2.5")) | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "1.3", current = "1.2.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `malformed version is incompatible`() { | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "v1.0.0", current = "1.0.0")) | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0-snapshot", current = "1.0.0")) | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0.0", current = "1.0.0")) | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "", current = "1.0.0")) | ||
| assertFalse(PluginApiVersionChecker.isCompatible(required = "abc", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `whitespace is trimmed before parsing`() { | ||
| assertTrue(PluginApiVersionChecker.isCompatible(required = " 1.0.0 ", current = "1.0.0")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `requireCompatible does not throw on compatible versions`() { | ||
| PluginApiVersionChecker.requireCompatible("p", required = "1.0.0", current = "1.5.0") | ||
| } | ||
|
|
||
| @Test | ||
| fun `requireCompatible throws MAJOR_MISMATCH for cross-major rejection`() { | ||
| val ex = assertThrows(PluginApiIncompatibleException::class.java) { | ||
| PluginApiVersionChecker.requireCompatible("plugin.x", required = "2.0.0", current = "1.0.0") | ||
| } | ||
| assertEquals("plugin.x", ex.pluginId) | ||
| assertEquals("2.0.0", ex.requiredVersion) | ||
| assertEquals("1.0.0", ex.availableVersion) | ||
| assertEquals(PluginApiIncompatibleException.Reason.MAJOR_MISMATCH, ex.reason) | ||
| } | ||
|
|
||
| @Test | ||
| fun `requireCompatible throws REQUIRES_NEWER when plugin asks newer minor`() { | ||
| val ex = assertThrows(PluginApiIncompatibleException::class.java) { | ||
| PluginApiVersionChecker.requireCompatible("plugin.y", required = "1.5.0", current = "1.0.0") | ||
| } | ||
| assertEquals(PluginApiIncompatibleException.Reason.REQUIRES_NEWER, ex.reason) | ||
| } | ||
|
|
||
| @Test | ||
| fun `requireCompatible throws MALFORMED_VERSION for unparseable input`() { | ||
| val ex = assertThrows(PluginApiIncompatibleException::class.java) { | ||
| PluginApiVersionChecker.requireCompatible("plugin.z", required = "not-a-version", current = "1.0.0") | ||
| } | ||
| assertEquals(PluginApiIncompatibleException.Reason.MALFORMED_VERSION, ex.reason) | ||
| assertEquals("not-a-version", ex.requiredVersion) | ||
| } | ||
|
|
||
| @Test | ||
| fun `requireCompatible throws REQUIRES_NEWER for newer patch within same minor`() { | ||
| val ex = assertThrows(PluginApiIncompatibleException::class.java) { | ||
| PluginApiVersionChecker.requireCompatible("p", required = "1.0.5", current = "1.0.0") | ||
| } | ||
| assertEquals(PluginApiIncompatibleException.Reason.REQUIRES_NEWER, ex.reason) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
toIntmight throw in case of invalid input. Please add safeguards here (and test cases).