Skip to main content

@nudojs/service

The service package provides the main programmatic API for type inference. It combines parsing, directive extraction, and evaluation to produce analysis results suitable for tooling (LSP, CLI, IDE extensions).

analyzeFile​

analyzeFile(
filePath: string,
source: string,
activeCases?: Map<string, number>,
externalCallRecords?: CallRecord[]
): AnalysisResult

Runs type inference on a file. Uses filePath for module resolution and diagnostics. activeCases maps function name → case index for diagnostics (e.g. which case is “active” in the IDE).

externalCallRecords accepts call records harvested by collectCallRecords from usage-site files (tests, examples, upstream apps). Records that resolve to functions defined in this file are matched and injected as synthesized call@L cases — see the Call-Site Discovery guide.

Functions without @nudo:case directives are not skipped: whole-program inference synthesizes a call@L case for each observed call site, or an entry@L case with unknown parameters when no call site is found (marked entryOnly on the FunctionAnalysis).

Returns: AnalysisResult


analyzeFileAsync​

analyzeFileAsync(
filePath: string,
source: string,
activeCases?: Map<string, number>,
externalCallRecords?: CallRecord[]
): Promise<AnalysisResult>

Async entry to analyzeFile: preloads path-based env files (/// @nudo:env ./nudo-harvest-node.ts) via dynamic import — impossible synchronously in ESM — then runs the sync analysis, which picks the preloaded factories up from the env-loader cache. Use it from async tooling (CLI, LSP); the sync analyzeFile degrades when the analyzed file declares path envs.

Returns: Promise<AnalysisResult>


collectCallRecords​

collectCallRecords(filePath: string, source: string): CallRecord[]

Phase 1 of call-site discovery: evaluates a usage-site file's top-level code and records every call it makes, with the real argument and result types observed at each call site. Test-framework callbacks (it, test, describe) are invoked with unknown parameters so call sites inside test bodies are captured — the test framework itself never runs. The pass produces no diagnostics and never throws: usage-site files may depend on unmocked globals, so collection is best-effort.

Pass the returned records to analyzeFile/analyzeFileAsync as externalCallRecords to have them injected as call@L cases. See Call-Site Discovery — Programmatic API for the two-phase flow.

Returns: CallRecord[] (see CallRecord)


getTypeAtPosition​

getTypeAtPosition(
filePath: string,
source: string,
line: number,
column: number,
activeCases?: Map<string, number>
): Abs | null

Returns the Abs (the lossless shape × term × pred × conf value) at the given source position (1-based line, 0-based column). Uses the active case index per function when position is inside a function with cases.

Returns: Abs or null if no type at that position.


getTypeAtPositionAsync​

getTypeAtPositionAsync(
filePath: string,
source: string,
line: number,
column: number,
activeCases?: Map<string, number>
): Promise<Abs | null>

Async entry to getTypeAtPosition with path-env preloading (see analyzeFileAsync).

Returns: Promise<Abs | null>


getCompletionsAtPosition​

getCompletionsAtPosition(
filePath: string,
source: string,
line: number,
column: number
): CompletionItem[]

Returns completion items at the given position. Supports variable completions and property/method completions after obj..

Returns: Array of CompletionItem


getCasesForFile​

getCasesForFile(filePath: string, source: string): {
functionName: string;
cases: { name: string; index: number }[];
loc: SourceLocation;
}[]

Lists all functions with @nudo:case directives and their case names/indices. Used for IDE case switching.


isNudoTargetPath​

isNudoTargetPath(path: string): boolean

Extension gate shared by the CLI collector, watch mode, and the LSP isNudoFile check: .js/.mjs/.ts (case-insensitive) are inference targets; .d.ts, .tsx, and everything else are not.


buildSemanticTokens​

buildSemanticTokens(
filePath: string,
source: string,
opts?: { loadModule?: LoadModule; autoBind?: boolean },
): number[]

Produces LSP-encoded semantic tokens (5-tuples: deltaLine/deltaStartChar/length/tokenType/tokenModifiers) from the analysis result — function bindings typed as function, other bindings as variable, parameters as parameter. Top-level named-export function bindings also carry an interface-tier modifier (contract / generated / derived) aligned with CodeLens ● interface via interfaceTierOf (A7). Non-export declarations keep declaration only. The LSP server's semanticTokens handler consumes this directly.

The matching legend and encoder are exported from the same module, and the LSP package re-exports them (TOKEN_TYPES/TOKEN_MODIFIERS) so the token-type indices can never drift from the extractor:

SEMANTIC_TOKEN_TYPES: readonly string[]    // ["function", "variable", "parameter", "property",
// "type", "keyword", "string", "number", "comment",
// "decorator", "method"]
SEMANTIC_TOKEN_MODIFIERS: readonly string[] // ["declaration", "readonly", "deprecated", "unreachable",
// "contract", "generated", "derived"]

type SemanticToken = {
line: number; char: number; length: number;
typeIndex: number; modifierBitmask: number;
};

encodeSemanticTokens(tokens: SemanticToken[]): number[];
interfaceTierModifierBit(src: "handwritten" | "generated" | "implicit"): number;

encodeSemanticTokens delta-encodes { line, char, … } tokens into the flat number[] the LSP expects — buildSemanticTokens already returns encoded output, so you only need it when building tokens yourself.


buildModuleGraph​

buildModuleGraph(
files: string[],
cache?: ModuleGraphCache,
): {
imports: Map<string, Set<string>>; // file → files it imports
dependents: Map<string, Set<string>>; // file → files importing it
}

type ModuleGraphCache = Map<string, { mtimeMs: number; size: number; edges: string[] }>;

Statically extracts each file's relative import edges — the building block for incremental analysis. Extension resolution matches module resolution ('', .js, .ts, .mjs); bare npm specifiers are skipped. Both the CLI's watch mode and the LSP's dirty propagation build a graph over their known files this way.

Pass a cache to keep per-file edges across rebuilds (the LSP session exports one as moduleGraphCache): an entry is reused when the file's mtimeMs and size are unchanged — a stat-only hit with zero disk reads and zero parsing; a miss re-reads the file and backfills the entry.


computeDirtySet​

computeDirtySet(dependents: Map<string, Set<string>>, changedFile: string): string[]

Returns the changed file plus its transitive dependents (reverse-edge BFS over dependents). Safe in the presence of import cycles.


topoSortDirty​

topoSortDirty(imports: Map<string, Set<string>>, dirty: string[]): string[]

Orders a dirty set topologically with dependencies before dependents (only import edges internal to the dirty set count; cycles are tolerated — remaining files are appended in arbitrary order). Re-analyzing in this order ensures importers see their dependencies' updated types first.

A typical incremental-analysis loop:

const graph = buildModuleGraph(files);
const dirty = computeDirtySet(graph.dependents, changedFile);
for (const file of topoSortDirty(graph.imports, dirty)) {
// re-read and re-analyze `file`
}

absToTSType​

absToTSType(a: Abs): string

Serializes an Abs to TypeScript type syntax (e.g. number, string | number, { id: number; name: string }).


generateDts​

generateDts(result: AnalysisResult): string

Generates TypeScript declaration content (.d.ts) from an analysis result. Produces declare function signatures with real parameter names, inferred return types, and JSDoc comments.


generateFunctionDtsLines​

generateFunctionDtsLines(fn: FunctionAnalysis): string[]

Per-function slice of generateDts — JSDoc plus one export declare function line. The CLI's infer --dts / watch --dts share this exact function with generateDts, so both paths emit byte-identical declarations. Functions without cases emit nothing (or a rest-args (...args: unknown[]) line when only combined is known); noDeclaration functions (CJS exports.X = fn) emit nothing and stay in infer/JSON output only.


absToZodSchema​

absToZodSchema(a: Abs): string

Converts an Abs to a Zod schema string. Handles all shape kinds including primitives, literals, objects, arrays, tuples, unions, and more.

Example:

absToZodSchema(obj({ name: str(), age: num() }))
// → "z.object({ name: z.string(), age: z.number() })"

generateGuardFunction​

generateGuardFunction(name: string, abs: Abs): string
generateGuardFunctionFromAbs(name: string, abs: Abs): string

Generates a zero-dependency runtime type guard function as a string. The generated function uses typeof, Array.isArray, and property checks for validation.

Example:

generateGuardFunction("isUser", obj({ name: str() }))
// → "function isUser(data) { ... }"

Case Emission​

The case-emitter functions freeze synthesized call@L cases into source text. The CLI's --emit-cases is a thin orchestration over them — see the CLI guide — Persisting cases as directives for the workflows and merge policy.

serializeCaseArg​

serializeCaseArg(a: Abs): string | null

Serializes a single Abs into expression text that the directive grammar (parseCaseArgExpr) can read back. Returns null for shapes directives cannot express: function, promise (eff), and brand values, bigint literals, non-finite / scientific-notation numbers, and strings/object keys containing structural characters or control characters.

Example:

serializeCaseArg(num())     // → "number()"
serializeCaseArg(strLit("a")) // → '"a"'

buildCaseDirective​

buildCaseDirective(name: string, argsAbs: Abs[]): string | null

Assembles one single-line directive * @nudo:case "name" (a, b) (leading *, no trailing newline) ready to be spliced into a JSDoc block. Returns null when any argument fails serialization or the name contains a quote or newline.

Example:

buildCaseDirective("call@L2", [str()])
// → ' * @nudo:case "call@L2" (string())'

stripGeneratedCaseDirectives​

stripGeneratedCaseDirectives(source: string): { source: string; removed: string[] }

Removes every generated @nudo:case directive line (name starting with the reserved call@ prefix); a JSDoc block left with no other directives or text is removed together with its /** and */ lines. Non-case directives and plain comments are never touched. removed lists the deleted case names in order. This is the first half of update mode — note that hand-written cases named call@… are stripped too, since the prefix is reserved.

insertGeneratedCaseDirectives​

insertGeneratedCaseDirectives(source: string, analysis: AnalysisResult): EmitResult

Freezes the analysis's synthesized cases (source === "callsite") into source: directives are inserted directly above each function declaration — into an existing JSDoc block after its /** line, or into a newly created block. Functions with hand-written cases or existing call@ directives are skipped (see EmitResult), as are entry-only functions; cases that fail serialization are reported per function. Returns the rewritten source alongside the written/skipped report.

unifiedDiff​

unifiedDiff(a: string, b: string, path: string): string

Line-level unified diff (--- a/path header, @@ hunks, 3 context lines) with no third-party dependency; returns "" when the texts are identical. Used by --dry-run to preview emission.

EmitResult​

type EmitSkipReason =
| "hand-written" // function has non-call@ case directives
| "already-generated" // function already has call@ directives (add mode leaves them)
| "entry-only" // no call sites found — nothing worth freezing
| "no-serializable-cases" // no case could be expressed as directive text
| "no-declaration" // CJS binding/assignment function, no stable declaration
| "skipped"; // function skipped by the analyzer itself

type EmitResult = {
source: string; // rewritten source (identical to input when nothing changed)
changed: boolean; // whether any function was written
written: Array<{ fn: string; cases: string[] }>;
skipped: Array<{ fn: string; reason: EmitSkipReason; detail?: string }>;
};

Minimal add / update flows:

import { analyzeFileAsync, insertGeneratedCaseDirectives, stripGeneratedCaseDirectives } from "@nudojs/service";

// add: insert the synthesized cases into the source as-is
const result = await analyzeFileAsync(filePath, source, undefined, records);
const emitted = insertGeneratedCaseDirectives(source, result);

// update: strip old generated directives first, re-analyze, then insert
const stripped = stripGeneratedCaseDirectives(source);
const reanalyzed = await analyzeFileAsync(filePath, stripped.source, undefined, records);
const synced = insertGeneratedCaseDirectives(stripped.source, reanalyzed);

Result Types​

AnalysisResult​

type AnalysisResult = {
functions: FunctionAnalysis[];
diagnostics: Diagnostic[];
bindings: Map<string, BindingInfo>;
nodeAbsMap: Map<Node, Abs>;
caseHints: CaseHint[];
/** functions imported from other modules, synthesized from
cross-file call sites observed while analyzing this file */
externalFunctions?: FunctionAnalysis[];
}

FunctionAnalysis​

type FunctionAnalysis = {
name: string;
loc: SourceLocation;
paramNames: string[]; // actual parameter names from AST
cases: CaseResult[];
combinedAbs?: Abs; // join of case-result Abs; source of the d.ts return type
entryOnly?: boolean; // synthesized entry@L case, no call sites found
skipped?: boolean;
/** CJS-style binding/assignment functions (exports.X = fn) have no
declaration-stable name; .d.ts generation skips them while
infer/JSON output still reports them */
noDeclaration?: boolean;
/** absolute path of the module this function is imported from
(externalFunctions only) */
fromModule?: string;
}

CaseResult​

type CaseResult = {
name: string;
argAbs: Abs[]; // lossless argument Abs
abs: Abs; // lossless result Abs
throwsAbs: Abs; // lossless thrown Abs (never when no throw)
throwLoc?: SourceLocation;
source?: "directive" | "callsite"; // "callsite" = synthesized from an observed call site;
// hand-written cases and entry@ fallbacks leave it unset
expected?: Abs; // `@nudo:case "name" (…) => expected` — presence marks a test assertion
aggregatedFrom?: number; // additional call sites folded into a symbolic case
intension?: { // intensional summary (algebra generalize)
display?: string; term?: string; pred?: string; conf?: string;
abs?: string; // lossless Abs, single line (formatAbs)
absMultiline?: string;
};
}

CallRecord​

One observed call at a usage site, harvested by collectCallRecords:

type CallRecord = {
fnName: string; // callee name observed at the call site
argAbs: Abs[]; // lossless argument Abs as observed
resultAbs: Abs; // observed result Abs (never when the call threw)
throwsAbs: Abs; // observed thrown Abs (never when no throw)
callLoc?: { line: number; column: number }; // call position; line becomes the call@L case name
targetModule?: string; // module the callee was bound from
targetExport?: string; // export name the callee was bound as
targetAliases?: string[]; // later re-export names (barrels, CJS forwarding shims)
fnModule?: string; // module whose evaluation created the function value (definition site)
}

The targetModule/targetExport/fnModule fields drive the attribution gate: a record only matches files its module actually points at, so same-named helpers in test files cannot smear their records across unrelated files. See Call-Site Discovery — Safety Design.

CaseInfo​

type CaseInfo = {
functionName: string;
caseName: string;
caseIndex: number;
}

Addresses a single case of a single function by name and index.

CaseHint​

type CaseHint = {
line: number;
label: string;
ok: boolean;
}

Inline hint (line, label, pass/fail) rendered by IDE integrations next to directives.

Diagnostic​

type Diagnostic = {
range: SourceLocation;
severity: DiagnosticSeverity; // "error" | "warning" | "info"
message: string;
tags?: DiagnosticTag[]; // e.g. ["unnecessary"]
code?: string; // e.g. "nudo:unknown-recv", "nudo:mock-invalid", "nudo-unreachable"
suggestions?: string[];
data?: unknown; // additional context for code actions
/** provenance of the receiver value (callsite argument that flowed into the error) */
origin?: { line: number; column: number };
}

DiagnosticSeverity is "error" | "warning" | "info"; DiagnosticTag is currently "unnecessary".

SourceLocation​

type SourceLocation = {
start: { line: number; column: number };
end: { line: number; column: number };
}

BindingInfo​

type BindingInfo = {
abs: Abs;
loc?: SourceLocation;
}

Type (and optional location) of a top-level binding, keyed by name in AnalysisResult.bindings.

CompletionItem​

type CompletionItem = {
label: string;
kind: "property" | "method" | "variable";
detail?: string;
}

SymbolInfo / ReferenceInfo / SymbolTable​

type SymbolInfo = {
name: string;
kind: "function" | "variable" | "class" | "parameter";
loc: SourceLocation;
uri?: string;
}

type ReferenceInfo = {
name: string;
loc: SourceLocation;
uri?: string;
}

type SymbolTable = {
definitions: Map<string, SymbolInfo>;
references: ReferenceInfo[];
}

Definitions and references for go-to-definition / find-references tooling; the LSP package builds tables of this shape over its open documents.