[Feat] Modifier.Node KSP 코드젠 + Maven Central 배포 설정 - #8
Merged
Merged
Conversation
- modifier-node-annotations: @ModifierNodeFactory, @invalidates, InvalidationScope - modifier-node-processor: KSP + KotlinPoet generator for ModifierNodeElement (create/update/equals/hashCode/inspectableProperties) and public extension fun; invalidation hierarchy folding (Measure > Placement > Draw); var/scope validation - modifier-node-sample: isolated dogfood module with two examples (debugTint draw-only, fixedSquare draw+layout) - ARCHITECTURE.md: scope, non-goals, honest limitations, decision gate Experimental branch; not for master merge until the decision gate passes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- FadingEdgeNode: graphicsLayer+drawWithCache chain rewritten as a raw
DrawModifierNode with manual GraphicsLayer lifecycle
- ARCHITECTURE.md §9: LOC comparison + findings
- fadingEdge never used composed; lowering a composition-free chain to a
raw node is a net loss (lost drawWithCache caching, manual layer lifecycle)
- codegen value is only in cases that would otherwise use composed
- conditional application (enabled=false early return) not expressible today
- decision-gate condition 1 unmet, but target was mis-chosen; needs a real
composed-style case next
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- Case A (accentOverlay): CompositionLocal read + draw — codegen decisive (7 -> 10 hand-written lines vs 7 -> 55 without codegen) - Case B (pressScale): InteractionSource + animation — codegen insufficient alone (10 -> 40 lines); surfaces missing "onUpdate hook" limitation - COMPARISON.md with verdicts; decision-gate condition 1 met when judged on Case A rather than the mis-chosen fadingEdge Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
… not viable - GeneratedElementTest (pure JUnit): equals/hashCode reuse contract, create/update field sync — the contract codegen actually guarantees - InvalidationContractTest (Robolectric): equals-skip = unchanged params never call update() - Verified from androidx.compose.ui 1.12.0-beta02 bytecode: NodeChain.updateNode() unconditionally calls autoInvalidateUpdatedNode() after update(); the only suppressor Modifier.Node.shouldAutoInvalidate is on the Node (codegen can't inject) and @deprecated in 1.12 - => generated per-param invalidateDraw/Measurement calls have no runtime effect; @invalidates survives only as a compile-time lint - ARCHITECTURE.md §10 (finding) + §11 (conclusion: A stop / B rescope to boilerplate+ABI / C broaden to Node ergonomics) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
…utoInvalidate Previous commit claimed the invalidation-scope feature was architecturally impossible. That was a bytecode misread: the Deprecated:true attribute on the synthetic getShouldAutoInvalidate$annotations() method is a Kotlin codegen artifact (getNode$annotations() has it too), not a deprecation of the property. From androidx.compose.ui 1.12.0-beta02 sources: - NodeKind.autoInvalidateNodeSelf: `if (phase == Updated && !node.shouldAutoInvalidate) return` - Modifier.Node.shouldAutoInvalidate is a documented, open, non-deprecated opt-out - Compose's own SimpleGraphicsLayerModifier / PainterNode / FocusTargetNode use it - AutoInvalidateProbeTest: controlled experiment proving a shouldAutoInvalidate=false node does NOT remeasure on a draw-only param change (and still remeasures on a measure param via update()) - processor: when any @invalidates is present, require the Node to declare `override val shouldAutoInvalidate` (compile error with fix-it otherwise) - all sample nodes updated with the one-line override - ARCHITECTURE §10 rewritten (incl. why the first conclusion was wrong) + §10.5 on the realistically-narrow perf benefit; §11 decision gate revised Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
…ity opt - @ModifierNodeFactory(visibility = GeneratedVisibility.Public | Internal), default Public — controls the generated Modifier.<name>() extension function - generated <Name>Element is now always internal regardless of node visibility, so only the extension function is part of the binary API - node validation: private node => error (generated file can't reference it); public node => warning (it's in your ABI, make it internal) - GeneratedElementTest.generated_element_is_internal (needs kotlin-reflect) - ARCHITECTURE §11: B confirmed; ABI task done, skipWhen next Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- marker annotations on a Boolean parameter; processor prepends `if (!param) return this` (multiple markers OR-joined) to the generated Modifier.<name>() function, so a disabled modifier never attaches a node - validation: marker requires a Boolean param; not both markers on one param - fadingEdge dogfood updated to `@SkipWhenFalse var enabled` (replaces the in-node !enabled branch) - skipWhenFalse_gates_application test: Modifier.skipProbe(on=false) === Modifier - ARCHITECTURE §11: item 2 done, onUpdate hook next Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- annotations: concise bilingual KDoc, drop the MVP-limitations essay - sample modifiers: one-line bilingual KDoc; move the composed-vs-Node rationale to COMPARISON.md, drop ASCII separators and investigation notes - tests: short "what this proves" comments, drop the misleading @Suppress("DEPRECATION") on shouldAutoInvalidate (it is not deprecated) - processor: shorten the shouldAutoInvalidate error/comment Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- @onchange on a value parameter; generated update() calls the node's fun on<Name>Changed() after assigning the new value, only when it changed - KSP validates the node declares fun on<Name>Changed() (no args) - PressScaleNode rewritten with @onchange: drops the boundSource dedup field and the manual rebind() call in draw() (Case B hand-written ~40 -> ~35 lines) - onChange_fires_only_when_param_changes test - COMPARISON.md Case B + ARCHITECTURE §10/§11 updated Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- FadingEdgeNode.draw(): use the DrawScope-scoped GraphicsLayer.record(size) overload, not the GraphicsLayer member — the member form throws at runtime when drawContent() is recorded (see LayoutNodeDrawScope error message) - FadingEdgeNode: @invalidates(None) on the @SkipWhenFalse `enabled` gate - processor: skip codegen for a node when a param has a hard error (non-var, bad marker, missing @onchange callback) — prevents cascading errors in the generated file - processor: warn on function-type params (reference-identity equals defeats reuse); guard against a blank derived function name - annotations module: explicitApi() + explicit `public` modifiers - ARCHITECTURE.md: sync stale sections (§3.2 visibility not binaryCompat, §6 progress points to §11, remove done TODOs); add §12 @default review All 12 tests green; debug + release AAR build clean, no warnings from our code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
- README: positioning (niche, solo maintenance, no SLA), setup, usage, annotation reference table, shouldAutoInvalidate requirement, honest performance expectations (§10.5), limitations - ARCHITECTURE §11: README done, @default explicitly deferred Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
…kipWhenTrue - split SampleNodes.kt into DebugTint.kt + FixedSquare.kt (one modifier each, wheelpicker style); rename compare/LocalTint.kt -> compare/AccentOverlay.kt to match the modifier name - SkipProbeNode gains a @SkipWhenTrue param; skip_markers_gate_application now covers both markers and the OR-join (previously @SkipWhenTrue was never exercised) - drop unused testImplementation(compose.ui.tooling) - add modifier-node-sample/README.md (module purpose + file/test map) 12 tests green, clean debug + release build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
The doc had grown into an investigation journal with contradictions layered on (e.g. §9 "조건부 적용 표현 불가" after @SkipWhen* shipped; two competing decision gates; §10.4 "ABI 미구현" after it shipped). - restructured: 문제 → 하는 것 → 어노테이션 → shouldAutoInvalidate 사실 → 가치/한계 → 비목표 → 비교요약 → 구현상태 → 판단근거 → 부록(@default, 조사이력) - single accurate value statement: main value = boilerplate removal + equals stability + ABI; parameter-level invalidation is a narrow micro-opt (was contradicted between §3 and §10.5) - one decision gate, not two; investigation drama moved to a short 부록 B - preserved the load-bearing facts: NodeKind.kt:337 opt-out, the $annotations Deprecated artifact, Compose's own usage - 388 → 234 lines; fixed §-refs in COMPARISON.md and sample README No code changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
…a throwaway Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir
…ix doc formatting - Add `modifier-node-annotations`, `modifier-node-processor`, and `modifier-node-sample` to `.idea/gradle.xml` - Fix code block language tag in `ARCHITECTURE.md`
…lliJ config - Lower project language level and bytecode target level from Java 21 to Java 11 in `.idea/misc.xml` and `.idea/compiler.xml` - Set Gradle JVM explicitly to Java 17 in `.idea/gradle.xml`
codegen 산출물을 실제 앱에서 조작해 볼 수 있는 탭을 추가한다. - gridOverlay: @SkipWhenFalse(노드 미attach) + @invalidates(Draw) - squareThumbnail: Measure/Draw 계층 접힘 시연 - :app 에 ksp 플러그인과 두 모듈 의존 추가 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnjkW5FJVRxQB6v5Kjj1pK
배포 전 검증에서 나온 두 건. 1. 에러를 내고도 파일을 생성하던 경로 2개를 막는다. - @invalidates 인데 shouldAutoInvalidate 미선언 - @invalidates(scope) 가 노드가 구현한 인터페이스와 불일치 후자는 컴파일되지 않는 invalidate 호출을 방출하고 있었다. 둘 다 "하드 에러면 방출 중단" 규칙(§구현)으로 통일. 2. :modifier-node-sample 의 testReleaseUnitTest 6개가 전부 깨져 있었다. ui-test-manifest 가 debugImplementation 뿐이라 release 테스트 매니페스트에 ComponentActivity 가 없어 Robolectric 이 액티비티를 못 띄웠다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnjkW5FJVRxQB6v5Kjj1pK
배포 단위는 :modifier-node-annotations 와 :modifier-node-processor 둘. :modifier-node-sample 과 :app 은 내부용이라 제외. - 두 모듈에 vanniktech mavenPublishing (io.github.sonms, 0.0.1) - publish.yml: 태그 패턴 2개 추가, 모듈 allowlist 를 case 로 교체, 테스트 태스크를 모듈 종류별로 분기 (JVM 모듈엔 testReleaseUnitTest 가 없어 기존 스텝이면 그 자리에서 실패했다). 프로세서 계열은 실질 검증인 :modifier-node-sample 생성물 테스트를 함께 돌린다. - ci.yml: 세 모듈 조립 + sample release 테스트 추가 - vanniktech 0.36.0 → 0.34.0. 0.36.0 은 Kotlin JVM 모듈에 적용할 때 Kotlin 2.2.0+ 를 요구하는데 이 프로젝트는 2.0.21 이다. 0.34.0 에서 네 모듈 모두 publishAndReleaseToMavenCentral 이 정상 생성됨을 확인. 검증: 두 모듈 publishMavenPublicationToMavenLocal 로 jar/sources/javadoc/ POM/.asc 서명까지 생성 확인. CI 명령 로컬 전체 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnjkW5FJVRxQB6v5Kjj1pK
배포 전에 해야 하는 이유: Maven Central 은 릴리스된 버전을 덮어쓸 수 없다. Dokka 없이 0.0.1 을 올리면 javadoc jar 가 매니페스트뿐인 빈 파일로 영구히 박힌다. :modifier-node-annotations 에 Dokka 를 붙이니 vanniktech 가 plainJavadocJar 대신 dokkaJavadocJar 를 싣는다 (2개 → 106개 파일, 어노테이션 4종 전부 문서화). - :modifier-node-annotations 에 dokka 플러그인 - dokka.yml: 생성 + gh-pages 배포 스텝 추가. :modifier-node-processor 는 소비자용 API 가 없어 제외 - docs/index.html 카드 추가 - README.md / README_KO.md: Tooling 표 + 사용법 섹션 (설정 · 예제 · 생성물 · 어노테이션 4종 · shouldAutoInvalidate · 한계) 두 좌표가 모두 필요한 점을 명시했다. ksp(...) 는 프로세서 클래스패스만 채우므로 annotations 는 따로 컴파일 클래스패스에 올려야 한다 — :app 에서 의존을 빼고 빌드해 Unresolved reference 로 확인했고, compileOnly 로 충분한 것도 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnjkW5FJVRxQB6v5Kjj1pK
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@ModifierNodeFactory를 붙인Modifier.Node서브클래스에서ModifierNodeElement와Modifier확장 함수를 KSP 로 생성한다.composed { }를 벗어나려 할 때 마주치는보일러플레이트 벽(손코드 7줄 → 55줄)을 없애는 것이 목적이다.
무엇이 들어왔나
:modifier-node-annotationsInvalidationScope. 전부SOURCEretention:modifier-node-processor:modifier-node-sample:appgridOverlay,squareThumbnail)어노테이션:
@ModifierNodeFactory,@Invalidates,@SkipWhenFalse/@SkipWhenTrue,@OnChange.설계 판단
update()는Measure > Placement > Draw계층을 접는다 — 켜진 것 중 상위 하나만 호출internal— ABI 에는Modifier확장 함수 하나만 남는다@Invalidates사용 시shouldAutoInvalidate = false선언을 KSP 가 강제한다.없으면 Compose 자동 무효화 때문에 no-op 이 되는데 codegen 은 노드 클래스를 수정할 수 없다
배경은
ARCHITECTURE.md,composed와의 비교는modifier-node-sample/COMPARISON.md.배포 설정
io.github.sonms:modifier-node-annotations/modifier-node-processor0.0.1.:modifier-node-sample과:app은 내부용이라 제외.publish.yml: 태그 패턴 2개 추가, allowlist 를case로 교체, 테스트 태스크 분기ci.yml: 세 모듈 조립 + sample release 테스트 추가dokka.yml+docs/index.html+README.md/README_KO.mdvanniktech 0.36.0 → 0.34.0. 0.36.0 은 Kotlin JVM 모듈에 적용할 때 Kotlin 2.2.0+ 를
요구하는데 이 프로젝트는 2.0.21 이다. Kotlin 을 올리면 KSP·Compose 컴파일러까지 따라
올라가야 해서 범위를 벗어난다. 0.34.0 에서 네 모듈 모두
publishAndReleaseToMavenCentral이 정상 생성되는 것을 확인했으므로 기존
wheelpicker/ratingbar릴리스 경로도 그대로다.배포 전 점검에서 나온 버그 2건 (수정 포함)
:modifier-node-sample의testReleaseUnitTest6개가 전부 실패하고 있었다.ui-test-manifest가debugImplementation뿐이라 release 테스트 매니페스트에ComponentActivity가 없었다. CI 가 debug 만 돌려서 드러나지 않았다.@Invalidates(Measure)를 Draw 전용노드에 붙이면 컴파일되지 않는
invalidateMeasurement()호출을 방출했다.둘 다 "하드 에러면 방출 중단" 으로 통일.
또 기존
publish.yml이:$MODULE:testReleaseUnitTest를 고정 호출해서, JVM 모듈 태그를추가만 했다면 릴리스가 그 자리에서 멈췄을 것이다. 모듈 종류별 분기로 수정했다.
검증
publishMavenPublicationToMavenLocal→ jar · sources · javadoc · POM ·.asc10개 파일 생성릴리스 버전을 덮어쓸 수 없어 배포 전에 붙여야 했다
머지 방식
--no-ffmerge commit 으로 부탁드립니다. 조사 과정을 squash 하지 않고 남긴다 —특히
@Invalidates의 파라미터 단위 invalidation 을 한 차례 "불가능" 이라 잘못결론냈다가 정정한 이력이 히스토리와
ARCHITECTURE.md부록 B 에 함께 남아 있다.머지 후
태그를 순서대로 푸시하면
publish.yml이 업로드부터 릴리스까지 자동 처리한다.processor 의 POM 이 annotations 좌표를 참조하므로 annotations 를 먼저 올리고,
Central 반영을 확인한 뒤 processor 를 올린다.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HnjkW5FJVRxQB6v5Kjj1pK