Subsystem · incremental

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.

  1. Stale-class pre-prune — deleted sources have their old class files removed before the compiler runs (StaleClasses).
  2. 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).
  3. 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).
  4. JDK fingerprint — the stored javac -version string is compared to the current one. A JDK upgrade forces a full recompile regardless of source mtimes (JdkChanged).
  5. mtime check — any source file or Curie.toml with a modification time ≥ the oldest .class file triggers a recompile (SourceChanged or TomlChanged).

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:

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:

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.

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:

Curie records the canonical set of generated files to target/.curie-plugins/<name>.output-set after every successful plugin run. On the next build:

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

FileWritten afterDetects
target/.sourcessuccessful production compilesource file added or removed (all languages)
target/.test-sourcessuccessful test compiletest source file added or removed (all languages)
target/.jar-resourcessuccessful JAR packageresource file added or removed
target/.curie-plugins/<name>.output-setsuccessful plugin rungenerated output file added, removed, or manually deleted
target/.javac-versionsuccessful production compileJDK version change
target/.classes.tomlsuccessful javac runstale Java class files (pre- and post-compile)
target/.test-classes.tomlsuccessful test javac runstale Java test class files
target/.curie-plugins/<name>.stampsuccessful plugin runplugin input file changed (mtime-based), or plugin config / curie version / plugin version changed (hash-based)
target/.test-stampsuccessful test runwhether tests need to re-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 editing a source file
  Compile         3 source file(s)  [source changed]
  Tests           ✔ 4 tests successful
  Done            target/hello-0.1.0.jar
$ curie build # after adding a source with an old mtime (cp -p, rsync, untar)
  Compile         4 source file(s)  [source set changed]
  Tests           ✔ 4 tests successful
  Done            target/hello-0.1.0.jar
$ curie build # after removing a Kotlin companion class
  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
$ curie build # after upgrading the JDK
  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.