CLI Usage
The nudo CLI is the primary way to run type inference on .js, .mjs, and .ts files. Install it globally or via npx:
npm install -g @nudojs/cli
# or
pnpm add -g @nudojs/cli
nudo infer
Infer types from a single file — or from every inference target under a directory. Functions with @nudo:case directives use them; every other function is still analyzed (whole-program inference) — observed calls become synthesized call@L cases, and functions with no call evidence get an entry@L case whose parameters default to unknown.
nudo infer <file-or-directory>
The target may be a .js, .mjs, or .ts file (TypeScript type annotations are stripped at the parser layer; the file is inferred with JS semantics) or a directory — directories are scanned recursively for inference targets (.js/.mjs/.ts, excluding .d.ts), and each file is analyzed in its own run. --json requires a single-file target.
Given lib/:
// lib/slug.js
export function slugify(title) {
return title.toLowerCase().replace(/ /g, "-");
}
console.log(slugify("Hello World"));
// lib/note.ts
export function note(text) {
return "note: " + text;
}
nudo infer lib/
Output — one section per function, files in scan order:
=== note ===
entry@L1: (unknown) => unknown
# no call sites found; parameters default to unknown
=== slugify ===
call@L4: ("Hello World") => string
slugify gets a call@L4 case from the top-level call — toLowerCase() folds to the literal, then .replace(...) widens to string, so the result is string. When one analyzed file imports a function from another, the imported function's cases appear in an --- <path> (imported) --- section instead.
Options
| Option | Description |
|---|---|
--dts | Generate a .d.ts declaration file next to the source file |
--loc | Show source locations (file:line:column) in the output |
--json | Output results as structured JSON — requires a single file (see the JSON example) |
--callsites <paths...> | Mine usage sites (tests, examples, apps) for real argument shapes and synthesize cases from them — see Call-Site Discovery |
--emit-cases [mode] | Debug only — write synthesized call-site cases back as @nudo:case directives — see Persisting cases as directives |
--dry-run | With --emit-cases: print a unified diff instead of writing to disk |
--exit-on-diff | With --dry-run: exit with code 1 when the diff is non-empty |
Examples
Given math.js:
export function subtract(a, b) {
return a - b;
}
subtract(5, 3);
subtract(1, 10);
Basic inference (call-site first):
nudo infer math.js
Output:
=== subtract ===
call@L6: (5, 3) => 2
call@L7: (1, 10) => -9
Observed: 2 | -9
Each call@L… line is an observed call-site fact. Observed: joins the results (absorption simplifies when a base type is present; pure-literal unions keep every literal). Optional @nudo:case witnesses print as debug "name": … — debug / nudo test only.
Generate TypeScript declaration file:
nudo infer math.js --dts
This creates math.d.ts alongside your source file with inferred function signatures.
Show source locations:
nudo infer src/math.js --loc
Output includes location information:
=== subtract (src/math.js:1:0) ===
call@L6: (5, 3) => 2
call@L7: (1, 10) => -9
Observed: 2 | -9
Functions without directives
Functions without @nudo:case directives are still inferred from how they are used. With no recorded call, parameters default to unknown and the case is named entry@<line>:
// src/plain.js
export function add(a, b) {
return a + b;
}
nudo infer src/plain.js
=== add ===
entry@L1: (unknown, unknown) => number | string
# no call sites found; parameters default to unknown
When the analyzed file calls an imported function, each observed call becomes a synthesized call@<line> case with the real argument shapes:
// src/main.js
import { add } from "./plain.js";
console.log(add(2, 3));
console.log(add("2", "3"));
nudo infer src/main.js
--- src/plain.js (imported) ---
=== add ===
call@L3: (2, 3) => 5
call@L4: ("2", "3") => "23"
Observed: 5 | "23"
To harvest argument shapes from separate usage-site files (tests, examples, apps), pass them with --callsites — see Call-Site Discovery.
Persisting cases as directives
--emit-cases is a debug / self-containment tool, not the contract product — obligations live in *.nudo.js sidecars / @nudo:refine / @nudo:interface (see nudo interface).
Synthesized call@L cases live only inside the analysis run — run nudo infer lib.js again without --callsites and they are gone. --emit-cases freezes them into the source file as real @nudo:case directives, which makes the file self-contained for later debug / nudo test runs: other tools (check, watch, .d.ts generation) can re-read the same shapes without re-evaluating the usage sites.
Bootstrap: harvest once, write back
Given a library and a test that exercises it:
// lib.js
function add(a, b) { return a + b; }
function greet(name) { return "hi " + name; }
console.log(add(1, 2));
add("x", "y");
module.exports = { add, greet };
// test.js
const { greet } = require("./lib.js");
greet("ada");
greet("bob");
Run inference with the usage site and write the synthesized cases back:
nudo infer lib.js --callsites test.js --emit-cases
=== add ===
call@L3: (1, 2) => 3
call@L4: ("x", "y") => "xy"
Observed: 3 | "xy"
=== greet ===
call@L2: ("ada") => "hi ada"
call@L3: ("bob") => "hi bob"
Observed: "hi ada" | "hi bob"
Emitted cases → lib.js (4 directive(s) across 2 function(s))
add: call@L3, call@L4
greet: call@L2, call@L3
lib.js now carries the directives (inserted into a JSDoc block above each function declaration):
/**
* @nudo:case "call@L3" (1, 2)
* @nudo:case "call@L4" ("x", "y")
*/
function add(a, b) { return a + b; }
/**
* @nudo:case "call@L2" ("ada")
* @nudo:case "call@L3" ("bob")
*/
function greet(name) { return "hi " + name; }
console.log(add(1, 2));
add("x", "y");
module.exports = { add, greet };
Running the same command again is idempotent — the summary at the end becomes:
No changes.
add: already-generated
greet: already-generated
Drift detection: update mode
Usage sites evolve, and directives frozen from them can go stale. =update re-synchronizes previously generated directives: it strips all call@ directives from the source, re-analyzes the stripped source, and writes the refreshed set back — so additions, changes, and deletions at the usage sites are reflected. Say the test drifted to a single different call:
// test.js — usage drifted
const { greet } = require("./lib.js");
greet(42);
Combine update with --dry-run and --exit-on-diff to turn this into a CI gate:
nudo infer lib.js --callsites test.js --emit-cases=update --dry-run --exit-on-diff
=== add ===
call@L3: (1, 2) => 3
call@L4: ("x", "y") => "xy"
Observed: 3 | "xy"
=== greet ===
call@L2: (42) => "hi 42"
Would emit cases → lib.js (dry run)
add: call@L3, call@L4
greet: call@L2
--- a/lib.js
+++ b/lib.js
@@ -4,8 +4,7 @@
*/
function add(a, b) { return a + b; }
/**
- * @nudo:case "call@L2" ("ada")
- * @nudo:case "call@L3" ("bob")
+ * @nudo:case "call@L2" (42)
*/
function greet(name) { return "hi " + name; }
console.log(add(1, 2));
The diff is non-empty, so the command exits with code 1. Drop --dry-run (and --exit-on-diff) to apply it:
nudo infer lib.js --callsites test.js --emit-cases=update
=== add ===
call@L3: (1, 2) => 3
call@L4: ("x", "y") => "xy"
Observed: 3 | "xy"
=== greet ===
call@L2: (42) => "hi 42"
Emitted cases → lib.js (3 directive(s) across 2 function(s))
add: call@L3, call@L4
greet: call@L2
update is idempotent too — a second run prints No changes.
To check a whole project for stale directives without reading diffs, see Health Checks and CI Drift Gating — nudo doctor reports drift across many files in one run.
What emission touches
Emission never touches hand-written work — it only manages its own call@ directives: hand-written cases are never modified, functions that already carry generated directives are reported already-generated (in add mode) or fully re-synchronized (in update mode), entry-only functions are never written, and non-serializable cases are skipped. The complete merge-policy table is documented in Call-Site Discovery — Merge policy; the programmatic flow is documented under service API — Case Emission.
nudo check
Check a single file for type errors. check prints one line per diagnostic in the form [severity] path:line:column message (error-code) and exits with code 1 when any error-level diagnostic is found — warnings alone keep the exit code at 0, which makes it suitable for CI.
nudo check src/broken.js
[warning] src/broken.js:2:9 Cannot resolve 'name' on unknown value (nudo:unknown-recv)
[warning] src/broken.js:2:9 Cannot resolve 'toUpperCase' on unknown value (nudo:unknown-recv)
Hint lines, error-level assertions, and the exit-code rules are covered in the nudo check reference.
nudo interface
The interface product: per-function refinement contracts with their source layer. With no sidecar and no annotation, every export still gets its implicit interface from call-site inference; sidecar bindings and @nudo:refine lift it to handwritten; --emit-persisted segments show as generated.
Sidecar keys for classes / aliases: exported class instance methods bind as Class.method / Class_method (constructor, static, get/set excluded). For export { Local as Public }, analysis and sidecars use the local declaration name (Local, Local.method) — Public is only the public export name, not a contract key.
nudo interface [paths...] # print only, never writes
nudo interface --emit <file> --fn <name> # persist inferred domains
nudo interface --draft <file> # reviewable contract draft from existing code
nudo refine # alias of `nudo interface`
--draft — code-first / migration
Generate a reviewable interface draft from what the code already does. Designed for migrating an existing JS package, or for writing logic first and contracts later.
nudo interface --draft lib.js # print a *.nudo.draft.js module
nudo interface --draft --write lib.js # write lib.nudo.draft.js
nudo interface --draft lib.js --fn greet
Evidence layered in the draft (never invents obligations):
| Evidence | Meaning |
|---|---|
callsite / directive | Observed argument domains (joinThenProject) |
body | Fields the implementation reads on a param — suggestion only, never a check obligation |
symbolic | generalizeFromAst fallback for returns |
| omitted param slots | No evidence — comment /* tighten */, not a contract |
Rules:
- Handwritten sidecar/refine bindings are skipped (never overwritten).
- Output file is
*.nudo.draft.js— not auto-bound (loadModule only reads*.nudo.js). - Accept by copying reviewed exports into
*.nudo.js(then they become real contracts). --emitremains the path that freezes call-site domains as@generatedfacts;--draftis the human-facing starting point.- IDE: CodeLens
⚡ draft interface(non-handwritten exports) runs the same draft path; agents usenudo.interface.draft. - Optional:
package.json→nudo.analysis.evalMissingSlot: "warning"surfaces evaluation-hit missing fields asnudo:missing-slot(defaultoff). - End-to-end walkthrough: Migrating existing JS.
lib.js
double [draft callsite/callsite] fn({ x: number() }, number())
lonely [draft none/none] fn({})
Draft written → lib.nudo.draft.js
review, then copy accepted exports into lib.nudo.js
Given a file with in-file call sites and no sidecar:
// lone.js
export function scale(x) {
return x * 2;
}
scale(3);
scale(5);
nudo interface lone.js
lone.js
scale [implicit] (x: 3 | 5) → 6 | 10
With a handwritten sidecar (calc.nudo.js importing std.nudo.js):
nudo interface calc.js
calc.js
addTax [handwritten] (x: number().gt(1)) → number()
greet [handwritten] (name: union(lit("ada"), lit("bob"))) → string()
Emitting
--emit has two modes depending on the target file's role (design §7.3):
- Root-driven derivation — when the file has a handwritten contract root,
--fnmay name a downstream export in the derivation closure.nudo interface --emit lib.js --fn add2writesadd.nudo.jswith a compositional segment (const x = positive.shift(1); export const add2 = fn({ x }, x.shift(2))), including thederived-from: lib.js:add4annotation and theimport { positive } from "./std.nudo.js"that mirrors the root sidecar. - Call-site domain — for the target file's own exports, projects the observed call-site domain onto a sidecar
@generatedsegment. Domain roots (exports with no in-file call sites) need--callsites <paths...>.
# downstream contract from a handwritten root
nudo interface --emit lib.js --fn add2
# own-file call-site domain
nudo interface --emit double.js --fn double
Updated double.js → double.nudo.js
written: double
re-run `nudo check double.js` to see the persisted interfaces in action
// double.nudo.js
// @generated by nudo — do not edit; regenerate with `nudo interface --emit`
// source: double.js:double
export const double = fn({ x: lit(4) }, lit(8));
// add.nudo.js (derived from lib.js:add4)
// @generated by nudo — do not edit; regenerate with `nudo interface --emit`
// source: add.js:add2
// derived-from: lib.js:add4
import { positive } from "./std.nudo.js";
const x = positive.shift(1);
export const add2 = fn({ x }, x.shift(2));
nudo interface double.js
double.js
double [generated] (x: lit(4)) → lit(8)
Cross-primitive literal domains persist as unions (design form):
// mixed.nudo.js
export const scale = fn({ x: union(lit(42), lit("a")) }, number());
Options
| Option | Description |
|---|---|
--emit | Write/update @generated segments instead of printing (mode: update — strips and rewrites generated segments; idempotent) |
--draft | Generate a reviewable contract draft from existing code (prints a *.nudo.draft.js module) |
--write | With --draft: write/update <file>.nudo.draft.js (never touches handwritten *.nudo.js) |
--fn <name> | With --emit/--draft: only these export names (repeatable). May name a downstream export in the root derivation closure |
--all | With --emit: target every top-level export (explicit opt-in; prefer --fn to keep diffs reviewable) |
--dry-run | With --emit: print a unified diff instead of writing |
--exit-on-diff | With --emit + --dry-run: exit 1 when the sidecar would change (CI gate) |
--callsites <paths...> | Usage-site files feeding the domain evidence for print/emit |
Exit codes: 0 normal; 1 for usage errors, --exit-on-diff with changes, and emit issues (nudo:interface-name-clash — a handwritten binding wins and the write is skipped).
$ nudo interface --emit calc.js --fn addTax
calc.js: no interface changes
skipped addTax (name-clash)
[error] nudo:interface-name-clash: sidecar already has a handwritten binding 'addTax' (calc.js); handwritten wins — skipping emit for it
# exit 1
Re-running --emit with unchanged evidence is a no-op (no interface changes); when evidence disappears (e.g. update without --callsites), persisted segments are preserved, never silently deleted. Sidecar auto-binding can be turned off project-wide with package.json → "nudo": { "interface": { "autoBind": false } } — the switch is wired into nudo check and LSP enforcement paths as well, not just printing.
Emit allowlist (Phase 3). package.json → "nudo": { "interface": { "emit": ["src/api/**"] } } restricts which source file paths may be written (sidecars are written next to the source). Empty / omitted = no path filter. Patterns are globs relative to the project root (** crosses directories, * does not). Without --fn/--all, root-driven emit only refreshes already-persisted downstream @generated segments and will not invent new contracts.
Persistence is snapshots: nudo check compares them semantically and reports nudo:interface-drift warnings when the file evolves — see check. nudo doctor surfaces the same drift as a CI gate for files whose sidecar already contains @generated segments.
nudo types
The type-as-computation view: each function's intension from Abs algebra — shape, term, pred, and confidence — instead of the extensional shape that infer reports. Refinements participate in algebra, so a declared precondition shows up inside the inferred term:
nudo types docs/examples/algebra/0-add-intensional.js --assume "x>0"
nudo types 0-add-intensional.js
assume: x > 0
add(unknown, unknown)
number | string
conf: partial
scale(number)
number
term: (x + 1)
pred: (x + 1) > 1
conf: path
twice(number)
number
term: ((x + 1) + 1)
pred: ((x + 1) + 1) > 2
conf: path
scale's signature is number with term: (x + 1) and pred: (x + 1) > 1 — the @nudo:refine x positive precondition (x > 0) was applied to the computation and produced the stronger postcondition. add carries no constraint, so its number | string result is conf: partial. Options (--fn, --assume, --generalize) are documented in the nudo types reference; the CI-pinned run of this exact file is in the example matrix.
nudo harvest
Convert installed @types/<pkg> TypeScript declarations into a Nudo env file — TypeScript source that rebuilds those types with Nudo env constructors. The @types package must be installed first:
pnpm add -D @types/node
nudo harvest node
Reference the generated nudo-harvest-node.ts from the files that need those ambient types:
/// @nudo:env nudo-harvest-node.ts
Options (--out) and the output format are documented in the nudo harvest reference.
nudo watch
Watch a file or directory and re-run inference on change:
nudo watch . # current directory
nudo watch src/math.js # a single file
nudo watch . --dts # with .d.ts generation
Directories are scanned recursively for inference targets (.js/.mjs/.ts, excluding node_modules); changes are debounced (200ms), and each run re-analyzes only the changed files and their dependents. The full behavior is documented in the nudo watch reference.
Runtime validator generation
nudo generate turns inferred types into runtime artifacts — Zod schemas, type-guard functions, and .d.ts declarations — from the same @nudo:case evidence:
nudo generate src/user.js # zod + guard + dts to stdout
nudo generate src/user.js --format zod # zod only
nudo generate src/user.js --output dist # writes dist/user.nudo.zod.ts, user.nudo.guard.ts, user.d.ts
nudo emit is the .d.ts-only alias (generate --format dts) and nudo guard the guard-only alias (--format guard); guards prefer the lossless Abs path (shape + decidable numeric preds) and fall back to the extensional projection. Options and output formats: nudo generate reference.
Health Checks and CI Drift Gating
nudo doctor re-checks a whole project in one command: analysis errors, and — with --callsites — whether the call@ directives frozen by --emit-cases still match what the usage sites would produce today. Drift or errors exit with code 1, which makes doctor a CI gate for solidification drift.
The typical lifecycle:
-
Solidify once — bootstrap the directives from the usage sites (see Persisting cases as directives):
nudo infer lib.js --callsites test.js --emit-cases -
The usage sites evolve — tests change their call shapes, and the frozen directives go stale.
-
doctorreports the drift:nudo doctor lib.js --callsites test.jslib.js
· 3 function(s), 1 entry-only
✗ drift: 5 directive(s) changed (+3 new, -2 removed) — refresh with: nudo infer lib.js --callsites test.js --emit-cases=update
Summary: 1 file(s) · 1 drift · 0 error(s) · 0 uncovered function(s)
Result: FAIL (drift or errors found) -
Refresh with the printed command — copy it as-is:
nudo infer lib.js --callsites test.js --emit-cases=update -
Re-check — a second
doctorrun is green again:Result: OK (uncovered function(s) are informational only).
In CI, check an entire source tree against the test suite in one line — any drift fails the build:
nudo doctor src/ --callsites tests/
Exit codes: drift or analysis errors → 1; uncovered functions are informational only and never fail the run. See the nudo doctor reference for all options and the --json output.
Practical Workflow
-
Develop with watch mode: Run
nudo watch . --dtsin a terminal while editing. Each save triggers re-inference and.d.tsgeneration. -
CI / pre-commit:
nudo checkexits with code1on error-level diagnostics, so it can gate CI. Pass a directory to check every inference target under it in one run (nudo checkscans directories recursively, excludingnode_modules):nudo check src/To exclude specific paths (e.g. generated files), loop over the exact files you want gated instead:
find src \( -name "*.js" -o -name "*.mjs" -o -name "*.ts" \) \
-not -name "*.d.ts" -not -path "*/node_modules/*" -print0 |
xargs -0 -n1 nudo check -
Generate declarations: Use
nudo infer src/ --dts(or a single file) to produce.d.tsfor consumers expecting TypeScript definitions. -
Reuse ambient types: Run
nudo harvest <pkg>once per@typespackage and reference the generated env file with/// @nudo:env ./nudo-harvest-<pkg>.tsin the files that need it. -
Inspect the algebra view: When a refined signature behaves unexpectedly, read its intension —
nudo types src/math.js --assume "x>0"shows theterm/pred/confbehind the inferred type (seenudo types).