Skip to content

v0.4.6: the derived tool dts compiles, and a test says so

Choose a tag to compare

@matAtWork matAtWork released this 19 Aug 16:06
· 139 commits to main since this release
07940ee

What's Changed

  • #44 — 0.4.6: the derived tool dts compiles, and a test says so by @matAtWork
  • A patch release. One fix, one gate; no API changes and nothing to migrate.

Full Changelog: v0.4.5...v0.4.6


0.4.6 — the generated types stop lying quietly

Reported downstream against v0.4.5, and it reproduced with this repo's own matbot.yaml: the derived tool
.d.ts did not compile.

buildMatbotToolsDts bundles every referenced workspace type into one flat scope, but it keyed that bundle
by declaration identity — file plus position — rather than by name. Two plugins each declaring a
file-local type of the same name therefore emitted both, side by side, and the artefact failed with
TS2300: Duplicate identifier.

The plugin sources were correct. plugins/background and plugins/edit-session each declare a local
SkipKind, which is legal TypeScript and here a deliberate decision that background records in a comment:
"two plugins agreeing on three words is not yet an abstraction worth a package." Renaming either one would
not have been a fix, only a deferral of the next collision. The generator was what could not represent them.

The damage was not the failed compile — it was that nothing compiled it. Both references resolved to an
error type, and an error type is assignable to anything. function-tools and the skills compiler are
graded against this dts, so generated code branching on kind was checked against an error type and the
diagnostic dropped. Both plugins' comments tell callers to "branch on kind, never on the prose" — exactly
the affordance that was silently gone. And it was never SkipKind-specific: any two plugins picking the
same local type name collided the same way, always quietly.

Why every gate was green

  • pnpm typecheck compiles each plugin separately, so the two locals never meet.
  • pnpm check:contracts verifies contracts against their inputSchemas and never compiles the dts.
  • The checker compiles snippets with the dts as an ambient prefix and then drops every diagnostic inside
    it
    . That is deliberate and still correct — a broken prefix is our bug, not the snippet's — and it is
    precisely why our bug was inaudible.

Nothing in the repo compiled the artefact the generator produces. That is the real finding, and the reason
this arrived from a downstream consumer rather than from CI.

The fix

Alpha-rename on collision: the first symbol to claim a name keeps it, later ones become SkipKind$1, and
every reference is rewritten to match. Two details matter more than the rename itself:

  • Keyed by symbol, never by declaration. A merged interface is several declarations of one symbol, and
    renaming those apart would break the merge the source relies on.
  • Names already taken by a plugin-api import are reserved up front, because a bundled local named
    Session beside import type { Session } is the same collision by another route, and which api types end
    up imported is not known until the walk finishes.

Rewriting each reference also settles the local spelling of an import { X as Y }, which previously emitted
a name the bundle never declared.

Two alternatives were considered and rejected. Inlining the literal instead of renaming, on the grounds
that one of the pair is not exported — but edit-session's SkipKind is exported and nothing imports it;
export there means "visible to a sibling file", so exportedness does not identify which name is real.
Erroring early, matching the ContractConflict precedent — but that precedent covers a genuine source bug
where a human must pick a winner, whereas this is a legal construct the generator simply owed a
representation. Erroring would have taxed plugin authors with global uniqueness for file-local names.

The gate

A test now compiles the emitted dts as source, with no prefix filtering. One assertion, and it is what
separates "the contracts resolve" from "the contracts appear to resolve".

A fixture pair covers the collision directly, asserting each type keeps its own narrowing in both
directions — a duplicate identifier resolves to an error type assignable to anything, so only the negative
case fails without the fix. The fixtures are written to a temp directory symlinked to plugin-api, because
a declare module augmentation only merges if its specifier resolves from the declaring file, and under
pnpm's isolated layout it does not resolve from anywhere under apps/cli. A file where it fails declares a
fresh ambient module and silently contributes nothing — so the symlink is what makes the test test anything.

Both new tests fail against the pre-fix generator.

Known gap

A bundled type named after a TS lib global (Record, Event) shadows rather than duplicates. No
TS2300, so the new gate will not catch it. Zero occurrences today; not addressed in this release.

Upgrading

Nothing to do. No API surface changed, no config or stored data is affected, and the web bundle's only diff
is its version stamp. Consumers generating code against the tool dts — function-tools, compiled skills —
get narrowing back on any contract that referenced a collided type.