-
-
Notifications
You must be signed in to change notification settings - Fork 55
ADFA-4128 (5/11): quickbuild:core — change detection & classification #1717
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| /build |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| # `:quickbuild:core` - the IDE-side half of Quick Build | ||
|
|
||
| Decides *what* to do on every save and drives the session that does it: watch the project, | ||
| classify each change into a [`BuildRoute`](src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/BuildRoute.kt), | ||
| run the live reload path or hand back to Gradle, and deploy the result to the running proxy app. | ||
|
|
||
| For what Quick Build is and how the whole loop fits together, read [`../README.md`](../README.md) | ||
| first. This file only covers what is inside this module. | ||
|
|
||
| ## The one rule that shapes everything here | ||
|
|
||
| **The domain layer is the Android-free floor.** Nothing under `domain/` imports `android.*` or | ||
| `androidx.*`, and nothing there takes a `Context`. Every Android capability the module needs is | ||
| declared as an interface - a *port* - and implemented in `:app`, wired in one Koin module | ||
| ([`QuickBuildModule.kt`](../../app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt)). | ||
|
|
||
| **The module as a whole is not Android-free, and is not meant to be.** It is a | ||
| `com.android.library` with AIDL, and six files under `data/` and `service/` import `android.*` | ||
| where the implementation is inherently framework-bound - `FileObserver` in | ||
| [`AndroidProjectWatcher`](src/main/java/org/appdevforall/cotg/quickbuild/data/AndroidProjectWatcher.kt), | ||
| `Service` and `Binder` in the deploy channel, `ComponentCallbacks2` for memory pressure. Those are | ||
| adapters at the edge, not logic. | ||
|
|
||
| Two things follow from an Android-free `domain/`, and both are the point: | ||
|
|
||
| - The routing rules, the session state machine and the deploy policy are **unit-testable on the | ||
| JVM** with no device and no Robolectric. That is most of `src/test/`. | ||
| - Swapping how CoGo installs an APK, watches files or reports metrics does not touch `domain/`. | ||
|
|
||
| Adding a dependency on an Android type inside `domain/` breaks both. Add a port instead. | ||
|
|
||
| ## Packages | ||
|
|
||
| Three layers, and dependencies flow **down** toward `domain/`. Nothing depends upward. Within | ||
| `domain/` and `service/` the sub-packages name the concern, and they line up: the `service/` | ||
| sub-package acts on what the `domain/` one of the same name decides. | ||
|
|
||
| ```mermaid | ||
| flowchart TB | ||
| subgraph service["service/ - runs the session, performs outside-world effects"] | ||
| direction LR | ||
| svcProvision["provision"] | ||
| svcSession["session"] | ||
| svcDeploy["deploy"] | ||
| svcTelemetry["telemetry"] | ||
| end | ||
|
|
||
| subgraph data["data/ - ports (file watch, device paths, daemon); implemented in :app"] | ||
| direction LR | ||
| dataPorts["data"] | ||
| end | ||
|
|
||
| subgraph domain["domain/ - pure logic and value types; the floor, depends on nothing above"] | ||
| direction LR | ||
| domWatch["watch"] | ||
| domClassify["classify"] | ||
| domSession["session"] | ||
| domReload["reload"] | ||
| domTelemetry["telemetry"] | ||
| domAnnotations["annotations"] | ||
| end | ||
|
|
||
| %% within service: components call each other freely | ||
| svcProvision -->|"hands off the built LiveSession"| svcSession | ||
| svcSession -->|"sends compiled payloads"| svcDeploy | ||
| svcDeploy -->|"relaunches / reconnects the proxy"| svcProvision | ||
|
|
||
| %% within domain: value types reference each other | ||
| domWatch -->|"a coalesced change batch"| domClassify | ||
| domClassify -->|"annotation-processor impact?"| domAnnotations | ||
| domReload -->|"which BuildRoute to run"| domClassify | ||
| domSession -->|"reads a BuildDiagnostic"| domReload | ||
|
|
||
| %% cross-layer: everything points DOWN into domain, never back up | ||
| svcSession ==>|"runs the SessionReducer"| domSession | ||
| svcSession ==>|"drives the reload orchestrator"| domReload | ||
| svcProvision ==>|"tracks generations, real-id install"| domReload | ||
| svcDeploy ==>|"acts on the DeployDecision"| domReload | ||
| svcTelemetry ==>|"stamps the E2eTimeline"| domTelemetry | ||
| dataPorts ==>|"emits WatchEvents, applies WatchFilter"| domWatch | ||
| dataPorts ==>|"reads / writes the GenerationStore"| domReload | ||
| ``` | ||
|
|
||
| Thin arrows are references **within** a layer, which are allowed: `service/` components call each | ||
| other, `domain/` value types reference each other. Thick arrows (`==>`) cross layers, and every one | ||
| points **down** into `domain/`. The two directions review must reject are **`domain/ -> service/`** | ||
| and **`domain/ -> data/`** - the pure-logic floor never reaches up to effects or ports. Edge labels | ||
| name what each dependency carries; the diagram shows the principal edges, and the per-package tables | ||
| below carry the full file-level detail. | ||
|
|
||
| `domain/` - pure logic and value types. `ChangedFiles`, the batch every layer speaks in, sits at | ||
| the root because it belongs to no single concern. | ||
|
|
||
| | Package | Holds | Start reading at | | ||
| | --- | --- | --- | | ||
| | [`domain/watch/`](src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/) | what counts as a change: the debounce, the filter, the batch reconciler | [`ChangeCoalescing`](src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/ChangeCoalescing.kt), [`WatchFilter`](src/main/java/org/appdevforall/cotg/quickbuild/domain/watch/WatchFilter.kt) | | ||
| | [`domain/classify/`](src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/) | which route a batch takes, and why a baseline stops being trustworthy | [`ChangeClassifier`](src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/ChangeClassifier.kt), [`BuildRoute`](src/main/java/org/appdevforall/cotg/quickbuild/domain/classify/BuildRoute.kt) | | ||
| | [`domain/session/`](src/main/java/org/appdevforall/cotg/quickbuild/domain/session/) | the state machine: states, events, effects, and what the user is told | [`SessionReducer`](src/main/java/org/appdevforall/cotg/quickbuild/domain/session/SessionReducer.kt), [`QuickBuildSessionState`](src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt) | | ||
| | [`domain/reload/`](src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/) | the live reload path: what to rebuild, hot swap versus restart, generations | [`LiveReloadOrchestrator`](src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt), [`DeployPolicy`](src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt) | | ||
| | [`domain/telemetry/`](src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/) | the measurement vocabulary: one timeline per edit, one sink to report it | [`E2eTimeline`](src/main/java/org/appdevforall/cotg/quickbuild/domain/telemetry/E2eTimeline.kt) | | ||
| | [`domain/annotations/`](src/main/java/org/appdevforall/cotg/quickbuild/domain/annotations/) | whether a change feeds an annotation processor, and what that costs | [`AnnotationImpact`](src/main/java/org/appdevforall/cotg/quickbuild/domain/annotations/AnnotationImpact.kt) | | ||
|
|
||
| | Package | Holds | Start reading at | | ||
| | --- | --- | --- | | ||
| | [`data/`](src/main/java/org/appdevforall/cotg/quickbuild/data/) | the ports themselves: file watching, device paths, the daemon process | `ProjectWatcher`, `QuickBuildPaths`, `DaemonProcessClient` | | ||
|
|
||
| `service/` - session lifecycle and the effects that touch the outside world. | ||
|
|
||
| | Package | Holds | Start reading at | | ||
| | --- | --- | --- | | ||
| | [`service/provision/`](src/main/java/org/appdevforall/cotg/quickbuild/service/provision/) | getting a proxy app built, installed and launched - including the clobber check | [`QuickBuildProvisioner`](src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildProvisioner.kt), [`ProxyAppInstaller`](src/main/java/org/appdevforall/cotg/quickbuild/service/provision/ProxyAppInstaller.kt) | | ||
| | [`service/deploy/`](src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/) | the AIDL channel to the proxy app and everything sent over it | [`PayloadDeployer`](src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt), [`DeployChannel`](src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/DeployChannel.kt) | | ||
| | [`service/session/`](src/main/java/org/appdevforall/cotg/quickbuild/service/session/) | the session itself: holds the reducer, runs the effects, drives one build at a time | [`QuickBuildSessionManager`](src/main/java/org/appdevforall/cotg/quickbuild/service/session/QuickBuildSessionManager.kt), [`LiveReloadExecutorImpl`](src/main/java/org/appdevforall/cotg/quickbuild/service/session/LiveReloadExecutorImpl.kt) | | ||
| | [`service/telemetry/`](src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/) | stamping a timeline as a build runs, and reporting it | [`E2eTimelineRecorder`](src/main/java/org/appdevforall/cotg/quickbuild/service/telemetry/E2eTimelineRecorder.kt) | | ||
|
|
||
| The split that matters: **`domain/` decides, `service/` acts.** A pure reducer computes the next | ||
| state and a list of effects; the session manager executes them. If you find yourself doing IO in | ||
| `domain/`, the logic wants to move to `service/` or the IO wants to become a port. | ||
|
|
||
| ## Two invariants that are easy to break | ||
|
|
||
| - **Everything stateful runs on one dispatcher, and it must be single-threaded.** Effects are | ||
| `launch`ed rather than run inline so a dispatch can never re-enter itself. | ||
| - **The reducer is total.** An unknown `(state, event)` pair keeps the current state and produces | ||
| no effects, so a late or duplicate event cannot corrupt a session. Adding a state or event | ||
| without extending the reducer silently gets you this fallback, not a compile error. | ||
|
|
||
| ## Where the rest is | ||
|
|
||
| | For | Read | | ||
| | --- | --- | | ||
| | What Quick Build is, the loop, the decisions | [`../README.md`](../README.md) | | ||
| | Which file implements which pipeline step | [`../docs/pipeline.md`](../docs/pipeline.md) | | ||
| | My edit did not show up - where to look | [`../docs/debugging.md`](../docs/debugging.md) | | ||
| | The wire formats this module speaks | [`../protocol/README.md`](../protocol/README.md) | | ||
|
|
||
| The other halves of the feature live in sibling modules: [`../daemon/`](../daemon/) compiles, | ||
| [`../runtime/`](../runtime/) runs inside the proxy app, and | ||
| [`../../gradle-plugin/`](../../gradle-plugin/) builds it. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import com.itsaky.androidide.build.config.BuildConfig | ||
|
|
||
| plugins { | ||
| alias(libs.plugins.android.library) | ||
| alias(libs.plugins.kotlin.android) | ||
| } | ||
|
|
||
| android { | ||
| namespace = "${BuildConfig.PACKAGE_NAME}.quickbuild" | ||
|
|
||
| buildFeatures.aidl = true | ||
|
|
||
| // AndroidProjectWatcherTest constructs the real watcher on the JVM: FileObserver's | ||
| // stubs then no-op (inotify inert) while the poll/coalesce pipeline runs for real. | ||
| testOptions.unitTests.isReturnDefaultValues = true | ||
|
|
||
| sourceSets { | ||
| named("main") { | ||
| // The deploy-channel AIDL lives in :quickbuild:runtime (the proxy app side). | ||
| // Compile the SAME .aidl here instead of depending on that module: its | ||
| // manifest declares the proxy app's appComponentFactory, which must never | ||
| // merge into CoGo's own APK. | ||
| aidl.srcDir("../runtime/src/main/aidl") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| tasks.withType<Test> { | ||
| useJUnitPlatform() | ||
| } | ||
|
|
||
| // DoD coverage gate: >=90% line+branch on non-UI (domain/data) code. | ||
| // The root build attaches the jacoco agent to every Test task; for Android modules | ||
| // the exec lands at build/outputs/unit_test_code_coverage/<variant>UnitTest/, NOT | ||
| // build/jacoco/ -- a JacocoReport pointed at build/jacoco/ silently SKIPs and the | ||
| // gate is never measured (see docs/process learnings, ADFA-3834). | ||
| tasks.register<JacocoReport>("jacocoTestReport") { | ||
| group = "verification" | ||
| description = "JaCoCo line+branch coverage for the v8Debug unit tests." | ||
| dependsOn("testV8DebugUnitTest") | ||
|
Comment on lines
+32
to
+40
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
rg -n -C 5 \
'jacocoTestReport|JacocoCoverageVerification|violationRules|minimum|testV8DebugUnitTest' \
--glob '*.gradle' --glob '*.gradle.kts' --glob '*.yml' --glob '*.yaml' .Repository: appdevforall/CodeOnTheGo Length of output: 7727 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- root build coverage configuration ---'
sed -n '440,545p' build.gradle.kts
printf '%s\n' '--- tracked workflow and coverage-related files ---'
git ls-files | rg '(^|/)(\.github/workflows/|.*(gradle|coverage|jacoco|sonar|quality).*)' | head -200
printf '%s\n' '--- all coverage enforcement references ---'
rg -n -i -C 4 \
'JacocoCoverageVerification|violationRules|minimum|counter\s*=|ratio\s*=|jacocoAggregateReport|jacocoTestReport|sonarqube|coverage' \
--glob '!**/build/**' --glob '!**/.gradle/**' --glob '!**/node_modules/**' \
. | head -500
printf '%s\n' '--- focused static verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
files = [
p for p in Path(".").rglob("*")
if p.is_file()
and (p.name.endswith((".gradle", ".gradle.kts", ".yml", ".yaml", ".properties", ".xml"))
or ".github/workflows" in p.as_posix())
and "build" not in p.parts
and ".gradle" not in p.parts
]
patterns = {
"verification_task": re.compile(r"\bJacocoCoverageVerification\b|\bviolationRules\b", re.I),
"threshold": re.compile(r"\bminimum\s*=\s*0?\.?9|\bminimum\s*[:=]\s*['\"]?90%|\b0\.90\b|\b90\s*%", re.I),
"report_task": re.compile(r"\bjacoco(?:AggregateReport|TestReport)\b", re.I),
"coverage_invocation": re.compile(r"\b(?:jacoco(?:AggregateReport|TestReport)|sonarqube)\b", re.I),
}
for label, pattern in patterns.items():
hits = []
for path in files:
try:
text = path.read_text(errors="replace")
except OSError:
continue
if pattern.search(text):
hits.append(str(path))
print(f"{label}: {len(hits)} file(s)")
for path in sorted(hits):
print(f" {path}")
PYRepository: appdevforall/CodeOnTheGo Length of output: 50380 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- analysis workflow ---'
sed -n '1,240p' .github/workflows/analyze.yml
printf '%s\n' '--- root Sonar and task wiring ---'
sed -n '360,470p' build.gradle.kts
printf '%s\n' '--- exact coverage-enforcement matches in tracked configuration ---'
git grep -n -i -E \
'JacocoCoverageVerification|violationRules|minimum[[:space:]]*[=:].*(0?\.?9|90%)|90%|0\.90|jacocoAggregateReport|jacocoTestReport|sonarqube' \
-- '*.gradle' '*.gradle.kts' '*.yml' '*.yaml' '*.properties' '*.xml' \
| grep -v -E '(^|/)(src|build)/|minimumSdk|minimum_sdk|AGP_VERSION_MINIMUM|minimum supported|minimum version' \
| head -200
printf '%s\n' '--- focused verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
paths = []
for path in Path(".").rglob("*"):
if not path.is_file():
continue
text_path = path.as_posix()
if ".github/workflows/" not in text_path and not path.name.endswith((".gradle", ".gradle.kts", ".yml", ".yaml", ".properties", ".xml")):
continue
if any(part in {"build", ".gradle", "node_modules"} for part in path.parts):
continue
paths.append(path)
checks = {
"JacocoCoverageVerification": re.compile(r"\bJacocoCoverageVerification\b", re.I),
"violationRules": re.compile(r"\bviolationRules\b", re.I),
"90-percent literal": re.compile(r"\b90\s*%", re.I),
"0.90 literal": re.compile(r"\b0\.90\b"),
"minimum threshold assignment": re.compile(r"\bminimum\s*=", re.I),
"coverage report tasks": re.compile(r"\bjacoco(?:AggregateReport|TestReport)\b", re.I),
}
for name, pattern in checks.items():
hits = []
for path in paths:
text = path.read_text(errors="replace")
for line_no, line in enumerate(text.splitlines(), 1):
if pattern.search(line):
hits.append(f"{path}:{line_no}:{line.strip()}")
print(f"\n{name}: {len(hits)}")
for hit in hits[:40]:
print(hit)
PYRepository: appdevforall/CodeOnTheGo Length of output: 11927 Add a JaCoCo coverage threshold The repository defines report tasks only. Neither Gradle nor CI enforces the stated 90% line and branch threshold. Add 🤖 Prompt for AI Agents |
||
|
|
||
| reports { | ||
| xml.required.set(true) | ||
| html.required.set(true) | ||
| } | ||
|
|
||
| // The javac output holds only generated code (AIDL stubs + BuildConfig), so the | ||
| // hand-written surface is exactly the Kotlin classes. | ||
| classDirectories.setFrom( | ||
| fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/v8Debug")) { | ||
| exclude("**/BuildConfig*") | ||
| }, | ||
| ) | ||
| sourceDirectories.setFrom(files("src/main/java")) | ||
| executionData.setFrom( | ||
| layout.buildDirectory.file( | ||
| "outputs/unit_test_code_coverage/v8DebugUnitTest/testV8DebugUnitTest.exec", | ||
| ), | ||
| ) | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation(projects.logger) | ||
| implementation(projects.eventbusEvents) | ||
| // Wire DTOs/constants shared with the daemon (single protocol definition). | ||
| implementation(projects.quickbuild.protocol) | ||
|
|
||
| implementation(libs.common.kotlin.coroutines.android) | ||
| implementation(libs.google.gson) | ||
|
|
||
| testImplementation(libs.tests.junit.jupiter) | ||
| testImplementation(libs.tests.google.truth) | ||
| testImplementation(libs.tests.kotlinx.coroutines) | ||
| // Shared offline-guard scanner (OfflineNetworkGuardTest). | ||
| testImplementation(testFixtures(projects.quickbuild.protocol)) | ||
| testRuntimeOnly(libs.tests.junit.platformLauncher) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
|
|
||
| <application> | ||
| <!-- Deploy channel host (LogSender bind pattern). Exported so the | ||
| generated proxy app (a different package) can bind; every inbound call is | ||
| uid-checked against the installed proxy app in QuickBuildHostService. --> | ||
| <service | ||
| android:name="org.appdevforall.cotg.quickbuild.service.deploy.QuickBuildHostService" | ||
| android:exported="true"> | ||
| <intent-filter> | ||
| <action android:name="com.itsaky.androidide.QUICK_BUILD_ACTION" /> | ||
| </intent-filter> | ||
| </service> | ||
| </application> | ||
|
|
||
| </manifest> |
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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Align the implementation-ownership contract.
Lines 14-15 state that every Android capability is implemented in
:app, but Lines 18-22 identifyAndroidProjectWatcherand deploy services inside:quickbuild:core. Lines 48-49 repeat the:appownership claim fordata/.Restrict the
:appstatement to app-owned port implementations, or document the core-owned Android adapters separately.🤖 Prompt for AI Agents