Skip to content

Releases: C9up/helix

v0.2.12

Choose a tag to compare

@github-actions github-actions released this 22 Sep 17:00

release: helix 0.2.12

Require Node 24, and build the crates for production

Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.

Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.

CI moves to Node 24 with them, since that is what the packages now ask
for.


Changes since v0.2.11.

v0.2.11

Choose a tag to compare

@github-actions github-actions released this 19 Sep 09:20

Declare the Node floor this package already has

24 of the cohort's packages declared engines.node >=22.0.0 and 8 did
not. Seven of the eight depend on @c9up/ream, which declares it, so
their real floor was already 22 -- just invisible to npm. helix is
standalone but its CI builds and tests on Node 22 only.

Undeclared, a Node 20 install succeeds without a word and the failure
arrives later as a runtime error far from its cause.

Read the artifact where cargo actually wrote it

The NAPI copy script looked under <package>/target unconditionally. Cargo
writes elsewhere whenever CARGO_TARGET_DIR is set — a shared cache, a CI mount,
a read-only external directory — so the build produced the library and then
failed to find it, or silently copied a stale one from a previous run.

CARGO_TARGET_DIR is honoured now, resolved against the package root when it
is relative, as cargo resolves it. All fourteen scripts had the same line; an
audit reported it in ream-mcp alone.

Verified end to end, not by reading: a real cargo build redirected to a
temporary directory, the artifact copied out of it, and the package suite green
on that binary.

Keep the dev-dependency alignment, drop the workspace: protocol

The internal ranges had been rewritten to workspace:^. That resolves inside
this monorepo and nowhere else: every package CI checks out its own repository
alone and runs pnpm install, where the protocol has no workspace to point at
and fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND before a single test runs. The
concrete ranges are back; the dev-dependency bumps that came with the same edit
are kept, and now match what the lockfile already resolved.


Changes since v0.2.10.

v0.2.10

Choose a tag to compare

@github-actions github-actions released this 06 Sep 15:20

Run cargo with --locked in CI

Without it, cargo rewrites Cargo.lock in place when it has drifted from the
manifests — so CI resolves dependencies fresh and tests a graph nobody
committed, then the release is built from it. The workspace lock had drifted
by 382 lines before the same flag caught it locally.

Every package here commits a Cargo.lock, so --locked is meaningful: it fails
loudly instead of silently updating. Verified against the current lock before
the flag went in.

The Node side is deliberately left alone: these repositories ship no
pnpm-lock.yaml, so --frozen-lockfile has nothing to freeze against, and
resolving from the registry is what a consumer gets anyway.

Take the NAPI platform table from the vendored copy

The map from Node's platform/arch to napi-rs's suffix was repeated here as
well. Adding a target means adding it everywhere, and a package that misses
the edit fails only on that platform.

The table, the path and the require come from scripts/vendor/nativeBinary.ts.
The policy does not: what this package does when the binary is absent, and the
error it raises, stay here — they carry this package's code and its build
instruction, which a shared helper has no business inventing.

Run clippy as a gate, not a note

cargo fmt --check was gating the formatting while nothing gated the
lints that catch a real defect — the one place the compiler stays silent
and clippy does not. Nine of the ten crates in this cohort had the same
hole; only one ran it.

No version change: this gates what is already there, and every crate
passes it today.

Measure the grace, not how long node took to start

a_failing_worker_that_will_not_exit_is_not_made_to_wait_out_the_grace
timed the whole call — spawn included — against a 1500ms ceiling. Spawning
node costs whatever the machine costs, and on a two-core CI runner that is
seconds, so the assertion could not tell "the pool waited out the 2s
grace" from "node was slow to come up". It failed on the second reading.

It now starts its clock when the pool announces the file error, which is
the moment the behaviour under test begins.

Find the tsx loader the way Node finds it

Five integration suites resolved the tsx ESM loader by scanning four
directories up for pnpm's virtual store. That path exists in this
workspace and nowhere else, so in helix's own repository — which is where
CI builds it — the scan returned nothing, the spawned worker had no loader
for a .ts fixture, and sixteen tests read that as the orchestrator
returning no results.

Node's resolver finds tsx wherever the package is installed. One helper,
in tests/__helpers__, instead of five copies of a lookup that only ever
worked in one layout.

Run the coverage suite after the build it spawns

helix's integration tests spawn bin/helix.js, which runs out of dist:
before pnpm build there is nothing to spawn, and sixteen of them read
that as the orchestrator returning no results.

Align the config with the one every sibling now carries

Lint helix the way its own repository will

biome's config lived only at the workspace root, including the line that
excludes the fixture whose parse error is the point of the test that loads
it. helix is built from its own repository, where that file does not
exist and biome falls back to defaults — so lint there checked a
different set of rules against a file it was never meant to read.

And coverage belongs on the whole vitest suite: tests/unit is a subset —
the selftests run through helix's own binary — so thresholds measured on
everything could not be met there.

Declare what CI has to install

Each package is its own repository: pnpm install there sees only this
file, so a dependency the workspace happened to hoist locally is simply
absent in CI. --coverage needs @vitest/coverage-v8 named here, and an
optional peer a test imports has to be a devDependency as well — optional
is exactly what keeps it from being installed.

Stop rejecting a callback that returns something

A union containing void does not get the rule that makes
forEach(x => list.push(x)) legal: () => void | Promise<void> accepts a
body returning nothing and rejects one returning a number. Every one of
these is awaited and its value dropped, or read back through a runtime
typeof === "function" guard, so the honest return type is unknown:
CleanupFn, SuiteHookCleanup, ReporterHandler, Plugin,
configureSuite, importer, the dataset body and test.each's.

HookFn keeps its union: the second arm is what gives a returned
CleanupFn its parameter types, and collapsing it to unknown loses that
inference — the runtime does consume that return, so it is not the same
finding.

Plus the gates the package already declared: lint now covers tests/.


Changes since v0.2.9.

v0.2.9

Choose a tag to compare

@github-actions github-actions released this 04 Sep 15:19

Typecheck the tests, and fix the four contracts that stopped them

include: ["src"] kept the whole test tree out of tsc. The reason was real:
tests/selftest augments TestHandle with a macro, and in one program that
declare module reached the source too — src/runtime/suite.ts, which builds a
handle before any macro is registered, was reported as missing a method nothing
had put there yet. So the tests were not typechecked at all, which is the worse
half of that trade.

tsconfig.test.json compiles them in their own program, and the augmentation
is optional now, which is what it always was: a macro exists on a handle once
Test.macro has registered it, and not before.

That surfaced four public contracts that were wrong, each of them only visible
from a test:

  • AnyFn = (...args: unknown[]) => unknown as a CONSTRAINT rejected every typed
    function, because parameters are compared contravariantly — vi.fn((a: number, b: number) => a + b) did not compile. AnyFunction, with never[], is the
    constraint that admits them all; AnyFn stays as the value a bare Spy
    stands for, and vi.fn() defaults to it so a spy with no implementation is
    still callable.
  • TestFn and TestCleanup returned void | Promise<void>, so
    () => log.push('x') was a type error for returning what push returns. The
    runtime awaits the value and drops it; the types say so now.
  • SuiteHook was a union of "returns nothing" and "returns its undo", so a hook
    that returned anything else was rejected for doing what the runtime already
    ignores. It returns unknown, and isSuiteHookCleanup is the guard that
    picks the undo out — the same shape hookList beside it already used.
  • The reporter tests built FileResult, TestResult, SerializedError and
    WorkerErrorMessage fixtures that the runtime never produces: missing name,
    missing suites/totals, and a type field the message has no such thing
    as. They were asserting about shapes nothing sends.

The rest is noUncheckedIndexedAccess on test fixtures, read through a
defined() helper rather than a ! — a fixture that stops producing the
element now fails on the line that reads it.

pnpm typecheck:tests runs in CI beside the build typecheck.

Turn on noUncheckedIndexedAccess

It was not missing here — it was explicitly false, in sixteen of the
seventeen tsconfigs. eon alone had it on, which is why nobody had seen
what it finds.

It stays a named deviation from upstream: @adonisjs/tsconfig sets
strictNullChecks and noImplicitAny but not this one. We keep it because
turning it on is what caught an as asserting a possibly-absent regex
group was a known value — the exact shape the flag exists to find. Doing
better than upstream is kept and written down, not reverted to parity.

Every site is restated rather than silenced: no !, no cast, no ?? 0
standing in for a branch that cannot happen. A reversed copy read by
value where an index walked a callback list backwards, the winner of a
scan kept as the value it found rather than its position, destructuring
where a length check was doing the proving, and an explicit break where a
loop condition already bounds the read.

Let a run say how long a finished worker may take to exit

exitGraceMs was a constant, and the wait it sizes is only "milliseconds"
until it is not: a large V8 coverage dump on a loaded CI runner takes
longer, and the pool then killed a worker that was about to finish
writing. The only way out was to stop asking for coverage. It is a
PoolConfig / RunConfig option now, still defaulting to 2 000.

The three Math.floor(cfg.x as number) reads that surrounded it are one
helper: narrowing an optional property does not survive to the next read
of it, which is why each ended in a cast asserting back what the check
above had just established.

Treat an empty expect() label as no label

expect(total, row.label) with an empty label is the ordinary way to reach
this, and the check was label === undefined, so the empty string went
through and opened the failure on a bare ": expected 3 to be 4".

The type is now checked alongside the value. expect() is public API a
JavaScript caller reaches too, and a symbol passed there throws inside the
template rather than labelling anything, turning a failed assertion into a
TypeError that says nothing about what actually broke.

Stop paying the exit grace on a file that never produced a result

Two paths out of the native pool were still wrong once the grace existed.

A worker can report a failure and then refuse to exit — the file blew up and
left something running. The grace is there to protect a result; there is none
on that path, so waiting it out only delayed the error the run already had,
under a message announcing the file had "finished". The error is now the only
diagnosis, and the worker is killed at once.

The stdout drain task was never stopped. It reads to EOF, and EOF only comes
when every holder of the write end is gone, so a worker we had to kill that
had itself spawned something leaves the task waiting on a file already
reported. It is now held by a guard that aborts it on every path out of
run_one_file, including the timeout that returns early.

Release 0.2.9

Let expect() take the message argument it documents compatibility with

expect(value, message) is Vitest's signature, and helix bills itself as
Vitest-compatible; Playwright has it too. Only the value was accepted, so
a failure inside a loop or a table-driven case said what broke but never
which row.

The label prefixes the matcher's own text rather than replacing it —
trading "expected 3 to be 4" for "the total after tax" would keep the
intent and lose the diagnosis. It threads through .not, .resolves and
.rejects, and through the promise-shape errors those raise.

Stop the native pool waiting forever on a worker that will not exit

A test that leaves a server, a timer or a connection running keeps its
worker alive after the result is framed. run_one_file then sat on an
unbounded child.wait(), so the run hung after its last file with
nothing printed at all — a passing suite that reads as a crash, and in
CI a whole budget burnt with no diagnostic to act on.

The TS pool has guarded this since 0.2.6, but the cutover to the native
orchestrator made that pool the exception path: coverage, --list-pinned
and a pluggable reporter instance reach it, an ordinary run does not. So
the guard was there and almost nobody ran it. Reproduced on a bare
helix test — 120s and still going, while the same file under
--coverage finished in 3s with the reason on stderr.

The wait is now bounded by a 2s exit grace, mirroring EXIT_GRACE_MS, and
the message is the TS pool's verbatim so the two paths cannot be told
apart. The result is kept: the test passed, only its cleanup did not.

Move the NAPI bindings to napi 3

The Rust needed no change; the toolchain did. napi-derive 3 writes one type-def
file per crate into NAPI_TYPE_DEF_TMP_FOLDER and panics outright when it sees
the old single-file TYPE_DEF_TMP_PATH — that variable is how it detects an
out-of-date toolchain, so the failure reads as "upgrade @napi-rs/cli" even
though the generator here is our own.

It also emits a function as a bare function name(...) where 2 emitted the
signature alone, so concatenating the name onto it produced
function xfunction x(...). The generator handles all three shapes now.

napi-build stays at 2 — there is no 3 on crates.io.

Verified by what the migration could break rather than by it compiling: the
generated src/native/generated.ts comes out byte-identical to the napi 2 one,
and the native binary is rebuilt and exercised by the JS suite.

Update the Rust dependencies within their ranges

Everything the existing semver ranges allow, so no manifest changes and no API
surface moves. fmt, strict clippy, tests and advisories all pass.

Clear strict clippy and the advisory list

Nothing in the root gate ran clippy or cargo-deny against a package workspace —
cargo test --all covered the ROOT workspace only, which is a handful of
crates. Five packages were failing strict clippy at the same time and nothing
said so.

Isolate a reporter that fails asynchronously

EventHandler returns void, and TypeScript ACCEPTS an async function for a
void return: a reporter written as async (payload) => … type-checks and its
rejection walked straight past the try/catch, which only ever saw a synchronous
throw. In a test runner that means one reporter awaiting something that fails
takes down the run it is reporting on.

The handler is still called synchronously — reporters observe in order and the
runtime relies on that — only a returned promise is followed. Fourteen call
sites, one place to fix.


Changes since v0.2.8.

v0.2.8

Choose a tag to compare

@github-actions github-actions released this 01 Sep 15:36

Release 0.2.8

0.2.8 was never published — npm stops at the version before it — so the work
that followed folds into it rather than incrementing past it. The tags for
0.2.9 existed with no npm release and no GitHub release behind them.

Turn on noUnusedLocals/noUnusedParameters, and say what is checked

tsconfig named tests in include AND in exclude, and exclude wins, so
nothing under tests/ was ever typechecked while the config claimed otherwise.
include now says src.

The exclusion had a real reason worth writing down: tests/selftest declares
module augmentations — Test.macro("asSlow", …) and its declare module —
and in one program those leak into src, so makeHandle is reported as missing a
member it is never meant to implement. Checking the tests here needs its own
tsconfig, plus the 28 errors currently behind the exclusion.

Report a reporter that fails to attach instead of ending the worker

registerReporter is chainable and synchronous, as upstream, so the handler's
promise is nobody's to await. Unhandled, its rejection ended the worker and the
file it was reporting on showed up as a crash rather than as results plus a
broken reporter.

Release 0.2.9

Move tsx to devDependencies, where it is used

It is a TypeScript loader, and src/ never touches it — only one integration
test does, to hand a child process a loader. Declared as a production
dependency it was installed by every project that installs the runner, for
work none of them run.

Release 0.2.8

Read the framework rc file when there is no helix config

ream test read reamrc and helix test did not, so the same project gave two
different bootstraps depending on which command ran — and one of the two was
always wrong. Reported cost: a bootstrap that starts the application, picked up
by convention, opening a server and a connection pool inside a unit suite that
touches neither.

The rc files come after helix.config.*, which still wins. This does not make
helix framework-aware: it reads the same tests block it already knew how to
unwrap, from a file it locates by name, and imports nothing else from it.

That supersedes the warning added earlier today, which now said something
untrue — that helix does not read the file — so it is gone with its tests.

toThrow's constructor overload was typed new (...args: unknown[]) => Error.
Constructor parameters are contravariant, so a class declaring
constructor(message: string) was rejected at compile time while the matcher
worked perfectly at runtime: a bare Error passed and everything that typed its
arguments did not, which cost one project nine hand-written helpers. never[]
accepts any parameter list, which is what a constructor-shaped parameter means.


Changes since v0.2.7.

v0.2.7

Choose a tag to compare

@github-actions github-actions released this 31 Aug 18:49

Show a POSIX path in the convention notice, and make the run readable

The notice printed path.relative, which is tests\bootstrap.ts on Windows —
the one platform where the assertion checking it ran, and where it failed. The
path is read by a person and pasted back into a command, so it is shown with
forward slashes everywhere, and the test now refuses a backslash outright.

Same shape as the failure in rover this morning, and the same second half: the
job ran vitest with the JSON reporter alone, so the log held an exit code and
not one word about which test failed. The default reporter runs alongside it,
with the JSON one still feeding the gate.

Release 0.2.7

Say when a bootstrap was picked up by convention

helix probes helix.config.* and nothing else, which is right — it is
framework-agnostic and a framework's rc file is not its business. But a Ream
project keeps its answer in reamrc.ts, so the same project gives two different
bootstraps depending on which command runs the tests, and neither said so.

The reported shape: a tests/bootstrap.ts that starts the application, picked up
silently, so a unit suite that needed no server opened one with a connection
pool behind it. The workaround was to not create the file — which means the
first person to write a legitimate one walks into it.

The convention still applies; it just announces itself, and only where there is
an rc file that could disagree, so a plain helix project gets no lecture. helix
still never reads that file — it checks whether one exists, by name.

Format the Rust crates, and gate it so they stay formatted

Twelve of the thirteen crates had drifted — 949 differences in all, atlas
alone 345, and build.rs files that had never been through the formatter.
None of their workflows checked, so nothing ever said so; the drift only
surfaced when it took a publish job down.

cargo fmt applied throughout, and a cargo fmt --check step added to each
workflow so this cannot happen again. Formatting only: the Rust tests pass
unchanged in every crate.

One spot in atlas needed a real edit rather than the formatter: cargo fmt
rewrote a return Err(format!(…)) arm back to the inline form on every run
while --check kept asking for the block form, so the file could never
converge. The message is bound to a name, which fits the line budget and
settles it.

Ignore a relocated cargo target

target/ matches a directory only. When the build output is moved elsewhere
and a symlink named target is left in its place, that pattern does not catch
it — it shows up untracked, and a stray git add -A commits a path that only
resolves on one machine.


Changes since v0.2.6.

v0.2.6

Choose a tag to compare

@github-actions github-actions released this 31 Aug 08:39

Exercise the assertion surface, the matchers and the runner's corners

An assertion that cannot fail turns a consumer's green suite into a claim
nobody checked, and most of the assert surface had never been called. Every
method is now asked a question it must accept and one it must refuse, the
matchers are checked for a non-empty failure message, and equals() is
measured on the types a plain key-walk gets wrong.


Changes since v0.2.5.

v0.2.5

Choose a tag to compare

@github-actions github-actions released this 28 Aug 17:00

Narrow instead of asserting, in the runtime's type guards

Eight checks were spelled typeof (x as { then?: unknown }).then === "function" — a cast whose only job was to reach a property, on a value
the surrounding condition had often only proven truthy. in narrows, so
nothing has to be asserted, and the cast was hiding that the guard did
not actually establish the value was an object.

The thenable check appeared four times and is now written once. So is
the length check, which was two casts on the same line: one to test the
property, one to read it.

Both accept a function as well as an object, because a thenable function
is legal and the checks they replace accepted one. hasLength also
accepts a string, which is neither — toHaveLength("abc", 3) caught
that when the first version narrowed too far.


Changes since v0.2.4.

v0.2.4

Choose a tag to compare

@github-actions github-actions released this 28 Aug 15:16

Say something in the comments the rename left saying nothing

Renaming the upstream runner to helix turned every provenance note in
helix's own source into a tautology. 136 parentheticals read
"(helix --bail)" or "(helix parity)" inside helix, and the plugin API
documented itself as "helix hands its plugins X; helix passes the same
four, so a helix plugin's body ports over unchanged".

Where the parenthetical named a field, the name stays and the
attribution goes — (helix \config.filters`)is(`config.filters`)`.
Where the sentence was a comparison, it is rewritten to say what is
actually true here: the plugin API carries four members plus two helix
adds, and each is described by what it does rather than by whose it
resembles.

Nothing about the plugin system itself changed. Plugin, PluginApi
and PluginContext are exported as before, configure({ plugins })
runs them, and the 23 tests across plugin-api and context-plugins
still pass.

Drop two README sections describing things that no longer exist

"Official plugins" documented a config option wired to a loader shim in
src/japa/, and "parity proofs (golden tests)" documented
tests/golden/ running every spec against a second runner, with a table
of what each golden pair pinned down. Neither directory exists, the
option was removed from the config type, and the CLI wiring behind it
went with this change. A reader following either section configures
something that does nothing, or looks for tests that are not there.

The rest is naming: the upstream this runner mirrors was named in
twenty-one comparative clauses of its own README, where "like helix"
would say nothing. The clauses are dropped rather than substituted.

Name the test runner helix in the Rust doc comments too

The #[napi] doc comments feed src/native/generated.ts, so the sweep
had to reach the crate as well. Regenerating produces no diff, which is
the check: the declarations and the Rust say the same thing.

Release the runner-naming sweep

0.2.3 reached the registry while this was in flight, so the shim removal
and the comment sweep ship under the next one.

Drop the last of the runner shim, and name the runner helix throughout

bin/helix.js still carried live wiring for a japaPlugins option: it
appended a loader pointing at src/japa/japa-alias.mjs. That directory
no longer exists, HelixConfig never declared the key, and
runGlobalHooks takes one argument and ignored the object the CLI
passed it. Three ways of being dead at once, so the branch could never
have fired — removed rather than left to look like a feature.

The rest is naming. The upstream this package mirrors was named
throughout the comments; here that runner is helix, so that is what the
comments say. Two sentences that became tautologies under the rename are
rewritten rather than left ("flat, the way helix's is").


Changes since v0.2.3.

v0.2.3

Choose a tag to compare

@github-actions github-actions released this 28 Aug 14:13

Say when a run cannot drain, and stop ignoring a nested config block

Two reports, both about the runner going quiet where it should speak.

bin/helix.js already told you when a finished run could not drain, but
ream test does not go through it — it goes through
@c9up/helix-plugin-ream, which just returned. So the summary printed,
every test green, and then nothing: no prompt, no output, and under a CI
timeout an exit 124 scored as a failure. The guard is extracted here as
armDrainGuard so the plugin runs the same code. bin/helix.js keeps
its own copy on purpose — it calls the guard from the error path too,
where the build may never have been imported, and an exit handler that
can fail to load is worse than a duplicated one.

Separately, a config whose settings sit under a tests block — the
shape of reamrc.ts, and of adonisrc.ts before it — was read as one
unknown key and dropped. Nothing said so: the declared bootstrap was
ignored and the conventional tests/bootstrap.ts ran in its place. In
the reported case that bootstrap starts the application, so 25 tests
that needed no server opened one and then hung — the first bug,
triggered by a file that had nothing to do with them. The nested form is
now read, with the flat one winning where both carry a key so a working
config keeps working.

Release the work that landed after the last published version

The registry now carries the version this package.json was still on, so
everything committed since ships under the next one rather than
retroactively changing what a published version means.

Build the cross-platform matrix only when the version moves

The five-runner matrix exists to produce the prebuilt binaries a release
ships. It ran on every push to main, rebuilding artefacts nobody
downloads — five runners, every time, for a comment fix.

A version-gate job now compares the package version at HEAD^ with
the one at HEAD and the matrix runs only when they differ. Anything
that is not a push passes the gate unconditionally, so workflow_dispatch
— how a release is actually cut — is unaffected, and so is publish,
which still waits on the full matrix. A missing HEAD^ reads as a bump:
erring towards building is the safe direction.

The test signal deliberately does NOT move with it. quality and the
cargo/integration jobs were already independent of the matrix, but
vitest ran INSIDE it, so gating the matrix alone would have quietly
taken the TypeScript suite off every ordinary push. A ts-tests job now
runs it on ubuntu, building its own napi binary rather than waiting on a
gated artefact. On a push without a bump that leaves typecheck, lint,
cargo and vitest — one runner instead of five.

Derive the native TypeScript surface from the Rust

The hand-written interface describing the .node binary was a second
description of the same thing, and nothing on this side noticed when the
first one changed: a pub fn could gain a parameter, stop being async
or change its return with the declaration still claiming otherwise.

napi-derive can emit the declarations itself. Its type-def feature
writes one JSON line per #[napi] item while cargo compiles;
scripts/build-napi-types.mjs collects them and
scripts/generate-napi-types.mjs turns them into
src/native/generated.ts. build:napi regenerates it, and the
TypeScript side consumes it instead of restating it.

Three things the generation had to handle:

  • type-def APPENDS to its output file, so a parallel cargo build
    interleaves the writes and definitions go missing, silently, leaving
    the generated file short. Crates are built one at a time, and a crate
    that emits nothing fails the script rather than producing a partial
    surface.
  • A Rust doc example holding a cron expression (0 */5 * * *) closes
    the generated comment early. Every */ is escaped except the one
    that legitimately closes the block — escaping that one breaks the
    file just as thoroughly.
  • The driver is Node rather than bash, because build:napi also runs
    on the Windows prebuild runner: a Git Bash mktemp path is not
    something the native proc-macro can write to, and the type-def file
    would come back empty with nothing to explain why.

The generated file is a .ts holding only ambient declarations rather
than a .d.ts, so tsc carries it into dist/native/ and the
reference from the emitted declarations still resolves for consumers.


Changes since v0.2.2.