Skip to content

Summary format ​

A summary file is a JSON array of objects, one per code unit:

json
[
  { "schemaVersion": 7, "kind": "handler", "identity": { }, "transitions": [ ] }
]

The file has one summary per line and no indentation. The [ and the ] are on lines of their own, and each line in between is one summary followed by a comma, except the last:

[
{"schemaVersion":7,"kind":"handler","identity":{},"transitions":[]},
{"schemaVersion":7,"kind":"client","identity":{},"transitions":[]}
]

Any JSON parser reads it. A tool that reads a file too large for one string can read it a line at a time and parse each line, after dropping the trailing comma. suss writes the file under a temporary name and renames it into place at the end, so a run killed while writing leaves the previous file. Files written by releases before 0.34.1 are indented, and suss still reads them when they are under about 512 MB.

Values written once ​

Summaries repeat a lot of what they say. A GraphQL client summary has a copy of its component's transitions, and a transition lists every effect reached before it returns. From version 7, a file writes an object or array that appears more than once, and prints to 32 characters or more, in full the first time and as a reference after that:

{"schemaVersion":7,"kind":"client","transitions":[{"id":"Orders.list:return:none:a57768c","effects":[{"$def":1,"$value":{"type":"invocation","callee":"fetchOrders","args":[]}}]}]},
{"schemaVersion":7,"kind":"client","transitions":[{"id":"Orders.refresh:return:none:a57768c","effects":[{"$ref":1}]}]}

A reader replaces {"$def":n,"$value":v} with v, and {"$ref":n} with the v of the $def that has the same n, which comes earlier in the same file. A summary itself is never a reference, so each line is still one summary. parseSummaries and readSummaryFile put every value back before they validate, so what they return is the same as for a file written without references. A tool that parses the JSON itself has to do the same, reading the lines in order. On a 6,000-file frontend this took the file from 653 MB to 52 MB.

suss extract writes one, suss contract writes one from a schema or a deploy template, and suss check, suss inspect and suss ask all read them. The zod schemas in packages/behavioral-ir/src/schemas.ts are the one place the format is written by hand. IR types goes through every type field by field.

Top-level fields ​

FieldTypeWhat it is
schemaVersionnumber, optionalThe format version. Absent means 1.
kindCodeUnitKindWhat sort of unit this is: a handler, a client, a component, and twelve more.
locationSourceLocationFile, line range, character span, export name, workspace, and the module when suss.json lists modules.
identityCodeUnitIdentityThe name, the export path, the boundary binding, the deployed unit.
inputsInput[]How values reach the unit, one per parameter, injection, hook return or closure.
transitionsTransition[]One per execution path.
gapsGap[]What the summary could not account for.
confidenceConfidenceInfoHow much of the behavior was read, and where the claim came from.
definitionsrecord, optionalTypes a ref shape points at, keyed by definition.
inputReadsarray, optionalWhat the unit read out of the values it was given, once each.
receiverClassobject, optionalThe subclass an inherited method runs on, for a summary of the method on that subclass.
metadatarecord, optionalFramework-specific data, namespaced by boundary semantics.

One unit ​

Here is a whole summary, from suss extract --dir fixtures/express -f express, with its four transitions cut:

json
{
  "kind": "handler",
  "location": {
    "file": "fixtures/express/handlers.ts",
    "range": { "start": 17, "end": 39 },
    "span": { "start": 438, "end": 827 },
    "exportName": "get",
    "workspace": "suss"
  },
  "identity": {
    "name": "get",
    "nameKind": "label",
    "exportPath": ["get"],
    "boundaryBinding": {
      "transport": "http",
      "semantics": { "name": "rest", "method": "GET", "path": "/users/:id" },
      "recognition": "express"
    },
    "id": "suss::fixtures/express/handlers.ts::get#GET /users/{id}"
  },
  "inputs": [
    { "type": "parameter", "name": "req", "position": 0, "role": "request", "shape": null },
    { "type": "parameter", "name": "res", "position": 1, "role": "response", "shape": null },
    { "type": "parameter", "name": "next", "position": 2, "role": "next", "shape": null }
  ],
  "transitions": [ ],
  "gaps": [
    {
      "type": "unfollowedCall",
      "conditions": [],
      "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"
    }
  ],
  "confidence": { "source": "inferred_static", "level": "high" },
  "schemaVersion": 7
}

The handler is 23 lines of Express with two guards and a nested condition. The db it calls is declared with no body, so the walk stopped there and the gap records which call it was.

The id is the workspace, the file and the name. Discovery made the name get up from the route's method, so nameKind is label, and a summary named by a label always has its boundary on the end of its id. Any other summary gets the boundary only when another summary in the run would have the same id, and a later one in the same file that still matches gets @2, @3.

A parameter the library fills with one field of the request says which field under field. FastAPI's x_tenant_id: str = Header() is { "role": "headers", "field": "x-tenant-id" }, with the underscores written the way FastAPI sends them.

One transition ​

A transition records that when all of these conditions hold, this output comes out and these effects fire. Here is the 404 branch of the same handler:

json
{
  "id": "get:response:404:9d39a1a",
  "conditions": [
    {
      "type": "negation",
      "operand": {
        "type": "truthinessCheck",
        "subject": {
          "type": "derived",
          "from": {
            "type": "derived",
            "from": { "type": "input", "inputRef": "req", "path": [] },
            "derivation": { "type": "propertyAccess", "property": "params" }
          },
          "derivation": { "type": "destructured", "field": "id" }
        },
        "negated": true
      }
    },
    {
      "type": "truthinessCheck",
      "subject": { "type": "dependency", "name": "db.findById", "accessChain": [] },
      "negated": true
    }
  ],
  "output": {
    "type": "response",
    "statusCode": { "type": "literal", "value": 404 },
    "body": {
      "type": "record",
      "properties": { "error": { "type": "literal", "value": "not found" } }
    },
    "headers": {}
  },
  "effects": [
    {
      "type": "invocation",
      "callee": "db.findById",
      "args": [{ "kind": "identifier", "name": "id" }],
      "async": true,
      "location": { "start": 25, "end": 25 }
    }
  ],
  "location": { "start": 28, "end": 28 },
  "isDefault": false
}

There are two conditions here, joined with AND: the id is present, and db.findById came back falsy. OR goes inside a predicate instead, in the compound variant, so two transitions have the same precondition when their condition lists are structurally equal.

Conditions are structured when the reader could take the expression apart, and opaque when it could not. An opaque predicate keeps the source text and a reason, so a downstream tool knows the branch is there and decides for itself how to treat it.

The id is content-addressed: ${functionName}:${terminalKind}:${statusKey}:${hash7}, where the hash is over the ordered condition chain's source text. Reordering branches keeps the ids. Changing a status, a condition or a terminal kind gives you a new one.

One effect ​

The transition above has an invocation effect, which records that a call fired and what it was passed. The other kind is interaction, which records a typed boundary crossing. This one is from suss extract -f aws-lambda -f aws-dynamodb over a Lambda that reads a DynamoDB table:

json
{
  "type": "interaction",
  "binding": {
    "transport": "aws-sdk",
    "semantics": {
      "name": "storage",
      "storageSystem": "aws.dynamodb",
      "scope": "default",
      "container": "Invoices",
      "accessPath": null
    },
    "recognition": "@suss/framework-aws-dynamodb"
  },
  "callee": "dynamo.send",
  "interaction": {
    "class": "storage-access",
    "kind": "read",
    "fields": ["*"],
    "operation": "GetItemCommand",
    "selector": ["invoiceId"]
  },
  "location": { "start": 19, "end": 24 }
}

location gives the first and last line of the call or read that produced the effect, in the same file as the transition's own location. The dynamo.send(...) above is written across six lines. A tool that wants to point at the call reads the lines here instead of searching the file for callee, which finds every other dynamo.send too. An effect no line of source produced, such as one a contract declares or a call a test framework makes on the unit's behalf, has no location.

A transition lists each effect once, however many sites on that path produced it. The repeats become count, so a path that calls the same validator thirteen times has one effect with "count": 13, and a path that calls it once has no count at all. The folded effect keeps the first site's location. callee is the source text with its whitespace collapsed, so a chain broken across lines and the same chain written on one line are one effect.

A transition lists only the effects that can run before it ends. A route that returns 404 when the read finds nothing does not list the write it makes after that check on the 404. An effect written inside the loop that the transition ends in stays on it, since an earlier pass may have run it, and so does cleanup in a finally or ensure around the place it ends.

Every interaction includes the BoundaryBinding of the resource it reaches, so the checker can pair it against whatever declares that thing, such as a Prisma schema or a CloudFormation table. The nine classes are storage-access, service-call, message-send, message-receive, unit-invoke, config-read, metadata-read, schedule and stream-write. IR types has each one's fields.

A read in a method that many objects share, which reads the table its constructor stored, has "container": null and lists the table for each construction in containerByConstruction. The calls that run it list where their receiver was made in receiverConstructions. IR types says how a reader puts the two together.

Where a value came from ​

A transition whose effects write a column or pick rows by one records where each of those values came from, under provenance. The GetItem above, written in an Express route as { Key: { invoiceId: req.params.id } }, gives:

json
"provenance": [
  {
    "at": { "slot": "selector", "effect": 0, "name": "invoiceId" },
    "from": [{ "type": "input", "inputRef": "req", "path": ["params", "id"] }]
  }
]

at says which column of which effect: effect is the effect's position in the transition's effects, and slot is field for a column the access writes and selector for one it picks rows by. from lists every place the value could have come from, in the value references a condition uses. An input is a path off one of the unit's inputs, a literal is a value written in the source, and an unresolved is where suss stopped following the value, with the source text it stopped at: a call into a library no pack describes, or a parameter of a helper function. A value the language converted on the way, as Number(req.params.id) or params[:id].to_i, is a derived reference whose derivation is a methodCall naming the conversion, with the input under it. A transition with no such column has no provenance field.

How two summaries pair ​

Two summaries describe opposite sides of one boundary when their semantics match, and each protocol matches its own way. REST pairs on (method, normalizedPath). function-call pairs on package::exportPath. Storage pairs on (storageSystem, scope, container, accessPath). Boundary semantics has the whole set.

An identity field is null when the source never said what it is. A send whose queue URL comes from a variable still appears:

json
{ "name": "message-bus", "messageBus": "aws_sqs", "channel": null }

That summary pairs with nothing on purpose. Pairing a null against whatever the source text happened to spell would put two unrelated boundaries together. A REST method of "*" means the handler responds to every method, and it pairs with whatever method each consumer uses.

Route paths ​

A REST path is the route as the pack read it. A path the route declares outright keeps its own spelling, so an Express route keeps :id. A path suss had to work out, from a prefix a variable supplies or pieces the code joins together, is written in the pattern grammar below, and pairing normalizes every path to that grammar first. A tool that reads paths should expect either form.

SpellingMeaning
/users/{id}one segment the code fills in at runtime
/files/{tenant?}zero or one segment
/files/{rest+}one or more segments
/files/{rest*}zero or more segments, the same as a bare * segment
(/api|/api/v2)/ordersone of the options, and an option may contain a slash

A hole's name is what the code called it, or a placeholder such as value where the expression had no name. Two paths pair when some request satisfies both, so /api/orders/{rest*} pairs with a consumer of /api/orders, and (/api|/api/v2)/orders pairs with a consumer of either option.

Versions ​

Every summary this build writes says "schemaVersion": 7. A summary without the field is version 1, written by 0.3.x. The parsers in @suss/behavioral-ir read every version ever published, so a published artifact never needs rewriting.

VersionWhat changed
1An identity field the source did not state is the empty string.
2Those fields are null instead, the empty string is invalid, and "*" becomes the REST method wildcard.
3A parameter input's role can be null, for a parameter whose role nobody could read.
4One storage variant replaces storage-relational.
5A store and a bus go by the names OpenTelemetry's semantic conventions give them, so a summary and a span spell one boundary the same way.
6A metric's measurement words are OpenTelemetry's too: histogram, and gauge, delta, cumulative for what one measurement covers.
7A file writes a value it repeats once and refers back to it after, as values written once describes. A summary means the same as at 6.

Two of those bumps needed a hand edit. Moving to 5 changes a suppression that says which bus (bus:sqs order.placed becomes bus:aws_sqs order.placed), and a storageSystem of postgres in pack config becomes postgresql. Nothing else, at any version, needs one.

What a consumer can rely on ​

The format is stable enough to build on. These are the guarantees:

  • The parsers read every version ever published. parseSummaries normalizes an older artifact on the way in, so a file written by any released version reads back as the current shape. Nobody rewrites published JSON.
  • A breaking change bumps the major version of @suss/behavioral-ir and comes with a migration note in the Changelog.
  • Adding an interaction class, a code unit kind, an output type or a semantics variant is additive. A tool that dispatches on class, kind, an output's type or semantics.name needs a default branch, and it never gets a new required field on a shape it already reads.
  • Transition ids survive reordering and reformatting. They are computed from the condition chain's source text, with source offsets left out, so a diff across two points in time matches by id.
  • A null identity field means the source did not say. It never means the empty string, which is invalid from version 2 on.
  • Paths are relative. Every path is measured from the root of the project it is in, so two checkouts of one project write the same summaries. A path a summary records for a file in another project, such as an import of a sibling package, starts with that project's name, as in @acme/shared::src/routes.ts.
  • The JSON is the canonical artifact. The text suss inspect prints is written for people to read, and it changes with the CLI, so build your tool on the JSON. Format stability lists the parts of the text that do stay put.

Two ways to read a file:

  • TypeScript or JavaScript. 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. The types come from the same schemas through z.infer.
  • Anything else. Validate against behavioral-summary.schema.json. The build generates it from the zod schemas, so it always matches the runtime parsers and nobody edits it by hand.

Publishing summaries with a package ​

The format contains nothing machine-specific, so a library author can ship pre-built summaries alongside the package and consumers pair against them without reading the source. Publish summaries has the convention.

Released under the Apache-2.0 License.