Skip to content

[Feat] Modifier.Node KSP 코드젠 + Maven Central 배포 설정 - #8

Merged
sonms merged 21 commits into
masterfrom
feat/modifier-node-codegen
Sep 10, 2026
Merged

sonms merged 21 commits into
masterfrom
feat/modifier-node-codegen

Conversation

@sonms

@sonms sonms commented Sep 10, 2026

Copy link
Copy Markdown
Owner

@ModifierNodeFactory 를 붙인 Modifier.Node 서브클래스에서 ModifierNodeElement 와
Modifier 확장 함수를 KSP 로 생성한다. composed { } 를 벗어나려 할 때 마주치는
보일러플레이트 벽(손코드 7줄 → 55줄)을 없애는 것이 목적이다.

무엇이 들어왔나

모듈 종류 설명
:modifier-node-annotations Kotlin JVM · 배포 어노테이션 4종 + InvalidationScope. 전부 SOURCE retention
:modifier-node-processor Kotlin JVM · 배포 KSP 프로세서. KotlinPoet 으로 Element + 확장 함수 방출
:modifier-node-sample Android Library · 내부 예제 5종 + Robolectric 계약 테스트
:app — 라이브 데모 탭 (gridOverlay, squareThumbnail)

어노테이션: @ModifierNodeFactory, @Invalidates, @SkipWhenFalse/@SkipWhenTrue, @OnChange.

설계 판단

  • 생성된 update() 는 Measure > Placement > Draw 계층을 접는다 — 켜진 것 중 상위 하나만 호출
  • Element 는 항상 internal — ABI 에는 Modifier 확장 함수 하나만 남는다
  • @Invalidates 사용 시 shouldAutoInvalidate = false 선언을 KSP 가 강제한다.
    없으면 Compose 자동 무효화 때문에 no-op 이 되는데 codegen 은 노드 클래스를 수정할 수 없다
  • 성능 주장은 낮춰 잡았다 — 실질 가치는 보일러플레이트 제거 + equals 안정성 + ABI 축소

배경은 ARCHITECTURE.md, composed 와의 비교는 modifier-node-sample/COMPARISON.md.

배포 설정

io.github.sonms:modifier-node-annotations / modifier-node-processor 0.0.1.
:modifier-node-sample 과 :app 은 내부용이라 제외.

  • publish.yml: 태그 패턴 2개 추가, allowlist 를 case 로 교체, 테스트 태스크 분기
  • ci.yml: 세 모듈 조립 + sample release 테스트 추가
  • dokka.yml + docs/index.html + README.md / README_KO.md

vanniktech 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건 (수정 포함)

  1. :modifier-node-sample 의 testReleaseUnitTest 6개가 전부 실패하고 있었다.
    ui-test-manifest 가 debugImplementation 뿐이라 release 테스트 매니페스트에
    ComponentActivity 가 없었다. CI 가 debug 만 돌려서 드러나지 않았다.
  2. 에러를 내고도 파일을 생성하는 경로 2개. 특히 @Invalidates(Measure) 를 Draw 전용
    노드에 붙이면 컴파일되지 않는 invalidateMeasurement() 호출을 방출했다.
    둘 다 "하드 에러면 방출 중단" 으로 통일.

또 기존 publish.yml 이 :$MODULE:testReleaseUnitTest 를 고정 호출해서, JVM 모듈 태그를
추가만 했다면 릴리스가 그 자리에서 멈췄을 것이다. 모듈 종류별 분기로 수정했다.

검증

  • 두 모듈 publishMavenPublicationToMavenLocal → jar · sources · javadoc · POM · .asc 10개 파일 생성
  • Dokka 적용 후 javadoc jar 2개 → 106개 파일 (어노테이션 4종 문서화). Maven Central 은
    릴리스 버전을 덮어쓸 수 없어 배포 전에 붙여야 했다
  • 프로세서 에러 경로 7종을 잘못된 입력으로 직접 확인 — 전부 정확한 소스 위치에 보고
  • 태그 파싱 로직 bash 검증, CI 명령 로컬 전체 통과

머지 방식

--no-ff merge commit 으로 부탁드립니다. 조사 과정을 squash 하지 않고 남긴다 —
특히 @Invalidates 의 파라미터 단위 invalidation 을 한 차례 "불가능" 이라 잘못
결론냈다가 정정한 이력이 히스토리와 ARCHITECTURE.md 부록 B 에 함께 남아 있다.

머지 후

태그를 순서대로 푸시하면 publish.yml 이 업로드부터 릴리스까지 자동 처리한다.
processor 의 POM 이 annotations 좌표를 참조하므로 annotations 를 먼저 올리고,
Central 반영을 확인한 뒤 processor 를 올린다.

modifier-node-annotations-v0.0.1
modifier-node-processor-v0.0.1

🤖 Generated with Claude Code

https://claude.ai/code/session_01HnjkW5FJVRxQB6v5Kjj1pK

sonms and others added 21 commits September 8, 2026 13:58
- 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
…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
@sonms
sonms merged commit e80212e into master Sep 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant