Repository navigation
Version 0.4.0
[0.4.0] - 2026-09-09
Fixed
--fail-on-changenow actually exits3when invoked vianextflow plugin. The documented CLI exit-code contract (1 runtime error, 2 usage
error, 3--fail-on-changeon a difference) is the whole reason
--fail-on-changeexists — a CI job keys off it. But thenextflow plugin <id>:<verb>launcher (CmdPlugin, on Nextflow'svoidLauncher.run()
path) invokes the plugin'sexec()and then discards its returned int, so
every invocation exited0no matter what — verified on 26.04.1, where two
identical:falseruns still exited0.DiffPlugin.exec()now forces the
process exit code itself viaSystem.exit(code)for any non-zero result
(flushing stdout/stderr first, since that path skips the trait's session
teardown), so 1/2/3 reach the shell regardless of whether the launcher
propagates the value. The exit-code mapping was split into a package-visible
dispatch()so it stays unit-testable without the terminalSystem.exit(),
ande2e/smoke.shnow asserts the real3end to end instead of noting the
bug as a known limitation.
Added
-q/--quiet/--summary-onlyprints just the summary block and skips
the report body. Every invocation rendered and wrote (or streamed) the full
HTML/JSON/Markdown report, even when a CI gate only cares about the one-line
N changed, …signal — so the report body was pure noise in the job log (and
a wasted file write). The new flag suppresses rendering entirely (contentis
never computed) and prints the summary to stdout regardless of--output; the
summary'sReport:line reads(suppressed by --summary-only). Exit-code
behaviour is unchanged, so--summary-only --fail-on-changeis now the leanest
CI gate. Accepts the launcher-injected--flag true, inline=, and explicit
=falseforms like the other boolean flags.- Apache-2.0 license headers on every source file. The repository ships an
Apache-2.0LICENSE, but none of the 40 Groovy sources (src/main+
src/test) carried the per-file SPDX/copyright header that the license text
itself recommends and that a registry-published plugin wants for clean
provenance. The canonical Nextflow header (Copyright 2026, Seqera Labs) is
now prepended to each file above itspackagedeclaration. - End-to-end smoke test that drives the real
nextflow plugin nf-diff:diff
launcher. The Spock suite exercises every component in isolation, but
nothing resolved the plugin by its bare id and ran the actual CLI verb
against a genuine.nextflow/history+ LevelDB cache — the exact path where
the internal Nextflow APIs the plugin reuses (HistoryFile,CacheDB/
DefaultCacheStore,ConfigBuilder) can drift between Nextflow lines. The
CI matrix already pinnedNXF_VERto both25.04.0and26.04.0for this
reason, yet only compiled and unit-tested against them. A newe2e/smoke.sh
(wired in asmake smoke) now runs a trivial pipeline twice to produce two
real runs, then invokes the plugin verb and asserts exit codes and report
content (JSONschemaVersion/summary, a standalone HTML document, and the
documented exit code3for--fail-on-changewhen the runs differ). CI
installs the matrix Nextflow version viaget.nextflow.io(which honours
NXF_VER) and runs it on every matrix leg, so drift is caught at the launcher
layer where it actually surfaces. --format=jsonoutput now carries a top-levelschemaVersionfield.
The JSON model emittedgeneratedAt,identical,runA/runB,summary
and the layers but no version marker, so a downstreamjqassertion in a CI
pipeline, PR bot or dashboard — the very consumers the README markets JSON to
— had no way to detect a breaking shape change.schemaVersion: "1"is now
emitted as the first key of the document, establishing an explicit contract
that can be bumped when the shape changes incompatibly.
Changed
- The opt-in work-dir layers now share one executor instead of one pool
each.--diff-outputs,--diff-logsand--diff-dageach fan their
independent, read-only work-dir I/O across a bounded thread pool — but
mapMatchedInParallel/runInParallelcreated (andshutdownNow()-tore-down)
a fresh pool per layer, so--diff-allpaid for three create/destroy cycles
in a single comparison.RunComparator.comparenow builds one daemon-threaded
pool (sized toavailableProcessors()) up front — only when at least one of
the three layers is enabled — threads it through the threecompute*methods,
and shuts it down once in afinally. Results still return inresult.tasks
order, and the single-pair sequential fast path (which never touches the pool)
is unchanged, so reports are byte-for-byte identical. - HTML report restyled to match the
nf-docsdesign language. The report
previously leaned on a dark-by-default, gradient-heavy look (radial body/hero
"glows", gradient-filled cards and chips, a gradient logo, 16px radii). The
internalnf-docs-generated pages use a flat, light-by-default documentation
aesthetic — a slate palette (slate-50/100/200surfaces in light,
slate-900/800/700in dark) with the shared Seqera primary green#0DC09Das
the sole brand accent, bordered white cards with only a hairline0 1px 2px
shadow, and tighter geometry. The report's inline CSS now adopts those exact
tokens: both theme palettes were re-mapped to slate +#0DC09D, the radial
gradients and gradient fills were removed in favour of flat bordered surfaces,
card/table radii dropped from 16px to 10px, the body gained
line-height:1.625and aui-sans-serif, system-ui, …stack, the page title
is now primary-green, and table rows gained a:hoverhighlight. Accent
colours (pills, row highlights, verdict, code-diff, warn-note, source badges)
were re-based onto the green/blue/green-500/red-500/yellow-500system,
with darkera16207/dc2626/16a34avariants in light mode for contrast on
white. Only the inlineCSSconstant changed — the report markup, JavaScript,
data-themetoggle mechanism and every existing class name are untouched, and
the report remains self-contained (no web fonts or external assets). - Derived summary counts now live on
DiffResult, not inline in each
renderer. The "software changed", "regressions", "outputs changed" and
"logs changed" stats were each recomputed inline in the HTML, JSON and
Markdown renderers (diff.software.count { it.changed },
diff.regressions.count { it.regression }, etc.).DiffResultalready
exposed peer accessors for the same class of derived count
(failedCountA(),newFailureCount(),dagEdgesAdded(),
overProvisionedA()) — these four just weren't pulled in, so if the
changed/regression/hasChangespredicate ever shifted the three
renderers could silently disagree. NewsoftwareChangedCount(),
regressionCount(),outputsChangedCount()andlogsChangedCount()
accessors sit next to the existing ones as the single source of truth, and
all three renderers now call them. RunComparatornow takes a singleCompareOptionsvalue object instead of
a twelve-argument positional constructor. The old signature interleaved four
booleans (showObvious,diffOutputs,diffLogs,diffDag) and three
numeric limits (outputsMaxBytes,logsMaxLines,outputsMaxLines), so only
argument order told them apart and a transposition compiled silently under
@CompileStatic— the classic long-parameter-list hazard, made worse because
adding a layer meant threading a new positional through every call site. The
newCompareOptionsnames each knob and defaults each to the constructor's old
default, soDiffCommandsets them by name andnew RunComparator()still
reproduces the no-argument behaviour. Future layers become one added field, not
a signature change at every call site.
Added
- Direct unit tests for the
ArgCursorparsing primitive. A new
ArgCursorTestpins the cursor's contract in isolation fromparse(): inline
--key=valuesplitting, the token-consumption semantics that the old manual
if( inlineVal == null ) i++bookkeeping encoded (requireValue/boolValue
consume the space-separated value; a bare or non-boolean-followed flag does
not), the shared numeric parse/floor helpers (intValue/longValue/
doubleValue), andpeek/consumePeekediteration.ArgCursorwas widened
fromprivateto package-visible for this. --diff-allconvenience flag enables the three opt-in work-dir layers
(--diff-outputs,--diff-logs,--diff-dag) at once. They share the same
precondition — the tasks' work directories must still exist — and are commonly
wanted together. The flag only enables, never forces off, so a later explicit
--diff-<layer>=falsestill opts an individual layer back out.- Direct unit tests for
RunLoader's pure helpers.RunLoaderis the
riskiest component (it reuses Nextflow's internalHistoryFile/CacheDB) yet
had no test. A newRunLoaderTestpins the pieces that are pure and
standalone — transient-lock detection (isLockError), the trace-store value
coercions (asLong/asString), and session-id shortening (shortId) — which
required only making those static helpers package-visible. - Fixture-backed test for
RunLoader.lastPair.lastPairreads only
.nextflow/history(no LevelDB cache), soRunLoaderTestnow writes a
hand-built history fixture and exercises the real selection logic end to end:
the default-B single-offset form, the explicitA:Bpair form (and its
equivalence toA:0), and the out-of-range guards (negative B,A <= B, and
too few runs in history).
Changed
- Argument parsing is centralised behind an
ArgCursor.DiffCommand.parse
previously hand-rolled, for every option, the inline--key=valuesplit, the
space-separated--key valuefallback with its manualif( inlineVal == null ) i++index bookkeeping, and — for each numeric flag — a duplicated
parse/NumberFormatException/range-check block. A privateArgCursornow owns
position tracking and exposesrequireValue/boolValue/intValue/longValue/
doubleValuehelpers, so each option case collapses to a single assignment and
the off-by-one hazard in the repeatedi++dance is gone. Behaviour is
unchanged (all forms — inline, space-separated, and launcher-injected
--flag true/--last N/--last A:B— parse exactly as before); only the
numeric-validation messages are now generated from a shared template. --lastgained an explicitA:Bpair form and clearer docs. A single
--last=Nstill compares the run N positions before the latest against the
latest — but that silently skips the runs in between, which was easy to
misread as "the N most recent runs". You can now name an exact pair by their
offsets back from the latest (0= latest, requiringA > B >= 0): e.g.
--last=2:1compares the run two back against the run one back. The bare
--lastand single-integer forms are unchanged (--last=N≡--last=N:0).
The-htext now spells out the skip behavior, and the info line logged at
selection names each side's offset explicitly.- Per-task
rawtrace map is no longer deep-copied.RunLoader.toTaskInfo
built eachTaskInfowithraw = new LinkedHashMap<>(store), duplicating the
entire trace store on top of the already-copieddisplaymap — roughly
doubling per-task memory on large runs.CacheDB.eachRecorddeserializes a
freshTraceRecord(and store map) per iteration and the record is discarded
immediately, so nothing can mutate or reuse it; the defensive copy bought no
isolation.TaskInfonow references the store map directly. - Cache-lock detection is no longer coupled to a single literal message.
RunLoader.isLockError— which decides whether a failed cache open is a
transient lock contention worth retrying — previously matched exactly one
string (Unable to acquire lock). A phrasing change in LevelDB or Nextflow
would silently disable the retry loop. It now matches a small, case-insensitive
allow-list of known lock signatures (still lock-specific, so unrelated failures
are never retried pointlessly) while continuing to walk the cause chain. - The opt-in work-dir layers now compare tasks in parallel.
--diff-outputs
and--diff-logspreviously walked matched task pairs one at a time, so a
pipeline with many tasks and large outputs paid for single-threaded
SHA-256/log I/O. Because each matched pair is independent, read-only work-dir
I/O,RunComparatornow fans the comparisons out across a bounded pool (sized
to the smaller of the work size and the available processors) while still
returning results inresult.tasksorder, so the report is byte-for-byte
unchanged.--diff-daglikewise reconstructs both runs' graphs concurrently.
Failures propagate unchanged (the underlying exception is unwrapped from the
executor), and pool threads are daemon so a stuck read never keeps the JVM
alive. - Command-line tokenisation now has a single source of truth.
ConfigLoader
previously carried its own copy of the quote-aware command tokenizer that
"mirrored"CommandParams'; the two could silently drift.CommandParams.tokenize
is now shared andConfigLoaderdelegates to it, so-c/-configextraction
and flag parsing always split commands identically.
Full Changelog: v0.3.0...v0.4.0