Architecture
suss extracts behavioral summaries from source code: structured descriptions of what each unit of code produces, under what conditions, with what side effects. The summary is the product. Everything downstream, the checkers and the query layers, reads summaries without knowing whether the source was TypeScript, Python or Ruby.
Take this ts-rest handler:
export const getUser = async ({ params }: { params: { id: string } }) => {
const user = await db.findById(params.id);
if (!user) {
return { status: 404, body: { error: "not found" } };
}
return { status: 200, body: user };
};It becomes two transitions. One returns 404 when user is null. The other returns 200 with a User body. Each transition records the condition that gates it and the output that follows, in a form the checker can compare against the contract on the other side.
The terms used here, code unit, boundary, terminal, transition, predicate, subject, output, effect, gap, recognizer, sub-unit, pack, confidence, have one definition each in the Glossary.
What counts as a boundary
The example above is HTTP, and suss treats a boundary generally: anywhere code meets something whose other side might disagree with it. A package export is a boundary too. You publish parseConfig(input: string), somebody imports it, and the consumers are every call site in every package that imports it. The machinery is the same either way. suss discovers the producer, discovers the consumers, extracts behavior from both, pairs the two sides and compares them. Because every summary comes out in one format, the comparisons compose, and the checker never has to ask which framework produced its inputs.
Data flow
Extraction is a straight line with one intermediate data structure, RawCodeStructure, between the layer that touches the AST (the adapter) and the layer that assembles summaries (the extractor):
Pipelines traces each CLI command through this end to end.
The extractor never sees an AST node. It works on RawCodeStructure, which is plain data, and three things follow from that:
- The extractor is testable on hand-written input.
assembleSummary(raw)is a pure function, so its tests need no compiler and run in under 50ms. - Adding a language means writing an adapter. The new adapter produces
RawCodeStructureand the extractor does not change. That is how Python and Ruby arrived. - Pack authors touch neither. A pack describes patterns as data.
Packages and what each owns
@suss/ir-core shared IR primitives: TypeShape, boundary
│ bindings + boundaryKey, source locations,
│ confidence. Both IRs build on this; neither
│ depends on the other.
│
├─ @suss/intent-ir team-authored intent IR: IntentDoc (authoring),
│ │ IntentSummary (checkable form), IntentFinding.
│ │
│ ├─ @suss/contract-intent *.intent / *.prd reader → IntentSummary
│ │
│ └─ @suss/checker-intent intent ↔ code checker; also consumes
│ behavioral summaries.
│
├─ @suss/values a bounded evaluator: what the source pins down
│ about a value, with a hole for the rest
│
@suss/behavioral-ir zod schemas, types, parsers. Install this to
│ consume summaries.
│
├─ @suss/extractor assembly engine + PatternPack interface.
│ │ No AST access.
│ │
│ ├─ @suss/adapter-typescript ts-morph-based extraction; runs
│ │ │ whole-program passes as rules
│ │ ├─ @suss/datalog small Datalog evaluator (facts,
│ │ │ rules, stratified negation)
│ │ └─ @suss/resolution language-neutral rules for following
│ │ a value to the function it comes
│ │ down to
│ │
│ ├─ @suss/adapter-python, @suss/adapter-ruby tree-sitter parsers
│ │ emitting the same RawCodeStructure
│ │
│ ├─ @suss/recognize the pack vocabulary, plus @suss/sql
│ │ for reading which tables a statement
│ │ touches
│ │
│ └─ @suss/packs every framework, client and runtime
│ pack in one install. The catalog is
│ generated: see /packs/catalog
│
├─ @suss/contract-* external spec → BehavioralSummary
│ (openapi, graphql, cloudformation,
│ appsync, serverless, terraform,
│ wrangler, prisma, storybook,
│ aws-apigateway)
│
├─ @suss/checker pairwise cross-boundary checker, over the
│ │ serialized IR
│ │
│ @suss/cli the dispatch point: loads both artifact streams
│ │ (behavioral + intent) and sends each to its
│ │ checker
│ │
│ @suss/mcp the same facts over MCP, for a coding agentDependency rules
@suss/ir-core: one peer dependency onzod. The primitives both IRs share (TypeShape,BoundaryBindingplusboundaryKey,SourceLocation,ConfidenceInfo) and the comparison primitives both checkers share (bodyShapesMatch). Intent and behavior describe boundaries the same way because they build on this; neither IR depends on the other.@suss/behavioral-ir: one peer dependency onzod. The runtime validators (parseSummaries,safeParseSummaries) and the generated JSON Schema all come from the zod schemas. This is what a downstream consumer installs.@suss/intent-ir: depends onir-coreonly. The authoring schema (IntentDoc), the checkable form (IntentSummary), andIntentFinding, which is deliberately not the behavioralFinding: a behavioral finding is a two-sided peer comparison, and an intent finding is one-sided coverage.@suss/contract-intent: reader for*.intentand*.prdfiles. Unlike the othercontract-*readers it producesIntentSummary. Intent is its own artifact stream, and the checker compares it against behavior.@suss/checker-intent: depends on both IRs, since it compares them, plusir-core. It exposes one pure function,checkIntentAgreement(intents, code), returning findings plus the checked and unchecked accounting. It is a peer of@suss/checkerrather than a dependency of it.@suss/extractor: depends only on the IR. DefinesRawCodeStructureandPatternPack. Never imports ts-morph or any compiler API.@suss/adapter-typescript: depends on the IR, the extractor, ts-morph,@suss/datalogfor its whole-program passes,@suss/resolutionfor the rules those passes join on, and@suss/values. The heavyweight package.@suss/datalog: zero dependencies. A semi-naive Datalog evaluator with stratified negation, where rules are plain data. Nothing in it refers to the IR or the AST, so an analysis written against fact patterns stays language-independent.@suss/resolution: a list of Datalog rules and nothing else. No parser, no language, no files. The rules answer one question, which function a value comes down to, and they compose one hop at a time, so a factory handing off to another factory, or a barrel re-exporting a wrapper, resolves without a rule written for that case. An adapter reads source into facts (binds,paramOf,callArg,reExports, and a handful more), concatenates its own rules, and evaluates on@suss/datalog. When an answer comes back empty, suspect the facts before the rules.packages/resolution/README.mdhas the fact vocabulary and the cases left unresolved on purpose, and How suss follows a value works through one example end to end.- Packs depend on
@suss/extractorfor thePatternPacktype, and on@suss/recognizewhere they describe an effect a library performs rather than a boundary it serves, plus a@suss/manifest-*package where discovery is manifest-driven. A pack is data. @suss/manifest-*: parse deploy manifests (SAM and CloudFormation templates, and the rest) into plain data. No IR, no other@sussdependency. Contract readers (manifest as specification) and framework packs (manifest as a discovery index) both read through them, so the parsing happens once and neither side depends on the other.@suss/contract-*: depend on the IR, plus on each other where they compose (cloudformationdelegates toopenapiandaws-apigateway). They produceBehavioralSummary[]from specs, manifests and schemas, and mark what they produceconfidence.source: "derived". See Contract sources.@suss/checker: depends on the IR and on@suss/datalog. Pairwise comparison is a pure function over twoBehavioralSummaryvalues returningFinding[]. Nothing in it touches extraction, the AST or packs, and it works on the serialized IR.@suss/cli: depends on everything, and imports the adapter dynamically so startup does not pay the ts-morph cost unless extraction runs. It is the one place that loads both summary streams and sends each to its checker, and that is what keeps the two checkers from depending on each other.
Ownership rules
Where new behavior goes:
- The adapter owns the language specification, both the syntax and the runtime semantics the language itself defines (Promise and its prototype methods, Array prototype methods, async/await, generators). If TC39 says it, the adapter handles it. Two cases show the line: the unit-body walkers descend into nested function expressions and arrows, such as Promise executors and
.thencallbacks, so recognizers and effects inside them attach to the enclosing unit; and a.thencallback's first parameter binds to the resolved value of the upstream promise. A pack-declared sub-unit boundary is the one opt-out, where the walker stops so the sub-unit's behavior lands on its own summary. The argument for drawing the line there is in a proposal: Adapter owns the ECMAScript spec. - Runtime packs own behavior the runtime defines:
setTimeout,setImmediateandprocess.*for Node,requestAnimationFrameand the DOM APIs for a browser. Where a name exists in both runtimes, each runtime owns its own, and there is no shared "language base" pack. - Framework packs own framework patterns: how handlers are registered, what a response looks like, how inputs arrive.
- Client packs own consumer-side discovery: fetch call sites, axios calls, GraphQL clients.
- Contract packs own translating an external specification (an OpenAPI document, a GraphQL SDL, a CloudFormation template, a Prisma schema) into the IR.
No pack exists whose only job is to translate the language specification. That work goes in the adapter.
A known tension in PatternPack
PatternPack was designed around provider-side extraction. Client discovery came later, through the clientCall match and the returnStatement terminal. It works, and it leaves structural noise behind. inputMapping means nothing for a client, because a client receives no framework-structured input. returnStatement and throwExpression are boilerplate every client pack repeats. contractReading applies only to providers and lives at the top level anyway.
Splitting PatternPack into provider and client sub-interfaces, with defaults for the client terminals, is the fix. Leave it while there are three client packs for TypeScript, and split it once a fourth ships and the boilerplate has become a pattern.
The extraction algorithm
For each code unit the adapter runs four independently testable steps, then assembles them:
- Terminal discovery. Use the pack's patterns to find every AST node that produces observable output.
- Path enumeration. The path engine enumerates every entry-to-terminal control-flow path over the function's structured statements (
if/else,switch, loops,try/catch,break/continue) and produces one condition list per path. Facts nothing can decide statically, such as which loop iteration ran or which statement threw, become opaque conditions. The few shapes the engine declines degrade to enclosure conditions plus an explicit unmodeled-control-flow marker. - Expression-level condition collection. Ternaries,
&&and||short-circuits, and conditions inside nested callbacks are read below the statement level and appended to each path's list. - Condition expression parsing. Decompose each condition into a structured
Predicate, resolving subjects through the symbol table. Fall back toopaquewhere decomposition fails.
Step 5 is assembly: each entry-to-terminal path becomes one Transition, pairing that path's conditions with the terminal's output. Extraction algorithm walks each step.
Two mechanisms run alongside and feed effects and sub-units into the same pipeline:
- Recognizers fire when the walker reaches a specific call or property access inside a unit. The runtime-node pack's scheduling recognizer fires on
setTimeout(...)and attaches a scheduling effect to the surrounding unit; its env-var recognizer fires onprocess.env.Xand attaches a config-read effect. - Sub-units synthesize a new code unit inside an existing one, usually a callback passed to a host function such as
setTimeout(callback)or a Promise executor. The walker descends into the sub-unit and runs recognizer dispatch there, so an effect in a nested function body is not missed.
Whole-program passes
Per-function extraction says what one function does. Two passes answer whole-program questions afterward, and both are Datalog rules over one shared fact database per extraction run:
- Reachable closure: every function statically reachable from a pack-discovered entry point becomes its own
librarysummary. - Re-throw enrichment: a bare
throw errin a catch block learns the throw sources its try block's callees can raise, transitively.
What an entry point reaches transitively is not stamped on it. The CLI walks the invocation effects across summaries where a command needs that answer, so it comes out the same for every language.
The layering is strict. Extraction emits facts, rules derive new facts, and assembly stamps derived results onto summaries as additive metadata. Rules never touch the AST, and that is what makes the analyses language-independent. Facts and rules is the working reference, with the relation table and a checklist for adding an analysis.
Verification: the differential fuzzer
A machine checks extraction's correctness principles on every build. A differential fuzzer (tools/differential, never published) generates handler programs and React components, extracts them through the shipping pipeline, executes the same code, and fails the build where a summary claims something execution disproves. Shrunk counterexamples are pinned in a permanent corpus, and a fixed gap becomes a regression test. The differential-fuzzing record has the protocol.
Degradation
Static analysis of production code is always imperfect, and suss records where it fell short:
- Opaque predicates. Where the adapter cannot decompose a condition, it keeps the source text and marks the predicate
opaque. A downstream tool sees an explicit "suss could not tell". - Gaps. There are two kinds, and they mean different things. An
unhandledCaseis about the code: the contract declares a 500 the handler never produces, or the handler produces a 418 the contract never declared. AnunreadOutcomeis about how much suss could read: areturndidn't match any of the pack's terminal patterns, so what it produces went undescribed. Both go in the output as data. - Confidence levels (
high,medium,low). A return nobody could read drops the summary straight tolow, because a function whose returns all went unread has no conditions either and would otherwise score as certain. Otherwise the level comes from the ratio of opaque to structured predicates. - Layered dependency resolution. In-project code gets full extraction, a typed external dependency gets its type information, and an untyped one becomes opaque predicates. Nothing needs configuring.
Boundary semantics today
The IR types are mostly protocol-agnostic. Every Output is a typed structure and every Predicate operates on ValueRefs. Nine semantics variants ship, each its own module under packages/ir-core/src/semantics/, composed by a registry:
rest:(method, normalizedPath)as the identity,"*"as the method wildcard. Two sides pair when their paths bucket together and their methods agree. Metadata undermetadata.http.*.graphql-resolver: the parent type name plus the field (Query.user, and alsoUser.posts), with contract derivation from inline SDL. Metadata undermetadata.graphql.*.graphql-operationdescribes the client side, and the contract checker pairs it rather than the key engine.message-bus: the key is built from the channel's subject, so a template that writesdefault#order.placedand a handler that writesorder.placedland in one bucket, and the buses have to agree inside it.function-call: keyed by package and export path where both are known.storage,runtime-config,metricandunit-invocation: each with its own identity.storageandruntime-configdon't declare an identity key, and their checkers pair by container and by deployable unit instead.
Each variant declares its identity key, its pairing key, and how two sides agree. The pairing engine in @suss/checker dispatches through the registry, so a new boundary type adds a variant. Boundary semantics covers what a variant looks like and what adding one involves.
What is not here
- A full control flow graph. suss identifies terminals and the conditions that gate them. Building a CFG and running data-flow analysis over it would capture more and cost orders of magnitude more.
- Cross-service aggregation.
@suss/checkercompares two summaries at a time. Aggregating across an organization, tracking boundaries over commits, and alerting on regressions are separate concerns that take pairwise findings as input. See Cross-boundary checking. - Runtime tracing. Everything is static. suss never instruments your code and never reads anything from your running system.
- Semantics for a dependency's calls. Seeing
await db.findById(id), the extractor records that the subject is the result ofdb.findById. It does not know what Prisma'sfindByIddoes, and cross-boundary comparison needs subjects to be stable rather than understood. - A shared adapter abstraction layer. Three adapters ship, and each has its own analysis logic over its own parser. What they share is the layer above them:
assembleSummaryturns aRawCodeStructureinto a summary for all three, so gap detection and confidence scoring have one implementation. Some tree-walking patterns are conceptually language-agnostic, such as finding every property access on a variable within a subtree, and a shared@suss/adapter-corewaits until the same pattern has been written twice for a reason. - A linter. A finding describes what two sides of a contract disagree on. It is not a style rule or a code-quality opinion.