IR types
The types below come from two packages. @suss/behavioral-ir owns the summary, the transition and the finding; @suss/ir-core owns the primitives every suss IR shares, which are the boundary binding, the type shape, the source location and the confidence block. Both are generated from zod schemas, and the JSON Schema the package publishes is generated from those at build time.
Install @suss/behavioral-ir (one peer dependency on zod) and call parseSummaries(json) to validate and narrow in one step, or safeParseSummaries(json) to handle errors without throwing. From another language, validate against the published behavioral-summary.schema.json.
The summary
BehavioralSummary
interface BehavioralSummary {
schemaVersion?: number;
kind: CodeUnitKind;
location: SourceLocation;
identity: CodeUnitIdentity;
inputs: Input[];
transitions: Transition[];
gaps: Gap[];
confidence: ConfidenceInfo;
definitions?: Record<string, TypeShape>;
inputReads?: Array<{ input: string; path: string[] }>;
metadata?: Record<string, unknown>;
}One of these per code unit. It is flat JSON with no cycles, apart from the recursive ValueRef, Predicate, TypeShape and RenderNode trees.
schemaVersionis absent on a summary written by 0.3.x, which the parsers read as version 1. Version 6 is current. Summary format lists what each version changed.definitionsis the table arefshape points into, keyed the waytypeDefinitionKeyin@suss/ir-corebuilds a key.withDefinitionsInlinedputs a definition back into a shape, and every comparison reads the inlined form.inputReadsrecords what the unit read out of the values it was given, once each, as an input name and a property path.metadatatakes framework-specific data that does not fit the universal structure. Keys are namespaced by boundary semantics: HTTP-scoped entries go undermetadata.http.*, GraphQL undermetadata.graphql.*. Semantics-neutral keys such asmetadata.derivedFromWrapperstay at the top level. A tool that does not need any of it can ignore the field.
CodeUnitKind
type CodeUnitKind =
| "handler" // HTTP request to response
| "loader" // React Router / Next.js loader
| "action" // React Router / Next.js action
| "component" // React / Vue / Svelte component
| "hook" // custom React hook
| "middleware" // Express middleware
| "resolver" // GraphQL resolver
| "consumer" // message consumer
| "client" // API client call site
| "worker" // background worker, scheduled task
| "library" // function reachable through a package's exports
| "caller" // function that calls into another package's exports
| "module-init" // what a module does when it is first imported
| "scheduled-callback"; // a function handed to setTimeout or a library hookThe kind decides how inputs arrive and what counts as output. A handler takes a request and produces a response. A component takes props and state and produces a UI tree. A consumer takes a message and produces effects.
consumer against client. Both sit downstream of a boundary, but suss models them differently. A consumer receives a message and produces effects. A client makes a request, branches on the response status and reads fields off the body, so a client's transitions have expectedInput on them where a consumer's have effects. The checker applies different rules to each.
library and caller are the two sides of an in-process function-call boundary. The packageExports discovery variant produces the library side, one per function reachable through the package's package.json entry points. The packageImport variant produces the caller side, one per (enclosing function, consumed binding). They pair by fn:<package>::<exportPath>.
module-init is what a source file does at import time, one per file, and it is always a consumer: it reads channels other units declare. scheduled-callback is a function the runtime calls at some later point, and what it reaches is recorded on its own summary.
The union is closed. A pack cannot invent a kind, because each kind comes with assumptions the rest of extraction makes, and a new framework that needs a new kind needs an IR change first.
SourceLocation and CodeUnitIdentity
interface SourceLocation {
file: string;
range: { start: number; end: number }; // line numbers, for a person
span?: { start: number; end: number }; // character offsets, for joins
exportName: string | null; // null for an anonymous function
workspace?: string;
}
interface CodeUnitIdentity {
id?: string;
name: string;
nameKind?: "binding" | "label";
exportPath: string[] | null; // null for unexported code
boundaryBinding: BoundaryBinding | null;// null for unbound code
deployableUnit?: DeployableUnit;
}
interface DeployableUnit {
deploymentTarget: "lambda" | "ecs-task" | "container" | "k8s-deployment" | "worker";
instanceName: string;
}Location tells you where the code is. Identity tells you what it is, whatever file somebody moves it to. Move services/api/handlers/users.ts to services/api/routes/users/index.ts and the location changes, while the identity, a getUser handler bound to GET /users/:id, stays the same.
rangeandspan. The line range is for a reader and an editor link. The character span is what identity and joins measure with, because two functions can share a line and never an offset range. A summary with no source position behind it, such as one read from a contract artifact, has nospan.workspaceis the name the project being extracted calls itself. Paths are relative to wherever the extract ran, so two services in one repository both reportsrc/handlers.ts, and the workspace is what tells them apart once their summaries are merged.idis workspace, file and export path together. A name on its own collides with other names too often to identify a unit.nameKindrecords where the name came from."binding"means other code can call the unit by it."label"means discovery coined it, and a label is never used as a binding.exportPathis an array, because deep module namespaces (namespace.submodule.getUser) turn up in some frameworks and an array is easier to compare than a dotted string.boundaryBindingis nullable on purpose. A utility function or an internal helper doesn't take part in any contract. An explicitnullforces a consumer to handle that case.deployableUnitrecords what runs this code, where the pack can work it out: the Lambda's logical id from a SAM template, or the container or deployment name elsewhere. The checker joins on it. Several Lambdas can each subscribe to one channel with their own handler, and the channel alone doesn't tell you which handler runs where. The field is optional, because a React component or a library export is never deployed on its own, so there is nothing to record.
Boundaries
BoundaryBinding
interface BoundaryBinding {
transport: string; // the wire: "http", "aws_sqs", "in-process", "os"
semantics: Semantics; // the pairing rule, discriminated on `name`
recognition: string; // which pack matched, or "reachable"
}recognition: "reachable" marks a unit found through the transitive closure of a unit a pack recognised. No pack pattern matched it directly. Such a unit has no pairing identity, so nothing cross-checks it, and its transitions and effects are extracted in full.
Each protocol is one module under packages/ir-core/src/semantics, carrying its own schema, its pairing key and its agreement rule:
type Semantics =
| { name: "rest"; method: string | null; path: string | null;
declaredResponses?: number[] }
| { name: "function-call"; module?: string; exportName?: string;
package?: string; exportPath?: string[] }
| { name: "graphql-resolver"; typeName: string | null; fieldName: string }
| { name: "graphql-operation"; operationName?: string;
operationType: "query" | "mutation" | "subscription" }
| { name: "runtime-config"; deploymentTarget: string; instanceName: string }
| { name: "storage"; storageSystem: string; scope: string;
container: string | null; accessPath: string | null }
| { name: "message-bus"; messageBus: MessageBus; channel: string | null }
| { name: "metric"; metricSystem: string; metricType: string | null }
| { name: "unit-invocation"; deploymentTarget: string;
instanceName: string | null };
type MessageBus =
| "aws_sqs" | "aws.sns" | "s3" | "eventbridge"
| "aws_kinesis" | "aws_firehose" | "gcp_pubsub"
| "bullmq" | "kafka" | "nats"
| "cloudflare-queues" | "cloudflare-cron" | "cloudflare-tail";An identity field is null when the source does not say what it is. The empty string is invalid there, because one empty string agrees with every other. REST's method also takes "*", for a handler that serves every method, and it pairs with whatever method each consumer uses.
runtime-config and unit-invocation both take their two fields from DeployableUnit, because the pairing key is a deployed unit, and one deployed unit has both of those boundaries on it. unit-invocation makes instanceName nullable, since only the provider side always has the name.
Storage's container and accessPath use the boundary-name syntax: a literal (orders-v1), a pattern with deploy-time holes ({stage}-orders-v1), or a reference to somewhere else that has the value ({ORDER_TABLE}). You can tell the three apart by the braces. parseBoundaryName in @suss/ir-core is the one reader of that syntax, and the package's README covers the three forms. A REST path also uses braces, and a route parameter stops at the / between segments, so the two conventions stay apart.
Boundary semantics covers each variant's pairing rule and the builder helpers packs use.
Behavior
Transition
interface Transition {
id: string;
conditions: Predicate[]; // AND-joined; all must hold
output: Output;
effects: Effect[];
location: { start: number; end: number };
isDefault: boolean;
confidence?: ConfidenceInfo;
expectedInput?: TypeShape;
metadata?: Record<string, unknown>;
}One transition records that when all of these conditions hold, this output comes out and these effects fire. A unit's behavior is its set of transitions, and every execution path through the unit maps to exactly one of them. Where the set is not exhaustive, the hole is recorded as a Gap.
conditionsis a flat AND.ORcomposition happens inside aPredicate, through thecompoundvariant. Two transitions then have the same precondition when their condition lists are structurally equal, with nothing else to work out.isDefaultmarks the fall-through case, the one with no explicit conditions or only early-return guards. Most handlers have one. Some have none, because every path is gated, and some have several, one per early return.idis content-addressed. It reads${functionName}:${terminalKind}:${statusKey}:${hash7}, where the hash is the first seven hex characters of a SHA-1 over the ordered condition chain's canonical source text.diffSummariesthen survives branch reordering, while changing a status, a condition or a terminal kind gives you a new id. Condition order is part of the identity, because short-circuit evaluation makesa && bandb && abehave differently. Source offsets are left out, so reformatting a file doesn't change an id.expectedInputis set onclienttransitions. After branching on a status the client reads fields off the body,result.body.nameand the like, and those reads are collected into aTypeShapesaying what this client expects for this status. The checker compares it against the provider's body:
{
"conditions": [{ "type": "comparison", "left": { "...": "result.status" },
"op": "eq", "right": { "type": "literal", "value": 200 } }],
"output": { "type": "return", "value": { "type": "record",
"properties": { "name": { "type": "text" } } } },
"expectedInput": {
"type": "record",
"properties": {
"name": { "type": "text" },
"email": { "type": "text" }
}
}
}The client reads body.name and body.email, so a provider 200 without email is a mismatch. The field is absent on provider transitions, and on client transitions where the field tracking could not resolve the accesses.
Predicate
type Predicate =
| { type: "nullCheck"; subject: ValueRef; negated: boolean }
| { type: "truthinessCheck"; subject: ValueRef; negated: boolean }
| { type: "comparison"; left: ValueRef; op: ComparisonOp; right: ValueRef }
| { type: "typeCheck"; subject: ValueRef; expectedType: string }
| { type: "propertyExists"; subject: ValueRef; property: string; negated: boolean }
| { type: "compound"; op: "and" | "or"; operands: Predicate[] }
| { type: "negation"; operand: Predicate }
| { type: "call"; callee: string; args: ValueRef[] }
| { type: "opaque"; sourceText: string; reason: OpaqueReason };
type ComparisonOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
type OpaqueReason =
| "complexExpression" | "externalFunction"
| "dynamicValue" | "unsupportedSyntax";Each variant has exactly the fields it needs.
truthinessCheckagainstnullCheck. JavaScript'sif (x)tests truthiness, which is a different test fromx != null, since0,""andfalseare falsy too. A nullness check is about whether the value is there at all. Treating the two as one condition would pair up branches that guard against different things.compoundagainstnegation.compoundcoversandandor, and leavesnotto its own variant. Anotof anotcollapses to the operand, and keeping negation separate makes that rewrite straightforward.compoundtakes any number of operands, so there is no separate binary form.opaqueis a normal variant. When the extractor cannot take a condition apart, it keeps the source text and records why. A downstream tool then knows the branch exists and decides for itself how to treat it.
Output
type Output =
| { type: "response"; statusCode: ValueRef | null; body: TypeShape | null;
headers: Record<string, ValueRef> }
| { type: "throw"; exceptionType: string | null; message: string | null }
| { type: "render"; component: string; props?: Record<string, unknown>;
root?: RenderNode }
| { type: "return"; value: TypeShape | null }
| { type: "delegate"; to: string }
| { type: "emit"; event: string; payload?: TypeShape }
| { type: "void" };What a terminal produces. The pack decides which variants matter for its framework; the union itself is framework-agnostic.
responseis an HTTP response.statusCodeis aValueRef, because the status can be dynamic (res.status(code).json(...)). A literal 200 arrives as{ "type": "literal", "value": 200 }.throwkeeps the constructor expression as text ("HttpError.NotFound"), because the class itself cannot be resolved statically in general.renderis a component render result.componentis the root element's name.rootis the whole tree, filled in by packs that understand their language's render form, so a checker can compare structural output against a contract source such as a Storybook story.returnis a plain return.valueis aTypeShape, because for a hook or a utility the shape is the contract.delegatepasses control on, to the next middleware or handler.tois a symbolic name such as"next".emitputs an event or a message on a channel.voidis an explicit void return, or a fall-through producing nothing observable.
RenderNode
type RenderNode =
| { type: "element"; tag: string; attrs?: Record<string, string>;
target?: { file: string; name: string }; children: RenderNode[] }
| { type: "text"; value: string }
| { type: "expression"; sourceText: string }
| { type: "conditional"; condition: string;
whenTrue: RenderNode; whenFalse: RenderNode | null };The tree under Output.render.root. attrs gives attribute values as source text, quotes included, and a boolean shorthand such as <input disabled> maps to the empty string. target records where the tag's component is declared, when the walk managed to resolve it, using the file and name that child's summary is keyed on. A checker can then join this render edge to that summary. A host tag leaves target unset. On a conditional, a null whenFalse covers both {cond && <X/>} and an else written as null, since React renders nothing either way.
Effect
type Effect =
| { type: "mutation"; target: string; operation: "create" | "update" | "delete" }
| { type: "invocation"; callee: string; args: unknown[]; async: boolean;
summary?: string; declaredAt?: DeclarationPlace;
argsDeclaredAt?: Record<string, DeclarationPlace>;
argsSummary?: Record<string, string>;
calleeParameter?: number;
preconditions?: Predicate[] }
| { type: "emission"; event: string; payload?: unknown }
| { type: "stateChange"; variable: string; newValue?: unknown }
| { type: "interaction"; binding: BoundaryBinding; callee?: string;
groupId?: string; preconditions?: Predicate[];
interaction: Interaction };
interface DeclarationPlace {
file: string;
span: { start: number; end: number };
}What a transition does besides producing a value. There are two layers.
Coarse effects (mutation, invocation, emission, stateChange) record that something happened: a call fired, a state variable was set, an event went out. They are there for impact analysis, the kind where you want to say "this change edits a handler that writes users, and here is who reads users".
On an invocation, summary is the summary this call reaches, the one whose unit is declared where the type checker resolved callee. It is absent when the callee is declared outside the run, when more than one summary describes that unit, or when nothing resolved and no summary in the same file has that name. declaredAt and argsDeclaredAt are the raw declaration places an adapter sets while extracting; naming turns them into summary and argsSummary and removes them, so you see them only in the extraction cache. calleeParameter is set when the callee is one of this unit's own parameters, and gives its index among them, so a caller that passes a function into that parameter reaches this call through it.
interaction effects are the typed boundary crossings. Each one includes the BoundaryBinding of the thing it talks to, plus a payload discriminated on class:
type Interaction =
| { class: "storage-access"; kind: "read" | "write"; fields: string[];
selector?: string[]; operation?: string;
relationPath?: string[]; relationKey?: boolean }
| { class: "service-call"; method: string; payload?: unknown;
responseShape?: TypeShape }
| { class: "message-send"; body?: unknown; routingKey?: string }
| { class: "message-receive"; body?: unknown }
| { class: "unit-invoke"; payload?: unknown }
| { class: "config-read"; name: string; defaulted: boolean }
| { class: "schedule"; via: string; hasDelay: boolean;
callbackRef:
| { type: "literal" }
| { type: "identifier"; name: string }
| { type: "opaque"; reason: string } };storage-accesscovers Prisma calls, Drizzle queries, ActiveRecord chains and raw SQL. It pairs against a storage provider on(storageSystem, scope, container, accessPath).relationPathis the relation fields the access travelled through from the container in the binding; only the provider's contract says where a relation points, so the pairing pass resolves the path and moves the access to the container it arrives at.relationKeymarks an access whose columns come from the contract's own declaration for the last relation in the path, instead of from columns the call spells out. A Prismaconnectis the usual case.service-callcovers fetch, axios, a ts-rest client, an Apollo client. It pairs against a REST or GraphQL provider.message-sendcovers SQS, Kafka and BullMQ producers. It pairs against a message-bus consumer on(messageBus, channel).message-receiveis the fields a consumer pulls out of a message. It doesn't state a channel, because a handler signature never tells you which channel it is for, so the checker reads the channel off the enclosing summary's binding instead.unit-invokeis calling a deployed unit by name. It pairs on(deploymentTarget, instanceName), once the invoking unit's environment resolves a name that only exists at deploy time.config-readis aprocess.env.Xaccess or its equivalent. It pairs against a runtime-config provider on the variable name plus the code scope.scheduleis a callback handed tosetTimeout,process.nextTickor a library hook. Nothing pairs against these, so the enclosing binding usesfunction-callsemantics and the interaction is there for dataflow and forinspect.hasDelayrecords only that a delay argument was passed, without its value.
Adding a class is an additive IR change. Each class maps one to one onto a binding.semantics.name by convention; the IR does not enforce that and every shipped recognizer follows it. See Pack patterns for the recognizers that emit them.
preconditions on invocation and interaction are the ancestor conditions gating the effect within its transition. They are absent for a call that always fires.
Input
type Input =
| { type: "parameter"; name: string; position: number;
role: string | null; shape: TypeShape | null }
| { type: "injection"; name: string; mechanism: string; shape: TypeShape | null }
| { type: "hookReturn"; hook: string; destructuredFields: string[] }
| { type: "contextValue"; context: string; accessedFields: string[] }
| { type: "closure"; name: string };How values reach a code unit. An HTTP handler usually has only parameter inputs; the rest are mostly for components and hooks.
role records what the parameter means to the framework ("request", "response", "pathParams", "requestBody"), and InputMappingPattern in the pack sets it. When suss cannot work out a parameter's role, it writes null and records a gap saying why. A role often depends on something the reader had to work out first. If the route's path went unread, for instance, there is no way to separate a path parameter from a query parameter.
const [user, setUser] = useUser() produces a hookReturn input with destructuredFields: ["user", "setUser"]. The React pack discovers components, hooks and event handlers and fills these in.
Gap
interface Gap {
type: "unhandledCase" | "unreadOutcome" | "unfollowedCall";
conditions: Predicate[];
consequence: "frameworkDefault" | "implicitThrow" | "fallthrough" | "unknown";
description: string;
callee?: string;
}Something the summary could not account for. The run keeps going and the gap goes into the output, so you can see that a case exists even where suss could not work out what happens in it.
unhandledCase is about the code. The declared contract lists a response no transition produces, or a transition produces a status the contract never declared. The checker turns each one into a providerContractViolation at error severity.
unreadOutcome is about how suss read the code. A return didn't match any of the terminal shapes the pack looks for:
{
"type": "unreadOutcome",
"consequence": "unknown",
"description": "One return in this function matches none of the terminal shapes this pack looks for, so what it produces is not described here"
}The handler may well be responding correctly, in a form nobody has taught the pack yet, so the checker reports lowConfidence at info severity. Teach the pack that terminal shape and the gap goes away.
unfollowedCall is the other one about how suss read the code. The walk met a call it could not resolve to a function with a body, so whatever runs behind it is missing from this summary and from every effect derived from it. callee gives the call as the source writes it:
{
"type": "unfollowedCall",
"consequence": "unknown",
"description": "The call to db.findById lands on a declaration with no body, so whatever runs there is missing from this summary",
"callee": "db.findById"
}A call into a dependency is unfollowable too, and suss doesn't record a gap for it. The run already describes that as a boundary crossing, and a gap on every JSON.parse would bury the stops you can actually do something about. So a gap here always means a call whose callee the project itself declares. suss inspect prints these under Could not follow:.
consequence records what happens in the unhandled case. frameworkDefault means the framework produces something (Express serves a 500 page). implicitThrow means an unhandled rejection propagates up. fallthrough means control passes to the next middleware or handler. unknown means the extractor could not tell.
Values and shapes
ValueRef
type ValueRef =
| { type: "input"; inputRef: string; path: string[] }
| { type: "dependency"; name: string; accessChain: string[] }
| { type: "derived"; from: ValueRef; derivation: Derivation }
| { type: "literal"; value: string | number | boolean | null }
| { type: "state"; name: string }
| { type: "unresolved"; sourceText: string };
type Derivation =
| { type: "propertyAccess"; property: string }
| { type: "methodCall"; method: string; args: string[] }
| { type: "destructured"; field: string }
| { type: "awaited" }
| { type: "indexAccess"; index: string | number };A reference to a value inside a code unit. Each variant records where the value came from, without interpreting it.
input, a function parameter.inputRefis the parameter name andpathis the property chain from there (args.params.idbecomes{ inputRef: "args", path: ["params", "id"] }).dependency, the result of a call.nameis the callee expression as text ("db.findById"), andaccessChainis any property access before the value is tested.derived, a composed reference.fromis the parent andderivationis how this one was produced. That pairing is what makes aValueRefa tree:container.repository.lastCommitis aderivedof aderivedof adependency.literal, a constant, usually the right-hand side of a comparison.state, component state, a closure variable or a module-level variable.unresolved, the extractor could not work out where the value came from. It keeps the source text, so the reference still shows you what was tested.
The structure is shallow on purpose. Two predicates that both test the result of db.findById(id).deletedAt have to be recognizable as the same subject even though nothing here knows what findById does. Shallow references survive a mechanical rename, cost nothing to compute, and mean the same thing in another language: Python's db.find_by_id(id) produces an analogous ValueRef.
TypeShape
type TypeShape =
| { type: "record"; properties: Record<string, TypeShape>;
spreads?: Array<{ sourceText: string }> }
| { type: "dictionary"; values: TypeShape }
| { type: "array"; items: TypeShape }
| { type: "literal"; value: string | number | boolean; raw?: string }
| { type: "text" }
| { type: "integer" }
| { type: "number" }
| { type: "boolean" }
| { type: "null" }
| { type: "undefined" }
| { type: "union"; variants: TypeShape[] }
| { type: "ref"; name: string; def?: string; from?: string }
| { type: "unknown" };Enough structure to describe a response body or a return value, without reproducing a whole type system. The Python adapter produces these same shapes. Serializing each language's compiler types instead would leave nothing that compares across languages.
Record against dictionary. A record is a closed struct with a fixed set of named fields. A dictionary is an index signature (Record<string, T>), where the key set is open and every access returns a T. A comparison has to keep them apart, because a dictionary accepts any key and a record accepts only the ones listed.
ref is the escape hatch. It means the extractor got as far as "this is typed as User" and did not inline the definition. A name on its own does not identify a type: two modules can each declare a User, and every instantiation of one generic reports the generic's own name, so Omit<User, "secret"> and Omit<Order, "total"> are both Omit. from says which file declares the type, when the project declares it, and def is the key into the summary's definitions table, built from what the type actually is. A name the language or a dependency owns has neither.
What crosses the wire
TypeShape describes values as they cross a serialization boundary: HTTP bodies, messages on a queue, return values a caller reads. It is not the in-memory type system of the source language, and two things follow from that.
Numeric precision. JavaScript's number is an IEEE 754 double. Integers past Number.MAX_SAFE_INTEGER, high-precision decimals, hex and scientific notation, and underscore separators all lose information through it. A numeric literal keeps the exact source text in raw, so a consumer that needs the precision never has to guess. Strings and booleans round-trip and have no raw.
Types with no wire form. BigInt, Date, Map, Set, Buffer, a regex: none of these has a canonical JSON representation. The extractor surfaces them as ref with the declared name. What goes on the wire is up to the producer and consumer: a Date may be an ISO string from toJSON, an epoch number, or absent.
undefined. It is modelled for source fidelity, for optional fields and explicit undefined returns, and JSON omits it. A record with email: undefined serializes to a body where email is absent, so a contract checker should treat { value: T | undefined } and { value?: T } as the same thing at the wire boundary.
Codecs are out of scope for v0. The IR describes what data flows, without recording which codec produced it, so all it can tell you is that a Date-shaped thing was referenced. A future direction is an explicit serde variant such as { type: "serialized"; wire: TypeShape; reconstructs: TypeShape }, which would let a checker ask whether a producer writing an ISO string satisfies a consumer expecting a Date. Until then the two sides agree on a codec out of band and the IR treats a ref as opaque past the name.
ConfidenceInfo
interface ConfidenceInfo {
source: "inferred_static" | "inferred_ai" | "declared" | "derived";
level: "high" | "medium" | "low";
corroboration?: Corroboration;
}
interface Corroboration {
outcome: "observed" | "refuted" | "untested";
runs: number;
counterexample?: unknown; // present when refuted
reason?: string; // present when untested
}How much of the behavior suss read, and where the claim came from.
inferred_static, structural analysis of the source, which is the common case.inferred_ai, reserved for LLM-assisted labels on opaque predicates. Nothing writes it today.declared, a summary somebody wrote by hand, such as a stub for a dependency suss cannot read.derived, produced by a contract source such as an OpenAPI document.
A return the pack could not read sets the level to low on its own, and suss applies that rule before any other. A function whose returns all went unread has no conditions either, and counting zero opaque predicates out of zero would otherwise come out high. Where no return went unread, the level is the share of predicates that came out opaque: 0 gives high, under half gives medium, and half or more gives low.
corroboration is what came of running the code with suss corroborate. Inputs satisfying the claim's own conditions are generated and run through the function, and the observation either agreed every time (observed), disagreed at least once (refuted, with the input that disagreed), or never produced a verdict (untested, with the reason). Corroboration adds evidence to a derivation and never rewrites the derived claim.
The checker does not change a finding's severity because of confidence, though a tool reading the findings is free to. A high-confidence mismatch is almost certainly a bug your users will hit. A low-confidence one is worth a look before you break the build over it.
Findings
Finding
interface Finding {
kind: FindingKind;
boundary: BoundaryBinding;
provider: FindingSide;
consumer: FindingSide;
description: string;
severity: "error" | "warning" | "info";
aspect?: BoundaryAspect;
sources?: string[];
suppressed?: FindingSuppression;
}
interface FindingSide {
summary: string; // "src/handlers/users.ts::getUser"
transitionId?: string; // set when the finding is about one branch
location: SourceLocation;
}
interface FindingSuppression {
reason: string;
effect: "mark" | "downgrade" | "hide";
originalSeverity?: "error" | "warning" | "info";
}
type BoundaryAspect =
| "read" | "write" | "send" | "receive" | "construct" | "selector";What the pairwise checker emits. The findings catalog lists every kind with its severity, when it fires and what to do about it; FindingKindSchema in packages/behavioral-ir/src/schemas.ts is the authoritative enumeration.
- Both sides are always named. Even when only the provider is at fault, as with
providerContractViolation, the finding still points at a consumer summary, often the same one, used as the pairing anchor. Tooling can then attribute every finding to a pairing. aspectrecords which side of a field the finding concerns.sendandreceiveare a payload's two directions,constructis a scenario setting an input, andselectoris a query'swhereclause. It is absent where the aspect is irrelevant or spans several.sourcesis set when the dedupe pass collapses identical findings from several providers. Each entry matches aFindingSide.summary, so a tool can surface every contributor without re-running the checker.suppressedis set when a.sussignorerule matched.markkeeps the finding visible and drops it from the exit code,downgradedrops the severity one level and still counts it,hidekeeps it out of both and survives only in the JSON. See Accept a finding.
Findings live in @suss/behavioral-ir, because diff viewers, aggregation layers and other downstream tools read findings the same way they read summaries, and none of them should have to depend on @suss/checker to do it.
Severity drives the exit code. suss check exits non-zero when any error finding is present; --fail-on warning or --fail-on info raises the gate. See Exit codes.
RunFinding
interface RunFinding {
kind: "nothingPaired" | "unreadableInput" | "mostlyUnpaired";
severity: "error" | "warning" | "info";
description: string;
remedy: string;
}A problem with the run rather than with a boundary. A boundary finding points at two sides that disagree, and a run finding has no two sides to point at, so these go in their own list under run, each with a remedy telling you what to do next.
SummaryDiff
interface SummaryDiff {
addedTransitions: Transition[];
removedTransitions: Transition[];
changedTransitions: Array<{ before: Transition; after: Transition }>;
}What diffSummaries returns for one unit. A transition counts as changed when its id survives and its content moved. That is the reason transition ids leave source offsets out.
The adapter interface
RawCodeStructure
An adapter reads source and produces this; the extractor turns it into a BehavioralSummary. It is defined in @suss/extractor, because it is a boundary inside the pipeline and it changes more often than the published output does.
interface RawCodeStructure {
identity: {
name: string; nameKind?: "binding" | "label"; kind: CodeUnitKind;
file: string; range: { start: number; end: number };
span?: { start: number; end: number };
exportName: string | null; exportPath: string[] | null;
};
boundaryBinding: BoundaryBinding | null;
parameters: RawParameter[];
branches: RawBranch[];
dependencyCalls: RawDependencyCall[];
declaredContract: RawDeclaredContract | null;
// plus optional fields an adapter fills in where it can: the wrappers
// registered around the unit, a GraphQL document as written, the body
// accessors a client reads through, and the readings it passed along
// uncollapsed. `packages/extractor/src/index.ts` has them all.
}
interface RawBranch {
conditions: RawCondition[];
terminal: RawTerminal;
effects: RawEffect[];
location: { start: number; end: number };
isDefault: boolean;
expectedInput?: TypeShape | null;
}Every field is plain JSON: no AST nodes, no compiler types. Three things follow from that split.
- The extractor can be tested without a compiler. Hand-written
RawCodeStructurevalues drive its whole test suite in milliseconds. - One place decides the hard parts. Opaque wrapping, gap detection, confidence, and the final summary structure are all decided in the extractor, so no adapter reimplements them.
- Adding a language is adding an adapter. The Python adapter produces the same structure, and nothing in the extractor depends on which compiler filled it in.
The extraction algorithm covers how the TypeScript adapter produces one from source files.