Incremental builds
Curie's rebuild gate is precise — a missed recompile (false negative) is always worse than a stray one (false positive), and the design is biased accordingly. Several independent checks work together; the compiler runs when any of them fires.
Decision flow
For each build step the checks run in this order. The first one that fires determines the rebuild reason; subsequent ones are not consulted.
- Stale-class pre-prune — deleted sources have their old class files removed before the compiler runs (
StaleClasses). - Source-set stamp — the saved set of source paths is compared to the current one. Any difference (addition or deletion) forces a recompile even when mtimes don't reflect it (
SourceSetChanged). - Output completeness — every class recorded in the source→class manifest is checked against disk; if one is missing (interrupted build, deleted output, failed partial build) the compiler reruns to regenerate it (
MissingClasses). - JDK fingerprint — the stored
javac -versionstring is compared to the current one. A JDK upgrade forces a full recompile regardless of source mtimes (JdkChanged). - mtime check — any source file or
Curie.tomlwith a modification time ≥ the oldest.classfile triggers a recompile (SourceChangedorTomlChanged).
If none of the above fires the step reports up to date and is skipped entirely. The exact reason is always printed in brackets so you can see which signal triggered the build.
Source-set stamp
mtime comparison cannot detect set-membership changes. A source added with a preserved old timestamp — via mv, cp -p, rsync -a, or unpacking a tarball — is not newer than any existing class file, so a pure-mtime check would report "up to date" and never compile it. A pure deletion is equally invisible because no surviving mtime changes.
Curie closes this gap by saving the canonical set of source paths to target/.sources after every successful compile. On the next build the current source set is compared against the saved one; any difference forces a recompile. This covers all three languages (Java, Kotlin, Groovy) with the same code path. The test compile uses a separate target/.test-sources stamp so production and test sets are tracked independently.
The stamp is missing on the very first build (after a fresh clone or curie clean). A missing stamp does not trigger the source-set check — the no-class-files path drives the initial compile instead, so a first build doesn't report a false "source set changed".
mtime check
When the source-set stamp reports no change, Curie falls back to mtime comparison. The compiler runs when any source file's mtime is greater than or equal to the oldest .class file's mtime in target/classes. The comparison is intentionally ≥, not > — on second-resolution filesystems (FAT, some NFS mounts, CI cache restores) a fast edit/test/edit loop can place an edit and a stamp within the same filesystem second, and silently masking the second edit would be a correctness bug.
Non-class files in target/classes (annotation-processor resources like META-INF/BenchmarkList or CompilerHints) are deliberately excluded from the baseline — they can have older mtimes than your sources and would otherwise force a recompile on every build.
JDK fingerprint
Curie records the output of javac -version in target/.javac-version after every successful build. The next build compares the stored string against the current one; any difference — even a patch-level JDK upgrade — forces a full recompile regardless of source mtimes.
Output completeness
The mtime check only treats a completely empty target/classes as needing a rebuild (NoClassFiles). A directory that still holds some class files but is missing others — an interrupted compile, a class deleted out from under the build, or a failed parallel build — would otherwise read as "up to date", and the gap stays invisible until packaging fails to find the main class. Curie closes it by checking every class recorded in the target/.classes.toml manifest (and target/.test-classes.toml for tests): if any recorded .class file is absent from disk, the compiler reruns to regenerate it (MissingClasses). The check only runs on the path that would otherwise report up-to-date, so a genuine no-op rebuild stays fast.
Stale-class detection
The checks above decide whether to recompile. Stale-class detection decides what stays on disk before and after the compiler runs. The mechanism differs by language because Java and Kotlin emit metadata differently.
Java: source→class manifest
Curie ships a javac wrapper that registers a TaskListener on JavacTask. For every TaskEvent.Kind.GENERATE event the listener records the originating source path and the binary name of the emitted class. After a successful build the mapping is written atomically to target/.classes.toml:
# Authoritative source → class-file mapping [sources] "/abs/path/src/com/foo/Bar.java" = ["com/foo/Bar.class", "com/foo/Bar$Inner.class"]
The manifest is consulted at two points during every build:
- Pre-compile prune — any source recorded in the previous manifest that no longer exists in the current source set has all of its old classes deleted before javac runs. Stops a deleted-but-still-on-disk class from being picked up via javac's classes-dir-on-classpath fall-through. Annotation-processor outputs (under
target/generated-sources) are exempted — the post-prune handles "AP stopped producing this". - Post-compile prune — for every source present in both old and new manifests, the class-file set difference (in old, not in new) is deleted. Catches the case where a still-present source produces fewer classes than before, for example when an inner or companion type is removed.
Kotlin: SourceFile attribute wipe
kotlinc offers no equivalent TaskListener hook, so Curie exploits a different invariant: every Kotlin source is passed to kotlinc on every recompile, so kotlinc re-emits every class the current source set still produces.
Before kotlinc runs, Curie parses each .class file's JVM SourceFile attribute and deletes any whose value ends with .kt. kotlinc then puts back exactly what's still live. Anything not re-emitted — a deleted source, a removed top-level declaration — is gone. This wipe also fires when a project transitions away from Kotlin entirely, ensuring no Kotlin-derived classes linger after the last .kt source is removed.
Resource-set tracking
The JAR packaging step has the same set-membership blind spot: resources are copied, never recompiled, so neither direction of membership change bumps any mtime.
- A resource added with an old mtime leaves the newest-mtime unchanged → JAR not rebuilt → resource missing from the JAR.
- A resource deleted changes no mtime → JAR not rebuilt → stale resource stays in the JAR.
Curie tracks this with a target/.jar-resources stamp that records the canonical set of every file under src/main/resources (or resources/). Any change between builds — addition or deletion — forces the JAR to be repackaged regardless of mtimes. Class files do not need this treatment: any class addition or removal flows through a recompile that writes fresh mtimes, which already triggers needs_repackage.
Plugin output-set tracking
Source-generator plugins (protobuf, openapi, …) write their generated files to target/generated-sources/<plugin-name>/. Two failure modes arise if the generated output is not tracked:
- Orphaned generated file — a
.protosource is deleted, the plugin reruns and no longer emitsFooService.java, but the old file is still on disk and gets compiled into the JAR. - Manually deleted generated file — a user deletes
target/generated-sources/protobuf/FooService.java. The plugin's inputs are unchanged so it skips; the.sourcesstamp detects the missing file and forces a recompile, but the compiler fails because the file is gone.
Curie records the canonical set of generated files to target/.curie-plugins/<name>.output-set after every successful plugin run. On the next build:
- If any file that was generated last time is now missing from disk, the plugin is forced to re-run to regenerate it (resolves the manual-deletion case).
- After the plugin runs, any file present in the previous output set but absent from the new one is deleted — an orphan wipe analogous to the Kotlin class wipe (resolves the deleted-proto case).
The same incremental::{load_source_set,write_source_set} primitives used for source and resource tracking are reused here, so all three tracking mechanisms share identical serialization and comparison logic.
Stamp files reference
| File | Written after | Detects |
|---|---|---|
target/.sources | successful production compile | source file added or removed (all languages) |
target/.test-sources | successful test compile | test source file added or removed (all languages) |
target/.jar-resources | successful JAR package | resource file added or removed |
target/.curie-plugins/<name>.output-set | successful plugin run | generated output file added, removed, or manually deleted |
target/.javac-version | successful production compile | JDK version change |
target/.classes.toml | successful javac run | stale Java class files (pre- and post-compile) |
target/.test-classes.toml | successful test javac run | stale Java test class files |
target/.curie-plugins/<name>.stamp | successful plugin run | plugin input file changed (mtime-based), or plugin config / curie version / plugin version changed (hash-based) |
target/.test-stamp | successful test run | whether tests need to re-run |
What you'll see
Compile up to date Tests up to date Package up to date Done target/hello-0.1.0.jar
Compile 3 source file(s) [source changed] Tests ✔ 4 tests successful Done target/hello-0.1.0.jar
Compile 4 source file(s) [source set changed] Tests ✔ 4 tests successful Done target/hello-0.1.0.jar
Compile 2 source file(s) [source set changed] Stale (Kotlin) removed 1 orphan class file Tests ✔ 2 tests successful Done target/hello-0.1.0.jar
Compile 3 source file(s) [JDK version changed] Tests ✔ 4 tests successful Done target/hello-0.1.0.jar
Forcing a clean rebuild
curie clean wipes the target/ directory entirely, removing all stamp files, class files, and the JAR. The next build starts from a cold cache — same as a fresh clone.