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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,10 @@ contract:
number fails CI; a missing or stale API in an opt-in compile block fails
compilation. Plain ` ```kotlin ` blocks are illustrative and are not executed.

The executable claim format supports a few trust-oriented knobs:

- `// repeat: N` runs the same claim `N` times to catch flaky or unstable rules.
- `// assert-not: text = "..."` checks that a string is absent from the rendered semantics tree.
- `// assert: has-click-action = "..."` checks clickable semantics on a node with matching text.

The `verify-claims` CI job runs this on every PR and master push.
31 changes: 23 additions & 8 deletions buildSrc/src/main/kotlin/ClaimParser.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
data class Assertion(val prop: String, val dp: Int)
data class Claim(val name: String, val subjectSource: String, val asserts: List<Assertion>)
data class Assertion(val prop: String, val value: String, val negated: Boolean = false)
data class Claim(val name: String, val subjectSource: String, val asserts: List<Assertion>, val repeatCount: Int)
data class CompileBlock(val name: String, val subjectSource: String)

object ClaimParser {
private val verifyFenceOpen = Regex("""^```kotlin\s+verify\s*$""")
private val compileFenceOpen = Regex("""^```kotlin\s+compile\s*$""")
private val fenceClose = Regex("""^```\s*$""")
private val nameLine = Regex("""^//\s*name:\s*([a-z0-9-]+)\s*$""")
private val assertLine = Regex("""^//\s*assert:\s*(width|height)\s*=\s*(\d+)\.dp\s*$""")
private val allowedProps = setOf("width", "height")
private val repeatLine = Regex("""^//\s*repeat:\s*(\d+)\s*$""")
private val dpAssertLine = Regex("""^//\s*assert:\s*(width|height)\s*=\s*(\d+)\.dp\s*$""")
private val stringAssertLine = Regex("""^//\s*assert:\s*(text|has-click-action)\s*=\s*"([^"]+)"\s*$""")
private val stringNegativeAssertLine = Regex("""^//\s*assert-not:\s*(text)\s*=\s*"([^"]+)"\s*$""")
private val allowedProps = setOf("width", "height", "text", "has-click-action")

fun parse(markdown: String, sourceFile: String): List<Claim> {
val lines = markdown.lines()
Expand Down Expand Up @@ -62,20 +65,32 @@ object ClaimParser {
?: err("missing '// name:' line")
if (!seenNames.add(name)) err("duplicate name '$name'")

val repeatCount = body.firstNotNullOfOrNull { repeatLine.matchEntire(it.trim())?.groupValues?.get(1)?.toInt() } ?: 1
if (repeatCount < 1) err("repeat count must be >= 1")

val asserts = body.mapNotNull { line ->
assertLine.matchEntire(line.trim())?.let { m -> Assertion(m.groupValues[1], m.groupValues[2].toInt()) }
val trimmed = line.trim()
dpAssertLine.matchEntire(trimmed)?.let { m ->
Assertion(m.groupValues[1], m.groupValues[2])
} ?: stringAssertLine.matchEntire(trimmed)?.let { m ->
Assertion(m.groupValues[1], m.groupValues[2])
} ?: stringNegativeAssertLine.matchEntire(trimmed)?.let { m ->
Assertion(m.groupValues[1], m.groupValues[2], true)
}
}
if (asserts.isEmpty()) {
err("no '// assert:' or '// assert-not:' lines")
}
if (asserts.isEmpty()) err("no '// assert: <width|height> = N.dp' lines")
asserts.forEach { if (it.prop !in allowedProps) err("unsupported assert prop '${it.prop}'") }

val subject = body.filterNot { l ->
val t = l.trim()
nameLine.matches(t) || assertLine.matches(t)
nameLine.matches(t) || repeatLine.matches(t) || dpAssertLine.matches(t) || stringAssertLine.matches(t) || stringNegativeAssertLine.matches(t)
}.joinToString("\n").trim()
if (!subject.contains(Regex("""@Composable\s+fun\s+Subject\s*\("""))) {
err("no '@Composable fun Subject()' found")
}
return Claim(name, subject, asserts)
return Claim(name, subject, asserts, repeatCount)
}

private fun toCompileBlock(
Expand Down
39 changes: 30 additions & 9 deletions buildSrc/src/main/kotlin/GenerateClaimTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ abstract class GenerateClaimTests : DefaultTask() {
out.deleteRecursively(); out.mkdirs()

val tests = claims.joinToString("\n\n") { c ->
val asserts = c.asserts.joinToString("\n") { a ->
val fn = if (a.prop == "width") "assertWidthIsEqualTo" else "assertHeightIsEqualTo"
" rule.onRoot().$fn(${a.dp}.dp)"
(1..c.repeatCount).joinToString("\n\n") { trial ->
val asserts = c.asserts.joinToString("\n") { a -> emitAssertion(a) }
val suffix = if (c.repeatCount == 1) "" else "_trial$trial"
"""
@Test fun `${c.name}$suffix`() {
rule.setContent { ClaimSubjects.`${c.name}`() }
$asserts
}
""".trimIndent()
}
"""
@Test fun `${c.name}`() {
rule.setContent { ClaimSubjects.`${c.name}`() }
$asserts
}
""".trimIndent()
}

val subjects = claims.joinToString("\n\n") { c ->
Expand Down Expand Up @@ -87,18 +87,39 @@ abstract class GenerateClaimTests : DefaultTask() {
require(duplicate == null) { "duplicate kotlin $marker block name '$duplicate'" }
}

private fun emitAssertion(assertion: Assertion): String =
when (assertion.prop) {
"width" -> " rule.onRoot().assertWidthIsEqualTo(${assertion.value}.dp)"
"height" -> " rule.onRoot().assertHeightIsEqualTo(${assertion.value}.dp)"
"text" -> {
val call = if (assertion.negated) "assertDoesNotExist()" else "assertExists()"
" rule.onNodeWithText(\"${assertion.value.escapeKotlinString()}\").$call"
}
"has-click-action" ->
" rule.onNodeWithText(\"${assertion.value.escapeKotlinString()}\").assertHasClickAction()"
else -> error("unsupported assertion prop '${assertion.prop}'")
}

private fun String.escapeKotlinString(): String =
replace("\\", "\\\\").replace("\"", "\\\"")

private fun generatedImports(): String =
"""
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.*
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.assertHeightIsEqualTo
import org.junit.Rule
Expand Down
21 changes: 20 additions & 1 deletion buildSrc/src/test/kotlin/ClaimParserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,29 @@ class ClaimParserTest {
assertEquals(1, claims.size)
val c = claims.single()
assertEquals("size-before-padding", c.name)
assertEquals(listOf("width" to 100, "height" to 100), c.asserts.map { it.prop to it.dp })
assertEquals(listOf("width" to "100", "height" to "100"), c.asserts.map { it.prop to it.value })
assert(c.subjectSource.contains("fun Subject()"))
}

@Test fun parsesTextAndClickActionAssertions() {
val md = """
```kotlin verify
// name: button-semantics
@Composable fun Subject() {
Button(onClick = {}) { Text("Save") }
}
// assert: text = "Save"
// assert: has-click-action = "Save"
```
""".trimIndent()

val claim = ClaimParser.parse(md, "x.md").single()
assertEquals(
listOf("text" to "Save", "has-click-action" to "Save"),
claim.asserts.map { it.prop to it.value },
)
}

@Test fun rejectsMissingName() {
val md = "```kotlin verify\n@Composable fun Subject() {}\n// assert: width = 1.dp\n```"
val ex = assertThrows(IllegalStateException::class.java) { ClaimParser.parse(md, "x.md") }
Expand Down
14 changes: 14 additions & 0 deletions skills/compose-expert/references/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ Box(
}
```

Executable smoke check for built-in button semantics:

```kotlin verify
// name: button-text-exposes-click-action
// repeat: 3
// assert: text = "Click me"
// assert: has-click-action = "Click me"
// assert-not: text = "Delete"
@Composable
fun Subject() {
Button(onClick = { }) { Text("Click me") }
}
```

**Source**: `androidx/compose/ui/semantics/`

---
Expand Down
9 changes: 8 additions & 1 deletion skills/compose-expert/references/animation.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ Each automatically handles coroutines and recomposition. Use the `label` paramet

Controls appear/disappear animations with enter and exit transitions.

```kotlin
```kotlin verify
// name: animated-visibility-renders-visible-content
// repeat: 3
// assert: text = "Hello!"
// assert-not: text = "Goodbye!"
@Composable
fun Subject() {
var visible by remember { mutableStateOf(true) }

AnimatedVisibility(visible = visible) {
Expand All @@ -42,6 +48,7 @@ AnimatedVisibility(visible = visible) {

// Trigger
Button(onClick = { visible = !visible }) { Text("Toggle") }
}
```

### Enter/Exit Transitions
Expand Down
8 changes: 6 additions & 2 deletions skills/compose-expert/references/composition-locals.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ CompositionLocals provide a way to pass data implicitly down the composition tre

A CompositionLocal is a slot in the composition that holds a value accessible to any descendant composable without explicit parameter passing. Values are provided using `CompositionLocalProvider` and accessed via `current`.

```kotlin
```kotlin verify
// name: composition-local-provider-overrides-default
// repeat: 3
// assert: text = "Dark"
// assert-not: text = "Light"
val localAppTheme = compositionLocalOf { "Light" }

@Composable
fun MyScreen() {
fun Subject() {
CompositionLocalProvider(localAppTheme provides "Dark") {
DescendantComposable() // Can access "Dark" via localAppTheme.current
}
Expand Down
17 changes: 17 additions & 0 deletions skills/compose-expert/references/lists-scrolling.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ LazyColumn {
}
```

Executable smoke check for the count overload:

```kotlin verify
// name: lazy-column-count-renders-visible-items
// repeat: 3
// assert: text = "Item 0"
// assert-not: text = "Item 3"
@Composable
fun Subject() {
LazyColumn {
items(3) { index ->
Text("Item $index")
}
}
}
```

### `itemsIndexed` — With Index
```kotlin
LazyColumn {
Expand Down
2 changes: 2 additions & 0 deletions skills/compose-expert/references/modifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Order matters. Modifiers are applied left-to-right in the DSL, but conceptually

```kotlin verify
// name: padding-before-size-adds-to-footprint
// repeat: 3
// assert: width = 132.dp
// assert: height = 132.dp
// Footprint = 132x132. size fixes the 100x100 inner box, padding adds 16dp on
Expand All @@ -26,6 +27,7 @@ Order matters. Modifiers are applied left-to-right in the DSL, but conceptually

```kotlin verify
// name: size-before-padding-keeps-footprint
// repeat: 3
// assert: width = 100.dp
// assert: height = 100.dp
// Footprint = 100x100, NOT 132x132. size fixes the element at 100x100 first;
Expand Down
17 changes: 17 additions & 0 deletions skills/compose-expert/references/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ A type is **stable** if:
- Overrides to `equals()` and `hashCode()` are based on stable properties
- Recomposition is skipped when the same instance is passed

Executable check for a stable model rendered through a composable:

```kotlin verify
// name: stable-model-renders-text
// repeat: 3
// assert: text = "Ada"
// assert-not: text = "Bob"
@Immutable
data class Person(val name: String, val age: Int)

@Composable
fun Subject() {
val person = Person("Ada", 42)
Text(person.name)
}
```

Mark stable types explicitly:

```kotlin
Expand Down
17 changes: 17 additions & 0 deletions skills/compose-expert/references/side-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,23 @@ Source: `compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/

`LaunchedEffect` launches a coroutine in a scope tied to the composable's lifecycle. The coroutine is cancelled if the key changes or the composable leaves composition.

Executable check for the common "load then render" pattern:

```kotlin verify
// name: launched-effect-updates-ui
// repeat: 3
// assert: text = "loaded"
// assert-not: text = "loading"
@Composable
fun Subject() {
var data by remember { mutableStateOf("loading") }
LaunchedEffect(Unit) {
data = "loaded"
}
Text(data)
}
```

```kotlin
@Composable
fun DataLoader(userId: String) {
Expand Down
9 changes: 7 additions & 2 deletions skills/compose-expert/references/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ Both associate state with a composition key, but differ in persistence scope.
- Lost on process death, configuration changes, back navigation
- Best for UI state: selection, expanded/collapsed, scroll position

```kotlin
```kotlin verify
// name: remember-counter-renders-clickable-state
// repeat: 3
// assert: text = "Count: 0"
// assert: has-click-action = "Count: 0"
// assert-not: text = "Count: 1"
@Composable
fun Counter() {
fun Subject() {
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
Expand Down
5 changes: 4 additions & 1 deletion skills/compose-expert/references/theming-material3.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

`MaterialTheme` is the root provider for design tokens in Compose Material 3. It establishes `colorScheme`, `typography`, and `shapes` across your app.

```kotlin compile
```kotlin verify
// name: material-theme-provides-tokens
// repeat: 3
// assert: text = "Uses MaterialTheme.typography.bodyLarge"
// assert-not: text = "MaterialExpressiveTheme"
@Composable
fun Subject() {
MaterialTheme(
Expand Down
15 changes: 15 additions & 0 deletions skills/compose-expert/references/view-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ ListItem(

**Key principle:** Slots accept `@Composable` lambdas, not pre-composed values. This ensures composition is deferred and scope-aware.

Executable check for the slot pattern:

```kotlin verify
// name: slot-pattern-renders-child-content
// repeat: 3
// assert: text = "Hello"
// assert-not: text = "World"
@Composable
fun Subject() {
Card {
Text("Hello")
}
}
```

```kotlin
// ❌ Wrong: passes composed value
fun CustomLayout(content: String) { ... }
Expand Down
Loading