@visionary_software/contrax-transformer
v2026.5.10
Published
Contrax TypeScript-Compiler Transformer — enforces @precondition / @postcondition JSDoc contracts at tsc build time.
Downloads
70
Readme
contrax-transformer — Compile-time Contract Enforcement (TypeScript line)
The TypeScript-Compiler Transformer that enforces contrax @precondition / @postcondition JSDoc contracts as hard tsc build errors, before the code ever runs. Mirror of Java's ContractProcessor (annotation processor), with the same recognition + dispatch + emission split — recognition lives in contrax-annotations's check, emission lives in violate, this engine is the AST plumbing that picks the right ts.SyntaxKind nodes to dispatch at.
This is the TypeScript line. The Java line lives on java; the Kotlin K2/FIR port on kotlin.
Install
bun add @visionary_software/contrax-transformer
npm i @visionary_software/contrax-transformer
pnpm add @visionary_software/contrax-transformer
yarn add @visionary_software/contrax-transformerDirect dependency: @visionary_software/contrax-annotations for check + the SPI vocabulary types. Peer: typescript. Loaded via ts-patch (the contrax-transformer expects to run as a ProgramPattern plugin in a tspc-driven build).
Usage
In tsconfig.json:
{
"compilerOptions": {
"removeComments": false,
"plugins": [
{ "transform": "@visionary_software/contrax-transformer" }
]
}
}In package.json scripts, swap tsc for tspc. The package ships a bunx contrax-init adoption CLI that does both edits idempotently:
bun add -D @visionary_software/contrax-transformer
bunx contrax-init # apply the edits
bunx contrax-init --dry-run # preview the edits without writingcontrax-init is always-apply and idempotent; re-running on an already-configured project reports nothing to do. Adopters never have to hand-edit either file.
Configuration
The transformer accepts a TransformerConfig:
interface TransformerConfig {
readonly enforcements?: ReadonlyMap<string, Enforcement>;
readonly discoverFrom?: string; // path to a directory whose node_modules to scan
}Two ways to populate the Enforcement registry, combinable:
Auto-discovery (the analog of Java's
ServiceLoader.load(Enforcement.class)): setdiscoverFromto your project root. The transformer walks<discoverFrom>/node_modulesfor packages whosepackage.jsoncarries acontrax.enforcementsfield pointing at the module that exports the Enforcements (Babel/PostCSS-style ecosystem convention — name-agnostic). Each declared module is required, and every callable named export is registered under its export name. An enforcement-providing package looks like:{ "name": "@visionary_software/contrax-enforcements", "main": "./dist/index.js", "contrax": { "enforcements": "./dist/index.cjs" } }Adopting it =
bun add @visionary_software/contrax-enforcements. No code change.Explicit map (for tests, power users, or one-off overrides): pass an
enforcementsmap directly. Explicit entries override discovery on key collision.
Lookup keys match the export names users reference in @precondition <Name> / @postcondition <Name>.
Discovery is silent on missing or invalid entries
The walk is permissive at every boundary:
- Package without a
contrax.enforcementsfield — skipped (most packages aren't Enforcement providers). - Field present but the relative path doesn't resolve — skipped (stale install / partial publish).
- Entry file present but
require()throws — propagates (a broken Enforcement bundle is a build-time programmer error worth surfacing). - Entry's named exports include non-callables — non-callables are filtered out, callables registered.
This matches the dispatch behavior in contrax-annotations's check(): a use site referencing an enforcement that didn't make it into the registry is a silent pass, not a build failure. Failing the build because a CI agent forgot to install an enforcement plugin would be operationally fragile.
The XMOD-3 meta-diagnostic (see below) catches the closely-related "upstream package was emitted with removeComments: true" case explicitly so it doesn't masquerade as silent dispatch.
Behavior
The transformer walks every non-.d.ts source file in the program and dispatches per node kind:
| AST node | What fires |
|---|---|
| ts.CallExpression / ts.NewExpression | One Pre check per argument, dispatched via check(details, preCheck) |
| ts.ReturnStatement (with an expression) | One Post check, dispatched via check(details, postCheck) |
Every other recognition / emission decision lives downstream in contrax-annotations. The transformer itself owns no contrax-specific behavior beyond picking which AST nodes to dispatch at.
TypeScript peculiarities
The transformer is the heaviest concentration of TypeScript-specific quirks in the contrax line — none of the items below have direct Java/Kotlin counterparts.
1. ServiceLoader analog via package.json#contrax.enforcements
The Java line uses ServiceLoader.load(Enforcement.class); the Kotlin line uses ServiceLoader.load(Enforcement::class.java). TypeScript has no equivalent. The transformer instead uses a Babel/PostCSS-style ecosystem convention: any package whose package.json declares a contrax.enforcements relative-path field is recognized as an Enforcement provider; the path is require()d at transformer-startup and every callable named export is registered into the registry.
{
"name": "@visionary_software/contrax-enforcements",
"main": "./dist/index.js",
"contrax": { "enforcements": "./dist/index.cjs" }
}This convention is opt-in by field, not by name prefix. Any package can ship Enforcements regardless of its name; conversely, an unrelated package that happens to start with contrax- is not auto-discovered.
2. CJS bundle requirement for Enforcement packages
The transformer runs on the Node runtime (tspc spawns a node process; Bun-based builds shell out to node for the transformer phase). Node's require() cannot consume native ESM, so every Enforcement-providing package must publish a CJS artifact and point contrax.enforcements at the .cjs. The package's primary main / exports.import can still be ESM for downstream TS consumers; the CJS sibling exists purely for the transformer's discovery walk. See contrax-enforcements and contrax-range-check for working examples.
3. JSDoc repair for the upstream parser quirk
tspc defaults to ts.JSDocParsingMode.ParseForTypeErrors, which strips every JSDoc tag except @see and @link from the parsed AST. That mode is correct for type-error-only compilation, but it would silently strip every contrax tag (@precondition, @postcondition, @rangeBounds, custom typedefs) before the transformer ever ran. The transformer's repairJSDocOn pass re-parses each source file with ts.JSDocParsingMode.ParseAll, then walks the original and reparsed ASTs in parallel, copying the reparsed JSDoc arrays back onto the original nodes (and clearing jsDocCache so subsequent ts.getJSDocTags calls see the patched arrays). This runs once per source file at transformer-startup, before any walking begins.
If you write a custom typedef walker on top of apt's primitives, prefer node.jsDoc[i].tags over ts.getJSDocTags(node) — see apt's "TypeScript peculiarities" section for the cache-invalidation rationale.
4. The bunx contrax-init adoption CLI
The Java/Kotlin lines have no adoption CLI — adopters wire META-INF/services/... files by hand or rely on Gradle plugins. TypeScript's adoption surface is two file edits (tsconfig.json plugins entry; tsc → tspc script rewrite), which is mechanical enough to fully automate. bunx contrax-init makes both edits idempotently using jsonc-parser so existing comments and trailing commas survive the rewrite. Re-running on a wired project is a no-op.
The CLI exports a programmatic entry (contraxInit({ cwd, dryRun })) that tests use to assert on the exact transformation without spawning a subprocess.
5. XMOD-3 — removeComments: true upstream meta-diagnostic
A consumer that imports a contrax-shipping library has no way to enforce its contracts if the upstream package was emitted with compilerOptions.removeComments: true — the JSDoc tags are gone from the .d.ts, so neither @precondition nor any custom registered tag survives. The naïve failure mode is a silent no-op: tags missing → engine sees no contracts → build passes despite real violations.
The transformer catches this via a two-signal heuristic that runs once per program build, after the JSDoc repair pass:
- The consumer's own non-
.d.tssource has at least one@precondition/@postconditiontag (proves contrax is genuinely wired up — won't false-positive on consumers who happen to import a JSDoc-light library). - A call from the consumer resolves to a declaration in an upstream
.d.tswhose source file contains zero JSDoc nodes anywhere.
When both hold, the transformer emits one Warning-severity diagnostic per upstream .d.ts with the message:
Upstream package
<fileName>appears to have been emitted withremoveComments: true(zero JSDoc nodes in the .d.ts). Any contrax contracts it declared have been stripped and cannot be enforced. Re-emit it withremoveComments: false.
The fix on the upstream side: re-emit with removeComments: false (preserving JSDoc in the published .d.ts).
License
GPL-3.0-or-later. See COPYING. Contact Visionary Software Solutions for commercial licensing.
