Subsystem · incremental

Incremental builds

Curie's rebuild gate is precise — false negatives (a missed edit) would be worse than a stray rebuild, and the design is biased accordingly. Three signals decide whether to invoke the compiler at all.

The mtime check

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 put an edit and a stamp in 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 BenchmarkList or CompilerHints) are deliberately ignored — they can have older mtimes than your sources and would otherwise force a recompile on every build.

The JDK fingerprint

Curie writes the output of javac -version to target/.javac-version after every successful build. On the next build that string is compared to the current javac -version; any difference forces a full recompile regardless of source mtimes. Upgrading the JDK between builds is an unambiguous "everything is stale" signal.

Stale-class detection

The mtime check decides whether to recompile. Stale-class detection decides what stays on disk. The story 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:

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"]

On the next build:

Annotation-processor outputs (under target/generated-sources) are exempted from pre-pruning so a no-change rebuild doesn't churn through every AP-generated class — the post-prune handles the "AP stopped producing this" case correctly.

Kotlin: SourceFile attribute + source-set stamp

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.

Pure deletions wouldn't bump any surviving mtime, so the source list is also stamped to target/.kt-sources. Any change between builds — additions, deletions, or renames — forces a recompile so the wipe gets a chance to run.

What you'll see

$ curie build # second run, no edits
  Compile         up to date
  Tests           up to date
  Package         up to date
  Done            target/hello-0.1.0.jar
$ curie build # after removing a companion class
  Compile         2 source file(s)  [source changed]
  Stale (Kotlin)  removed 1 orphan class file
  Tests           ✔ 2 tests successful
  Done            target/hello-0.1.0.jar

Forcing a clean rebuild

curie clean wipes the target/ directory entirely. The next build starts from a cold cache — same as a fresh clone.