Extraction algorithm
The TypeScript adapter turns one function's AST into a RawCodeStructure, and every later stage relies on that structure being correct.
For each code unit, extraction runs in five steps that compose:
Steps 2 and 3 only walk the AST. They use no framework knowledge and resolve no symbols. Each can be tested on its own with a tiny fixture function.
Step 4 resolves symbols through ts-morph's type checker. It is the most language-specific step and the one that makes the most compiler calls.
Each step is in its own file: paths/pathConditions.ts for the path engine, conditions.ts for the expression-level walker it composes, then predicates.ts, subjects.ts and terminals/. None of them calls another directly. assembly.ts puts them together.
All five steps work on one function at a time. The whole-program passes that run afterward, the reachable closure, re-throw enrichment and boundary effects, are rules over a shared fact database. See Facts and rules.
Step 1: findTerminals
Input: a function AST node + a list of TerminalPattern from the framework pack Output: a list of { node, terminalData } pairs
Walk every descendant of the function node. For each descendant, try to match it against each TerminalPattern in order. On a match, extract the terminal data using the pattern's extraction rules and record the pair.
findTerminals(func, patterns):
results = []
func.forEachDescendant(node => {
for pattern in patterns:
if matchTerminal(node, pattern.match):
data = extractTerminalData(node, pattern.extraction)
results.push({ node, data, kind: pattern.kind })
break // one terminal per node
})
return resultsPattern match types:
returnShape: the node is aReturnStatementreturning an object literal, optionally with required properties. For ts-rest:requiredProperties: ["status", "body"]means the return must bereturn { status: ..., body: ... }.parameterMethodCall: the node is a call expression on a specific parameter, with a specific method chain. For Express:parameterPosition: 1, methodChain: ["status", "json"]matchesres.status(X).json(Y).throwExpression: the node is aThrowStatement, optionally requiring the thrown expression text to match a constructor pattern. A project supplies the name of its own helper, soconstructorPattern: "widgetError"then matchesthrow widgetError(...).
Extraction:
Given a matched terminal node, apply the extraction rules to pull out status code and body:
{ from: "property", name: "status" }: read thestatusproperty from the object literal. If it's a numeric literal, produce{ type: "literal", value: N }. Otherwise{ type: "dynamic", sourceText }.{ from: "argument", position: 0 }: read the first argument of the matched call.{ from: "constructor", codes }: look the thrown expression's constructor name up in thecodesmap the pack supplied (full text first, then last dot-segment). This only fires forthrowExpressionmatchers.
Step 1b: extractShape: three-pass body-shape extraction
Response bodies and return values are extracted into TypeShape (see IR types). The adapter runs three passes in order, stopping at the first that succeeds:
Syntactic decomposition. Object literals, array literals, and primitive literals decompose directly from the AST. This preserves literal narrowness that the type checker would widen:
return { status: "success" }recordsstatusas{ type: "literal", value: "success" }, nottext. Negative numerics (-3) and unary plus fold into the literal with signedrawtext. Numeric literals includeraw, so hex / scientific / separators / integers pastNumber.MAX_SAFE_INTEGERsurvive the IEEE 754 coercion.AST resolution. For terminal nodes that aren't literals (bare identifiers, property access chains, destructuring bindings, local single-return function calls), the adapter walks back to the defining value and re-enters
extractShapeon that. This letsconst kind = "success"; return { kind }still produce a literal shape even though the use-site type checker would have widenedkindtostring. The walker only recurses into initializers that are syntactically informative: literals, aggregate literals, ternaries, and identifier / property chains. Call / await /newinitializers skip this pass, because their declaration-site type is usually wider than use-site flow narrowing (e.g.const user = await db.find()returnsT | null, but past a null guard the use site is onlyT).Type-checker fallback. Anything the first two passes can't resolve is handed to ts-morph's type checker via
shapeFromNodeType. This catches identifiers whose declarations live in other modules, generics, and types without literal initializers. The type checker sees flow narrowing at the reference site, so narrowed unions collapse correctly here. Opaque named types (Date,Promise,Map,Error, …) stop at{ type: "ref", name: "Date" }instead of expanding their structural properties, because the wire form depends on the codec and the structural expansion would mislead the reader. Index-signature types (Record<string, T>,{ [key: string]: T }) with no named properties become{ type: "dictionary", values: ... }.
Spreads. { ...user, admin: true } runs the spread expression through the same three-pass pipeline. A resolvable record result is merged in source order, so later keys and later spreads override earlier ones, and only an unresolvable spread falls through to the record.spreads[] escape hatch. A union spread counts as unresolvable too, say a value narrowed to record | null where the caller would have flow-narrowed it to record, which keeps the conservative reading that some extra fields could be anything.
Recursion and cycles. Both the type-checker walk and the AST walker bound recursion: the type walker caps at depth 6 and tracks already-expanded type identities; the AST walker caps at 8 hops and tracks node identities. Cyclic const a = a (and deeper variants) terminate at a ref.
Steps 2+3: CFG path conditions
The path engine (paths/pathConditions.ts) computes steps 2 and 3, and it is the only condition engine. It enumerates every entry-to-terminal control-flow path over the function's statement flow and emits one RawBranch per path, so a terminal reached along several paths becomes several transitions, each with its own conjunction of conditions. It implements the exhaustiveness principle below directly, and it handles nested guards and returns inside loops. The hand-written condition collectors it replaced were unsound and have been deleted, so there is no fallback path:
if (a) { if (b) return X; } Y→ Y gets the paths[¬a]and[a, ¬b], never a fabricated¬a ∧ ¬bor an empty list;- sibling guards inside a block gate their tails (
if (a) { if (b) return X; T }→ T gets[a, ¬b]); if (a) {…} else { return; } T→ T gets[a](else-exit closure);- terminals inside loops get an opaque "some iteration" condition, and post-loop terminals get an opaque "loop exited" negation. No static reader can decide what held on every iteration, so the engine keeps the condition opaque;
- a dead-code terminal, one that no entry path reaches, never produces a transition.
Expression-level branching below a statement (ternaries, && and ||, case clauses inside nested callbacks) is appended from the scoped ancestor walker in conditions.ts. The path engine walks statements, and the walker covers the expression tree beneath them.
The differential fuzzer checks the engine mechanically (tools/differential; see the differential-fuzzing record).
What the engine models
It models the whole structured statement language: if/else, switch (case groups, trailing breaks, fallthrough into an empty clause), every loop form, try/catch (plus finally where it is only cleanup), break/continue, return/throw, and expression-bodied arrows. Two constructs are beyond what it can decide:
- Loops. No static reader can decide whether a condition held on some iteration, so an in-loop terminal gets an opaque "some iteration of:" condition and a post-loop terminal gets an opaque "loop exited" negation.
- Catch blocks. No static reader can decide which statement threw, so a catch-body terminal gets a single opaque
catchcondition (source: "catchBlock").
Callbacks the unit hands to its own calls
A nested arrow or function expression is part of the unit that wrote it unless a pack claimed it as a sub-unit. walk/descent.ts makes that decision once, and every pass uses it: the recognizer walk, the invocation-effect walk, terminal discovery, and the statement lowering. So a .then, .map, or promise-executor callback contributes its branches to the unit the same way its calls already contributed their effects, and a route handler or event subscription a pack declared stays a unit of its own.
Its exits are its own, though. A return or a throw inside a callback ends the callback rather than the unit, so the enclosing path picks up again past the call and the code after it stays reachable. The callback's return never becomes one of the unit's terminals. In fetch(u).then((r) => r.json()) the callback resolves the promise the unit returns, and treating that as a second exit would invent a transition that is not in the source.
When a branch reaches the summary
A branch that rejoins leaves nothing behind on the terminals after it. if (a) { log() } return X gives X one unconditional path, because X is reached either way. mergeRejoined does that, and it is also what stops a run of guards multiplying. The paths a body hands back at the end are the exception: no statement follows them, so nothing merges them, and if (res.ok) { toast.success() } else { toast.error() } at the end of a function leaves two fall-through branches. The checker needs both to see a consumer discriminating on a status when neither arm returns.
A client's caller keeps the arms of a status test even when code follows the if. if (res.status === 200) { setPassed(true) } else { setPassed(false) }; setLoading(false) would otherwise rejoin before setLoading, and the caller would look as if it never handled a failure. Each adapter tells the engine which tests to keep through keepsArms: a test that reads one of the status or success members the client's pack lists, read the same way the checker reads them. Those two arms go on as separate paths to every later terminal, and the end of the body adds the rejoined path as well. Every other test merges the usual way.
Shapes the engine declines
The engine does not model labeled statements, finally blocks that exit or contain terminals, switch fallthrough into a non-empty clause, non-trailing switch breaks, or a function past the 256-path budget. There is no second engine behind these. Each terminal keeps its enclosure conditions, the ancestor branches it is inside, which gate it whatever path the flow takes, plus one opaque unmodeled control flow (<reason>) conjunct. The transition then no longer claims a complete condition set, as the second correctness principle below requires.
Below statements: the expression-level walker
collectAncestorConditionInfosBelow (conditions.ts) walks from a terminal up to its containing statement and records the expression-level branching in between: ternary arms, &&/|| short-circuits, and conditions inside nested callbacks that the statement-level enumeration doesn't see. The path engine appends these to each path's condition list. The walker follows the same purity rule as the engine: AST only, no symbol table, no framework knowledge.
Step 4a: parseConditionExpression
Input: a condition AST node (e.g., !user, user.deletedAt, status === 200) Output: a Predicate structure, or null if the expression can't be decomposed
Pattern-match the expression node kind and build the corresponding Predicate variant:
parseConditionExpression(expr):
match expr:
PrefixUnaryExpression with "!" operator:
inner = parseConditionExpression(expr.operand)
if inner is truthinessCheck:
return { ...inner, negated: !inner.negated }
return { type: "negation", operand: inner }
BinaryExpression:
op = expr.getOperatorToken().getText()
match op:
"===", "!==", "==", "!=":
return {
type: "comparison",
left: resolveSubject(expr.getLeft()),
op: mapOp(op),
right: resolveSubject(expr.getRight()),
}
">", ">=", "<", "<=":
return {
type: "comparison",
left: resolveSubject(expr.getLeft()),
op: mapOp(op),
right: resolveSubject(expr.getRight()),
}
"&&":
return {
type: "compound",
op: "and",
operands: [parseConditionExpression(expr.getLeft()),
parseConditionExpression(expr.getRight())],
}
"||":
return {
type: "compound",
op: "or",
operands: [parseConditionExpression(expr.getLeft()),
parseConditionExpression(expr.getRight())],
}
Identifier or PropertyAccessExpression:
return {
type: "truthinessCheck",
subject: resolveSubject(expr),
negated: false,
}
CallExpression:
// e.g., isActive(user) or Array.isArray(x)
return {
type: "call",
callee: expr.getExpression().getText(),
args: expr.getArguments().map(resolveSubject),
}
TypeOfExpression or InstanceOfExpression:
return {
type: "typeCheck",
subject: resolveSubject(expr.getExpression()),
expectedType: extractTypeName(expr),
}
_:
return null // caller wraps as opaqueKey invariant: parseConditionExpression returns null (not an opaque predicate) when it can't decompose the expression. The caller (assembleBranch) is the one that wraps null into an opaque predicate with the original source text. That keeps parseConditionExpression focused on structure, and it keeps the assembly logic in one place.
What should fall through to opaque:
- Complex expressions with side effects
- Expressions involving
awaitinside the condition - Tagged templates and other dynamic string constructions
- Conditional expressions nested inside other conditions
- Any AST node kind this function doesn't recognize
Step 4b: resolveSubject
Input: an expression node whose value is the subject of a condition Output: a ValueRef structure
Trace the expression backward through the symbol table to find where its value originated.
resolveSubject(expr):
match expr:
Identifier:
symbol = expr.getSymbol()
if symbol is null:
return { type: "unresolved", sourceText: expr.getText() }
decl = symbol.getDeclarations()[0]
if decl is ParameterDeclaration:
return {
type: "input",
inputRef: decl.getName(),
path: [],
}
if decl is VariableDeclaration:
init = decl.getInitializer()
// Unwrap await
if init is AwaitExpression:
init = init.getExpression()
if init is CallExpression:
return {
type: "dependency",
name: init.getExpression().getText(),
accessChain: [],
}
if decl is part of a BindingPattern (destructuring):
parentInit = findDestructuringSource(decl)
parentRef = resolveSubject(parentInit)
return {
type: "derived",
from: parentRef,
derivation: { type: "destructured", field: decl.getName() },
}
// Other initializer: fall through to unresolved
return { type: "unresolved", sourceText: expr.getText() }
return { type: "unresolved", sourceText: expr.getText() }
PropertyAccessExpression:
objectRef = resolveSubject(expr.getExpression())
return {
type: "derived",
from: objectRef,
derivation: { type: "propertyAccess", property: expr.getName() },
}
NumericLiteral, StringLiteral, TrueKeyword, FalseKeyword, NullKeyword:
return { type: "literal", value: parseLiteralValue(expr) }
_:
return { type: "unresolved", sourceText: expr.getText() }Why the result stays shallow: resolveSubject makes no attempt to understand what db.findById does or what it returns. It records that the value came from calling db.findById and that .repository.lastAnalyzedCommitHash was then read off it. Cross-boundary comparison needs no more than that: two predicates on either side of a boundary can be seen to test the same thing without the extractor understanding Prisma query semantics.
Dependency on the compiler: this is the most expensive step. Every identifier lookup goes through the symbol table. For a 500-line handler with 50 conditions, this can dominate extraction time. Two optimizations keep it down:
- Cache per function. Within a single function, the same variable may be tested repeatedly, so cache
Identifier → ValueReflookups by node identity. - Avoid project-wide reference search.
findReferencesAsNodes()walks the entire project and is quadratic in project size. Don't use it here;getSymbol().getDeclarations()is local and fast.
Step 5: Assembly
Compose the outputs of steps 1-4 into a list of RawBranch (assembly.ts):
extractRawBranches(func, pack):
terminals = findTerminals(func, pack.terminals)
{ byTerminal, fallthrough } = computePathConditions(func, terminals)
branches = []
for terminal in terminals:
// A terminal with no entry path is dead code: no branches.
for conditionList in byTerminal.get(terminal.node) ?? []:
branches.push({
conditions: conditionList.map(conditionInfoToRawCondition),
terminal: terminal.data,
location: terminal.data.location,
isDefault: conditionList is empty
or every condition is source earlyReturn / earlyThrow,
})
return branchesEach ConditionInfo records the condition's source text, polarity, and provenance (explicit, earlyReturn, earlyThrow, catchBlock). conditionInfoToRawCondition runs Step 4 on it (parse to Predicate, resolve subjects) and wraps whatever won't decompose as opaque.
Two post-passes follow in assembly.ts:
- Fall-through synthesis. When the pack opted in with a
functionFallthroughterminal pattern and no existing terminal covers the default path, the adapter adds a synthetic terminal whose condition lists are the paths that fall off the end of the body (pathConditions'fallthroughresult). Making the pack opt in keeps the semantics where they are declared: HTTP packs treat no-response as a gap, and React event handlers treat an implicit return as normal. - Effect attachment. Every call in the body, wherever it is written, becomes an invocation effect, and it attaches to each branch whose path runs it: the call's preconditions must be true on the branch, and the call's statement must come before the branch's terminal (or the call runs on every path, as a
finallybody does). A call that is itself a terminal, or a link in a terminal's receiver chain, is dropped, since the terminal already records it. Recognizer-typed effects attach by the same two tests.
The RawBranch[] then flows to assembleSummary() in @suss/extractor, which handles the opaque-wrapping, gap detection, confidence scoring, and expectedInput pass-through. The steps here are the adapter's side of that handoff.
Step 5b: Client field tracking
For client code units (discovered via clientCall), the adapter runs an additional step after branch extraction: trace which properties the consumer reads from the response variable within each branch.
collectClientFieldAccesses(callExpr, func, branchLocations):
responseVar = findResponseVariable(callExpr)
// e.g. "const res = await fetch(...)" → responseVar = "res"
for each branch:
subtree = findBranchSubtree(func, branch.location)
accesses = collectPropertyAccesses(subtree, responseVar)
// e.g. res.body.name → ["body", "name"]
// res.body.email → ["body", "email"]
// Keep only the body-related accesses, dropping status/ok/headers
bodyAccesses = accesses.filter(not status/ok/headers)
branch.expectedInput = buildShapeFromPaths(bodyAccesses)
// → { type: "record", properties: { body: { type: "record",
// properties: { name: { type: "unknown" }, email: { type: "unknown" } } } } }expectedInput flows through RawBranch to assembleSummary to Transition.expectedInput, where the checker's checkBodyCompatibility compares it against the provider's output body shape. Every leaf type is unknown, because the adapter records which fields a branch reads and stops there. The comparison therefore checks only whether each field is present.
Testing strategy
The five steps correspond to five independently testable units:
| File | Tests |
|---|---|
terminals.test.ts | Fixture handlers in ts-rest / Express / React Router styles; assert the expected terminal nodes are found with correct extracted data. |
paths/pathConditions.test.ts | Fixture functions with guards, nesting, switch, loops, try/catch, break/continue, and declined shapes. Assert each terminal's per-path condition lists (and the degraded form for declined shapes). |
conditions.test.ts | The expression-level walker: ternaries, &&/` |
predicates.test.ts | Individual expression nodes (not full functions). Assert the parsed Predicate. One test per AST expression kind. |
subjects.test.ts | Fixture functions with parameter access, dependency call results, destructuring, property chains. Assert the resolved ValueRef. |
Each test uses its own small fixture, and unit tests never run end to end. Full extraction integration tests live in three places: the adapter's own integration test (packages/adapter/typescript/src/*.test.ts against fixtures/ts-rest), each framework pack's integration test (the adapter run against fixtures for that framework), and the CLI test suite (deep-equal assertions on representative summaries per framework, plus -o round-trip).
Beyond fixtures, a differential fuzzer (tools/differential) checks the correctness principles below mechanically. It extracts generated handler programs through the shipping pipeline, runs them against batteries of requests, and shrinks any disagreement between the summary's claims and the observed behavior down to a minimal counterexample. A construct with a documented soundness gap runs an inverted property that has to keep rediscovering the gap until somebody fixes it. See the differential-fuzzing record.
Correctness principles
Three properties must hold for the algorithm to be trusted:
- Exhaustiveness. Every path through the function body maps to exactly one
RawBranch. If it doesn't, the missing path becomes a gap, and it is never dropped silently. - No false conditions. A predicate that appears on a transition must actually gate that transition in the source code. Under-specifying is fine, because the condition falls back to opaque. Reporting a condition that isn't there is not.
- Stable subjects across renames.
ValueRefs should be structurally equal across mechanical renames. If a user renamesusertoaccount, the subject should still resolve todependency("db.findById")plus the same property path, unchanged.
Violations of #1 degrade confidence but don't invalidate the summary. Violations of #2 or #3 are bugs and must be fixed.