Changelog
One section per release, newest first.
0.38.2 (2026-10-31)
0.38.2 reads Ruby and Python projects in 35 to 40% less time, makes the rules engine cheaper in every language, follows Python routes into generic repositories, and stops Ruby files that reopen a module from listing each other as imports.
Background
suss reads Ruby and Python source with tree-sitter, a parser that runs as WebAssembly. Each time an adapter asked a node for its type, its children or its text, the question went into WebAssembly and the answer came back as a new JavaScript object. Once the source is read, suss works out what values come down to with a rules engine, which matches each rule against the facts it has one part of the rule at a time.
In 0.38.2 the Ruby and Python adapters copy each parse into plain JavaScript objects once and free tree-sitter's copy. The rules engine works out once per rule what each step of that matching needs, where it used to work it out again at every step.
Faster reads
Both changes write the same summaries as before:
- The Ruby and Python adapters read a JavaScript copy of each parse, so asking a node for its children or its text no longer goes through WebAssembly (#1370).
- The rules engine numbers each rule's variables and keeps their values in one array. For each set of the rule's parts that have matched, it works out once which values the remaining parts are looked up by. It still picks the next part the same way, so it derives the same facts in the same order (#1371).
| Project | 0.38.1 | 0.38.2 |
|---|---|---|
| mastodon (Ruby) | 23.5 s | 14.0 s |
| A private Rails app | 26.3 s | 15.8 s |
| dispatch (Python) | 12.8 s | 8.3 s |
| A private Cloudflare worker (TypeScript) | 94 s | 86 s |
Peak memory on mastodon falls from 3.1 GB to 2.3 GB, and on the Rails app from 3.2 GB to about 2.5 GB. A TypeScript project that spends less of its time in the rules, such as cal.com, reads in the same time as before.
Ruby files that reopen a module no longer import each other
Ruby lets any number of files open the same module. suss read the name in module Admin as a use of Admin, so every file that opened Admin was recorded as importing the first file that declared it:
# app/controllers/admin/base_controller.rb
module Admin
class BaseController < ApplicationController0.38.1 "moduleImports": ["app/controllers/admin/account_actions_controller.rb", "app/controllers/application_controller.rb", ...]
0.38.2 "moduleImports": ["app/controllers/application_controller.rb", ...]suss builds the module graph from moduleImports to work out which files run inside a deployable unit, such as a Lambda handler, so those false edges could pull in files a handler never loads. On mastodon, 52 files listed one of these imports. Upgrading changes moduleImports in Ruby summaries once, and only by removing entries (#1369).
Python routes that go through a generic repository
A FastAPI app often reaches its tables through one repository class that many models share, made once per model and handed out by properties:
class RepositoryGeneric[Schema, Model]:
def get_one(self, key): ...
class GroupRepositoryGeneric[Schema, Model](RepositoryGeneric[Schema, Model]):
...
class BaseController:
@property
def repos(self):
if not self._repos:
self._repos = AllRepositories(self.session)
return self._repossuss read the base RepositoryGeneric[Schema, Model] as nothing, so the subclass inherited none of its methods, and it never found what a property's getter stores on the instance. A route's call such as self.repos.tools.get_one(id) linked to nothing, and on mealie 22 of 196 routes reached the database at all. In 0.38.2, 87 do. Most of them reach it through a method such as select(self.model), where the table depends on which repository the route went through, and those show the table as postgresql:<unnamed container>.
A variable declared as one of SQLAlchemy's own types, such as query: Select, is no longer read as the model a query works on. A pagination helper that takes one used to read a table called Select. Upgrading adds call links and summaries to Python projects written this way (#1373).
Upgrade with npm install -g @suss/cli@0.38.2, or npx @suss/cli@0.38.2.
0.38.1 (2026-10-30)
0.38.1 reads TypeScript projects faster where suss asks about the same values many times, and prints an object type's members in one order however suss came to the type.
Faster TypeScript reads
To work out what a value comes down to, such as the function a route runs or the object a client was built from, suss asks its fact store, and it remembers the answer. Until now it threw every remembered answer away as soon as it read another fact. It reads facts all through a run, so a project that asks about the same values many times kept working out answers that hadn't changed. Two changes cut that, and both write the same summaries as before:
- An answer suss has had to work out a second time now keeps a list of the facts it looked up, and suss keeps the answer until a fact arrives that one of those lookups would find (#1367).
- The rules engine remembers, for the rest of one evaluation, which rules have the facts to run, where it used to check every rule again each round. Looking for GraphQL clients, suss also stops asking where calls imported from other packages come from (#1366).
| Project | 0.38.0 | 0.38.1 |
|---|---|---|
| A private Cloudflare worker | 113 s | 92 s |
| A private frontend of about 1.4 million lines | 132 s | 127 s |
A project that asks about each value only a few times, such as cal.com, reads in the same time as before.
Object types print their members by name
TypeScript lists the members of an object type it builds, such as Omit<A & B, "x"> or a mapped type, in the order it happened to create them. A summary could then print the same type two ways, depending on what suss had read before it, so a run with a warm cache and a run without one could disagree. Object members now print by name, as union members already did:
export function Profile(props: { id: number; completedOnboarding?: boolean }) {0.38.0 "shape":{"type":"ref","name":"{ id: number; completedOnboarding?: boolean | undefined; }"}
0.38.1 "shape":{"type":"ref","name":"{ completedOnboarding?: boolean | undefined; id: number; }"}The first run after upgrading changes the text of those types once. A diff between summaries written by 0.38.0 and by 0.38.1 shows their members reordered, and the short hash in a type definition's name changes with them (#1365).
0.38.0 (2026-10-27)
0.38.0 reads a large TypeScript project in less than half the time and memory 0.37.1 needed, and writes summary files about a tenth the size, which moves the summary format to version 7.
Breaking changes
A summaries file writes a repeated value once. An object or array that appears more than once, and prints to 32 characters or more, is written in full the first time and as a reference after that. Before:
{"schemaVersion":6,"kind":"client","transitions":[{"id":"Orders.list:return:none:a57768c","effects":[{"type":"invocation","callee":"fetchOrders","args":[]}]}]},
{"schemaVersion":6,"kind":"client","transitions":[{"id":"Orders.refresh:return:none:a57768c","effects":[{"type":"invocation","callee":"fetchOrders","args":[]}]}]}After:
{"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}]}]}suss check, inspect, ask, map and the MCP server read both versions. So do parseSummaries and readSummaryFile from @suss/behavioral-ir, and what they return doesn't change. A tool that parses a summaries file with plain JSON.parse has to put the values back itself: replace {"$def":n,"$value":v} with v, and {"$ref":n} with a copy of the v of the $def with the same n, reading the lines in order. Summary format describes it (#1361).
Faster TypeScript reads in less memory
A large TypeScript project took minutes to read and needed more heap than the 8 GB suss check gives each read, so a bare check over a monorepo with a big frontend ran out of memory. Several changes cut both, and each writes the same summaries as before:
- When suss asks its fact store a question it asked before, and no new facts have arrived since, it reuses the earlier result (#1352).
- A body several units read is walked once (#1356), and each effect's key is worked out once per summary (#1357).
- suss checked whether an overloaded call's argument was untyped only after printing every overload's return type. It now checks first (#1353). Two narrower questions are asked only when they can matter (#1354, #1355).
- The fact store kept a map for every column of every fact, and an array for every value in a column's index, though most facts have a node id of their own. It now keeps a fact's key at the first column no other fact shares, and a lone tuple without an array around it. On the private frontend below that took the live heap from 8.3 GB to 7.0 GB, and the saving reaches Python and Ruby reads too (#1360).
| Project | 0.37.1 | 0.38.0 |
|---|---|---|
| A private frontend of about 1.4 million lines | 282 s, and more heap than the default 8 GB | 132 s in the default 8 GB |
| mastodon (Ruby), peak memory | 3.9 GB | 3.0 GB |
| dispatch (Python), peak memory | 2.3 GB | 1.5 GB |
cal.com apps/web | 38 s, 5.2 GB | 40 s, 5.1 GB |
cal.com is about 2 seconds slower, because writing a file with each repeated value once takes longer than writing it plain.
Summary files about a tenth the size
A GraphQL client summary has a copy of its component's transitions, so a component with 68 operations wrote the same 34 transitions 68 times. With the format change above, the private frontend's summaries went from 653 MB to 52 MB, and reading them back from 7.8 s to 5.2 s. Every summary reads back the same as before (#1361).
Tests no longer stand in for a wrapper's callers
suss reads a project's own REST or GraphQL wrapper once for each caller, to work out the request each one sends. A test calling the wrapper passes stand-in values, which describe no request the app makes. A Cloudflare worker whose tests called its fetch handler about a thousand times took more than four hours to read. suss now leaves out a test file's calls to a production wrapper, and that worker reads in about two minutes (#1358, #1359).
Fixes
- A number written with underscores, such as
ENV.fetch("MAX_FOLLOWS_THRESHOLD", 7_500), read asnullin Ruby and Python, and mastodon's summaries then failed validation insuss check. Ruby, Python and TypeScript now read underscores, base prefixes, and Ruby's leading-zero octal (#1362).
0.37.1 (2026-10-26)
0.37.1 reads large TypeScript projects in well under half the time 0.37.0 took, and lets suss map list every finding and write its data as JSON.
Faster TypeScript reads
A large TypeScript project could take many minutes and many gigabytes to read. Four changes cut that down, and each one writes the same summaries as before:
- suss printed a type's text every time the code mentioned the type, and printing is slow because the compiler works out how to name each symbol in it. It now prints each type once at each place (#1344).
- To check whether an environment read has a default, suss wrapped every node of the function in a ts-morph object that lives for the rest of the run. It now wraps only the identifiers spelled like the variable (#1345).
- Most of the adapter's other walks did the same, wrapping every identifier, token and type annotation they passed. They now read the compiler's nodes, wrap only the kinds they act on, and skip type syntax (#1348).
- Node gives a process a 16 MB young generation, the part of the heap where new objects start, and suss spent almost half of a large read collecting it. Each read now starts with 256 MB, or a sixteenth of the heap when the heap is smaller. A size you set in
NODE_OPTIONSor on the node command line is kept (#1350).
| Project | 0.37.0 | 0.37.1 |
|---|---|---|
cal.com packages/features | 232 s, 9.9 GB | 46 s, 4.5 GB |
| A private frontend of about 1.4 million lines | 706 s | 282 s |
Every finding in the map
suss map showed a finding only inside the route or field it was on, so you had to open routes one at a time, and a finding on no route at all showed only as a count. Browse now has a Findings tab with one line per finding, errors first and grouped by kind, with the route it is on and the lines on each side. You can filter by severity, by kind, and by whether the finding is on a route. Past 200 findings of one kind, the tab gives the rest as a count (#1347).
The map as JSON
suss map --json writes the data the page is drawn from to .suss/map.json: every route and field with its outcomes, what it reads and writes, its callers and its findings, plus the tables, env vars and cards. The format has a version, which goes up when a field changes meaning or goes away. The examples on suss.sh have a copy beside each page (#1346).
Fixes
- A function whose types lead through a long chain of named types, such as Prisma's, could fail to read with "Maximum call stack size exceeded" on some runs and not others, and its summary then described none of its body. suss now expands each named type after the one that mentions it (#1349).
- A bare
suss checkorsuss mapwith nosuss.jsongives each pack the optionssuss initworks out for it, such as the databaseactiverecordreads fromconfig/database.yml. On a private Rails app the map went from 0 tables to 78 (#1343). - The map examples on suss.sh are rebuilt with this release (#1342).
Known issues
- The private frontend above needs about 18 GB of heap, and suss gives a read 8 GB unless you set more, so the read stops with "JavaScript heap out of memory". Give it more with
--read-memoryoncheckandmap, orNODE_OPTIONS=--max-old-space-size=20000onextract.
0.37.0 (2026-10-26)
0.37.0 follows a request into the background jobs it enqueues, reads the deliveries a CloudFormation template declares, and turns a missing environment variable into one finding per read, with a rule you can paste that keeps working when the code moves.
Breaking changes
A .sussignore rule for a missing environment variable needs rewriting. suss used to report one boundaryFieldUnknown per deployment and per path through the code that read the variable, and the rule it printed pointed at one path:
- kind: boundaryFieldUnknown
consumer: { transitionId: "chargeCard:return:da39a3e" }Now there is one finding per place the code reads the variable, listing the deployments that run that code without declaring it, and the rule gives the variable and those deployments:
- kind: boundaryFieldUnknown
boundary: "runtime-config STRIPE_API_KEY"
consumer: { field: "STRIPE_API_KEY" }
deployments: ["Checkout"]A rule keyed on transitionId no longer matches, so the finding comes back. Delete it, run suss check, and paste the rule printed under the finding. A deployment the rule leaves out, including one added later, is still reported (#1325, #1338, #1339).
OpenAPI summaries are labelled by the spec's path. openapi:openapi.yaml is now openapi:api/openapi.yaml, as the CloudFormation and serverless readers already label their documents. A .sussignore rule that gives only the file name still matches. A check --since against summaries from 0.36.0 sees each OpenAPI operation as renamed once (#1340).
Changes to the summary format
config-readeffects recorddefault, the value a read falls back to when the variable is unset, when it settles to one (#1332).- OpenAPI operations and GraphQL schema fields record the line they are declared on, where every one used to be line 0 (#1340).
- Template summaries point at their resource's line instead of line 1, and EventBridge rules record
metadata.messageBus.eventPattern. A template delivery is a summary markedmetadata.messageBus.wiring(#1331). .sussignorerules takedeployments(#1339).
Background jobs
A Rails request that enqueues a job used to stop at the enqueue, and the job's own reads and writes belonged to nobody:
ReportExportJob.perform_later(export.id)The new activejob, sidekiq, celery and bullmq packs make each job's handler a consumer and each enqueue a send to it. suss ask "what does ExportsController#create reach" now goes on into ReportExportJob#perform and the tables it touches, and .delay() in Celery and queue.add() in BullMQ do the same. A job is never reported as an orphan from either end, since a scheduler config or another service can enqueue it, and Celery's send_task usually sends to a worker in another service. suss init suggests the packs from your Gemfile, requirements or package.json (#1327, #1328, #1329, #1326).
Deliveries a CloudFormation template declares
suss reported dead-letter queues as unused and queue consumers as orphans, because nothing in code sends to them:
OrdersQueue:
Type: AWS::SQS::Queue
Properties:
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersDlq.ArnThe CloudFormation reader now reads a queue's redrive, a dead-letter config, Lambda destinations, SNS subscriptions to SQS, S3 notifications, pipes, schedules and alarm actions as deliveries. An EventBridge send pairs with a rule when the rule's pattern lets it through, so a send from a source the pattern does not list is an orphan. A rule that also filters on detail matches only possibly, and nothing is compared across it (#1331).
AppSync fields declared in a nested stack now pair with the Lambda behind them, through the parameters the parent stack passes, in TypeScript, Python and Ruby (#1336).
One environment variable finding per read
A flag read in a shared utils.ts was reported missing from every function that imported the file, though only one of them called it. A function is now charged only with the reads its own calls reach. On one public SAM app with 25 handlers, the false errors went from 24 to 0. Reads behind a call suss cannot follow are listed under unchecked.runtimeConfig instead of being reported (#1322, #1323).
Each finding now covers one read, lists the deployments that lack the variable, and the rule it prints is the one above. A fallback after a call that keeps a missing value missing, such as ENV["X"].presence || "dev", counts as a default (#1325, #1330).
suss map
The page is easier to read:
- Each outcome of a route takes one line: its status, when it happens, and where.
- A route or field a spec declares links to the line in the spec, and one nothing serves says why suss didn't find what serves it.
- Every GraphQL type name links, enums, unions and scalars included.
- The env list shows each variable's default, and groups the variables a config file such as
config/database.ymlreads when the app boots. Those reads never showed in 0.36.0. - A project with thousands of findings keeps a small page: each entry lists ten findings of a kind and counts the rest (#1335, #1340).
Large repositories without a suss.json
A bare suss map or suss check on a large monorepo used to print one line and then nothing for half an hour. With no suss.json, it now runs the reads suss init would write, one per workspace package, each in its own process with a time and memory limit, several at once, and prints each one as it starts and ends:
23 reads, 3 at a time, each stopped after 20m 0s:
[10/23] suss extract --lang typescript --dir apps/web ...: 3791 summaries in 2m 40s
[23/23] suss extract --lang typescript --dir packages/features ...: failed after 20m 0sA read that fails is left out, the rest are checked, and the report says which failed. --jobs, --read-timeout and --read-memory change the limits (#1333). A package shared between services is read through the services that import it, and suss init finds the packages beside it (#1334).
Fixes and improvements
- TypeScript follows
newinto the constructor it runs, and reads a parameter default at a call that leaves the argument out (#1322). - A graphql-ruby field's outcomes say the type each path resolves to (#1324).
- A warm
extractmisses the cache afterprisma generaterewrites the client or an install changes a library a pack reads (#1337). suss mapis in the quickstart and adoption guides (#1321).
0.34.1 (2026-10-20)
0.34.1 makes a first run on someone else's code report a sixth as many findings, and lets suss read back summary files of any size.
Changes a run shows
- A summaries file is written one summary per line. It is still one JSON array, so a program that parses it sees the same value, but a script that greps the indented text or diffs files by line sees a different layout. 0.34.1 reads files that earlier releases wrote, and asks you to extract again when one is too large to read whole. (#1273)
- A default
suss checkcountslowConfidencenotes on itsNot shown:line instead of in the finding count, so a run with only those notes prints "No findings".--all,--jsonand a--fail-onthat reaches info work as before. (#1263) suss initprints one section per project it finds, and every command runs from the folder you raninitin. Each extract writes its own file, named after its project. (#1242)suss contractexits 1 when it cannot read a single boundary from a document, where it used to exit 0. (#1246)
A first run on 30 public apps
The first-run harness in tools/first-run runs suss init on 30 public apps that keep a server and its client in one repository, then runs every command init prints and suss check --dir over what they wrote. Each app is pinned to one commit and its dependencies are not installed. On the same commits:
| 0.34.0 | 0.34.1 | |
|---|---|---|
| Apps where an extract wrote summaries | 27 | 29 |
Apps where check paired a client with its server or spec | 15 | 19 |
| Findings | 3,266 | 515 |
| Errors among them | 105 | 226 |
lowConfidence notes, which 0.34.1 leaves out of the count | 165 | 520 |
Findings fell on every app where 0.34.0 reported more than a handful, such as dispatch from 1,087 to 86 and twenty from 1,013 to 59. Errors went up because more boundaries pair: on chatwoot, 0.34.0 paired one route with the app's Swagger document and 0.34.1 pairs 151, and 59 of chatwoot's 109 errors are one Rails filter that sends a 403 the document does not declare. (#1240)
Summary files past 512 MB read back
A large project could write a summaries file that check --dir and inspect --diff then could not read, stopping with "Cannot create a string longer than 0x1fffffe8 characters". suss now writes one summary per line and reads the file a line at a time. directus api/ went from 671 MB to 180 MB and reads back in 2.4 seconds with check --dir, where 0.34.0 could not read it. cal.com packages/trpc went from 533 MB to 179 MB, and inspect --diff of it against itself peaks at 1.9 GB instead of 5.3 GB. A file an earlier release wrote still reads when it fits in one string. (#1273)
Calls to another company's API stay off your routes
A client call to someone else's host, such as fetch("https://api.github.com/users/" + login), used to pair with any route of your app that had the same path, and drew findings about statuses your app sends and that host never does. That call now stays unpaired. A call to your app's own public name pairs when suss.json lists that name under hosts. A call goes to the route that spells out more of what the call spells, and a Rails route also serves the optional .:format. (#1270)
Rails filters and JSON bodies
A before_action that returns early no longer claims a 200 for every route behind it, and render json: bodies are read, so each response lists the keys it renders. Filters written as blocks, rescue_from handlers a raise reaches, the 404 for a missing record, inherited filter statuses, and a filter that responds through a method the including controller supplies are read too. (#1244, #1253, #1257, #1261, #1265, #1271)
init sets up every project in a repository
At the root of a Python API with a TypeScript client beside it, init prints a section for each project, with commands that run from where you ran it. 0.34.0 printed a TypeScript extract that the root refused because it reads as Python. init also prints a contract command only when that contract's reader finds something in the file. (#1242, #1247, #1258, #1260, #1263)
OpenAPI documents split across files
suss contract --from openapi resolves every $ref against the file it is written in, so a document that keeps its paths in other files reads. 0.34.0 read such a document as empty. (#1246)
Performance
Each number is the median of three alternating cold runs of 0.34.0 and 0.34.1 on the same corpora, with the same summary counts from both:
| Corpus | 0.34.0 | 0.34.1 | Change |
|---|---|---|---|
twenty-server, with the packs suss init picks | 78.0s | 77.5s | within run-to-run spread |
| twenty-server, with the benchmark packs | 36.6s | 37.6s | +2.8% |
| twenty-front | 84.1s | 79.5s | -5.4% |
directus api/ | 73.6s | 72.5s | -1.6% |
| saleor-dashboard | 39.3s | 40.0s | within run-to-run spread |
| saleor-storefront | 6.2s | 6.1s | within run-to-run spread |
twenty-server with the benchmark packs is about one second slower, a little more than the 1.7% the runs vary by. On twenty-front the rules engine went from 14.2 s to 7.0 s, which accounts for the gain there.
Fixes
- A finding is left out when the checker read one side only in part, and a filter suss read in full counts as the handler's own code. (#1245, #1248, #1251, #1264)
- Test code no longer pairs with the app, a value that only test code writes no longer decides one production code reads, and a spec whose parameter names differ from the route's still pairs. (#1245, #1256, #1270)
- False findings from shared middleware, built Workers, duplicate specs and stories, duplicate document notes and a guessed overload. (#1262, #1269)
- Findings on one pair that say the same thing merge into one, and "several situations under one status" fires only when the bodies differ. (#1270, #1271)
- A client that hands its response to a helper of its own gets the helper's status tests, and a status test with no return in either arm keeps both arms. (#1266, #1272)
- A URL suss cannot pin down no longer pairs with routes it cannot reach, and route paths are written so the other side can meet them. (#1243, #1267)
- Statuses FastAPI, NestJS and Nest exception classes send, the database a Prisma schema says, and the redirect
headhands back in requests and aiohttp. (#1244, #1249, #1256, #1260) - Next.js routes a route file re-exports or wraps, Storybook stories through path aliases, and props read in render props and hook callbacks. (#1259, #1265, #1269)
- One unit that cannot be read no longer ends a TypeScript extract,
extract --dirreads only the directory it was given, and a chain of callbacks stays inside the path budget. (#1241, #1250, #1252) - Resolution questions about a call's receiver and an argument's parameter no longer walk the whole project, and the Ruby and Python adapters keep fewer syntax nodes in memory. (#1254, #1255, #1268)
- The extraction cache hits again for three or more packages under one tsconfig, and
inspect --diffno longer reports a PATCH route's twin PUT or a renamed exception the controller still rescues. (#1260, #1261, #1265)
0.34.0 (2026-09-28)
0.34.0 lets a coding agent's edits be checked as it makes them, and lets an intent document say where a value has to come from.
Breaking changes
A check that pairs nothing fails. A bare suss check, check --dir and check --since over summaries that pair nothing exit 1 with a nothingPaired finding under run, where they used to print "no findings" and exit 0. A two-file check with an empty file does the same, and its --json output on that failure is { findings, run } instead of a bare array. Pass --allow-empty where an empty run is expected. (#1208)
check counts what paired. The report opens with Compared 2 of 3 boundaries, and "every compared boundary agreed" appears only when every boundary paired. unmatched.providers and unmatched.consumers now include a summary that paired somewhere else but not at its own boundary, so a job that gates on --fail-on-unpaired sees more entries than before. (#1230, #1234)
Flags a command cannot use are refused. init and inspect refuse a flag they do not take, check --dir X file.json is refused where it used to ignore the file, and extract --out-dir refuses --gaps, --timing, --datalog-profile, --no-cache, --explain and --fail-on-pack-error. init --write writes suss.json and each pack's config file without prompting, and without the flag it only prints. (#1208, #1234)
A NestJS @Post() handler returns 201, @HttpCode(n) sets the status, and every route under app.setGlobalPrefix("api") has the prefix in its path. A spec written without the prefix stops pairing with the code, and one written with it starts. (#1231, #1235)
Every Python and Ruby summary used to say confidence: low, a setting left over from when those adapters read only decorators. They now carry the score the adapter works out, the way TypeScript summaries do, so most say high. (#1231)
unlinkedScenario is a warning, where it was info. A project that runs suss check --intent with --fail-on warning and has PRD scenarios with neither a link nor a covering test will start failing. Give each one a link or a covering test, or accept it with a .sussignore rule that gives the scenario's title under the new scenario field. (#1217)
Smaller changes a run shows:
- The first run after upgrading is cold, because the cache format changed.
suss-mcpwrites.suss/live/into the project. (#1226) - A relative pack config path in
suss.jsonresolves from the directorysuss.jsonis in, not the working directory. (#1195) sussandsuss-mcprestart themselves with a larger heap, up to 8 GB, whenNODE_OPTIONSsets no size. A start takes about 50 ms longer, and a large project's peak memory is higher because V8 collects less often. (#1222)- A Python or Ruby environment read no longer says
deploymentTarget: "lambda"orinstanceName: "<unknown>", soinspect --diffacross the upgrade shows a binding change on every read. Findings are the same. (#1213) - A Drizzle
db.query.<key>whose key does not resolve to a table records the access with no table, instead of using the key as the table name. (#1181) - An EventBridge send whose detail type can be one of up to 16 strings becomes one send per string. (#1156)
For library callers:
readCallFacts,functionOf,CallFactsandFunctionKeymoved from@suss/clito@suss/checker. (#1213)- In
@suss/resolution,answersByKey,singleAnswersandplaceholderValuesare gone. Read answers throughanswersFor,writtenAnswersFor,resolvedFunctionsorsettledFunction. The relationholdsDefaultis nowdeclaresDefault, andwrittenValueUndertakes a fourthaskargument. (#1212, #1190) - A
transparentWrappersentry is{ module, name, argument }instead of{ callee, argument }. (#1186) @suss/valueskeeps a set of up to 16 strings as a set, where it used to widen past 4, so summaries with enum-like values change. (#1156)- Several unions gained members, which breaks an exhaustive
Recordover them at compile time: atestunit kind, anexitoutput, astream-writeinteraction, four intent finding kinds, and theunreadValueandunfollowedCallunchecked reasons. Every new field is optional, so a 0.33.1 summary parses on 0.34.0. A 0.33.1 reader rejects a 0.34.0 file that contains one of the new kinds. (#1217, #1224, #1229, #1238)
A Claude Code plugin checks each edit an agent makes
The supervisor plugin runs suss inside the agent's session. After each edit it re-reads the files that changed and tells the agent what the edit did, before the next edit. When an edit adds a 409 to an Express handler whose client does not handle it, the agent is told unhandledProviderCase at POST /orders with both locations, and the next edit's report says the finding is resolved. When the agent stops, the plugin lists every outcome the session changed on both sides of the boundary.
Before it starts, the agent writes down what it means to change (adds, removes, changes, exits:), and when it stops, suss intent check compares that list against what the session changed. extract --out-dir writes the summaries for a partial run, and check --since <dir> reports only the findings that are new since a snapshot. Supervise an agent has the setup. (#1201, #1203, #1206, #1209)
The MCP server keeps the program in memory between edits and serves the plugin's hooks over a local socket, and the hooks fall back to the CLI when no server is running. A partial TypeScript run reuses what it worked out for every function the edit did not touch, and Python and Ruby reuse the cache file by file. A leaf edit on a NestJS service went from 14.4 seconds through the CLI to 7.6 through the live server, on a Rails app from 5.0 to 3.0, and on a FastAPI app from 8.1 to 6.5. (#1215, #1218, #1226, #1233)
An intent can say where a value comes from
suss records where each column a query picks rows by, and each column it writes, got its value, and an intent document can say where it has to come from:
results:
- reads: postgresql:orders
by: [tenant_id]
from: { tenant_id: input.auth.tenantId }When the code takes the tenant from somewhere else, such as the request body, suss check --intent reports valueFromElsewhere with both sources. A conversion such as Number(x), int(x) or x.to_i does not break the chain, and a value that goes through a helper suss cannot see into is listed as unchecked. This works in TypeScript, Python and Ruby, including SQLAlchemy filters and raw SQL placeholders. (#1229)
A PRD scenario can name the test that covers it
A scenario lists the test that covers it under coveredBy, and suss reads tests from vitest, pytest, RSpec and Minitest:
coveredBy: packages/checker/src/since/changesSince.test.ts > findingsSince > splits the findings into new and gone, by identity
coveredBy: tests/test_orders.py::TestCancel::test_second_time
coveredBy: spec/models/order_spec.rb > Order > #cancel > changes nothing the second timesuss extract -f vitest --intent intent/ (or -f pytest, -f rspec, -f minitest) reads only the tests the PRDs list, and suss check --intent intent/ reports a listed test that is gone or renamed (missingCoveringTest), one marked to be skipped (coveringTestSkipped), and one whose calls never reach what the scenario is about, or reach it only through a mock (testMissesSubject). A test whose calls suss could not follow is listed as unchecked instead of reported as missing its subject. A test runs through its fixtures, let and subject blocks, setup methods and shared examples, and new factory-bot and fabrication packs say which class a factory builds. On mastodon, 164 of 204 listed RSpec examples count as covering their model and 10 are unchecked. A scenario can also say an effect happens on every path with always. (#1211, #1217, #1238)
The activerecord pack reads scope :x, -> { ... } as a class method on the model, so a call to a scope reaches the scope's body and its table reads, including a scope a concern defines. (#1238)
What a command prints and how it exits
Writes to stdout and stderr are recorded as effects, process.exit, sys.exit and exit end a path with their code, and the flags a command reads from its parser are its inputs, in all three languages. An intent can say exits: 1, and check --intent compares it. (#1224)
Repositories and clients a framework injects
A new typeorm pack reads repositories and data sources NestJS injects into a constructor, and the Prisma pack finds a PrismaService that extends the client. On twenty-server, 517 of 578 repository calls now record their table, and on ghostfolio, 136 of 140 calls through an injected Prisma client do. The Prisma pack also finds a client before prisma generate has run, one built in another file and imported, and one from Prisma 7's new generator. On immich, none of the 303 NestJS handlers paired with its OpenAPI spec in 0.33.1, and 287 pair now. (#1225, #1231, #1235)
Modules inside one app
suss.json can list an app's modules, and each module's exports get their own keys, so inspect --diff shows when a change adds a call across a module line. suss reports the crossing and enforces nothing. (#1219)
A test app that a function builds and drops
suss skips a mount or a route on an app that never leaves the function that built it, in all three languages, so a test helper that builds a second app no longer adds its routes to the router. (#1227, #1228)
Performance
Each number is the median of three alternating cold runs of 0.33.1 and 0.34.0 on the same checkout:
| Corpus | 0.33.1 | 0.34.0 | Change |
|---|---|---|---|
twenty-server, with the packs suss init picks | 190.5s | 105.6s | −45% |
| twenty-server, with the benchmark packs | 49.7s | 47.7s | −4% |
| twenty-front | 146.7s | 120.9s | −18% |
directus api/ | 111.5s | 92.5s | −17% |
| saleor-dashboard | 58.5s | 52.3s | −11% |
| saleor-storefront | 8.2s | 7.5s | −9% |
| mastodon (Ruby) | 35.0s | 23.0s | −34% |
| dispatch (Python) | 11.8s | 14.7s | +25% |
dispatch is slower because 0.34.0 reads 52% more of it: Python imports now resolve under the source directory pyproject.toml declares, so 1,064 functions get a summary where 701 did. Peak memory went from 1.5 GB to 1.9 GB on dispatch and from 3.0 GB to 3.7 GB on mastodon, partly from the extra code and partly because the CLI now starts with a larger heap and collects less often. (#1199, #1202, #1216, #1236)
Fixes
- A warm cache run gives the same output as a cold one, and a file edited while a run reads it is read again next time. (#1197, #1226)
- A class method and an instance method with the same name are told apart. (#1200)
- A write through a name, such as
client.timeout = 5, reaches later reads of that property. (#1194) - An unannotated parameter gets the type its callers declare. (#1161)
- Python imports resolve under the source directory
pyproject.tomldeclares. (#1159) - Ruby looks up an included module the way Ruby does, and reads
concernsdirectories. (#1160) - A client typed as an intersection, a union or an alias is recognized. (#1182)
- A why question on a large Ruby project finishes in seconds instead of running for hours. (#1183)
- A queue consumer is scoped to the files its handler entry imports. (#1155)
- An environment read used only behind a presence test counts as defaulted. (#1157)
- Contract bodies are compared when a source that gives only a range comes first. (#1179)
- Types are written the same way whatever order the checker read them in. (#1221)
--jsonusage errors print{"error"}on stdout forcheck,inspect --diff,inspect --flowandintent outcomes. (#1234)
0.33.1 (2026-09-23)
0.33.1 takes back the time 0.33.0 added to a TypeScript service that reads its environment through helpers.
Fixes
- The node pack works out once per run which helpers read an environment variable named by a parameter, and which parameters are handed
process.env. It used to ask at every call to a project function, reading the called function's file each time, and asked again at everyparse(text)call whethertextwas the environment, reading every file that imports the call's file. twenty-server took 53 seconds with 0.33.0 and 44 with 0.32.0, and takes 41 now, the median of three alternating runs; the variables it reports are the same. (#1153)
0.33.0 (2026-09-20)
0.33.0 reads a large project in a fraction of the time 0.32.0 took, follows the work a file does when it loads, and reads the SQL a service writes by hand.
Breaking changes
An intent document with an unknown key no longer loads. The document, its transitions and its scenarios are checked strictly now, the way the boundary block already was, so a document that said scenario: instead of scenarios: stops the run and says which key it did not recognise instead of checking nothing and passing. (#1108, #1109)
A transition lists each effect once, with a count saying how many sites produced it, where it used to repeat the effect once per site. (#1141)
deploymentTarget and instanceName on a runtime-config binding are left off unless a run configured one; every read the node pack emitted used to say lambda. A summary written by 0.32.0 still validates. (#1129)
A Terraform value nobody settled is spelled {var.image} in the image, runtime and entryPoint fields, the way every other field already wrote it. (#1127)
Reading a project takes a fraction of the time
Zulip's zerver/ directory, 1,676 Python files, took 624 seconds to read with 0.32.0 and takes 14 with 0.33.0. Mastodon's app/ directory, 1,254 Ruby files, went from 8.9 seconds to 5.1. In TypeScript, directus's api/ went from 372 seconds to 90, twenty-front from 231 to 115, and saleor-dashboard from 57 to 46, each the median of three alternating runs of the published 0.32.0 CLI against this release. twenty-server went the other way, from 44 seconds to 53, because the node pack's new environment questions pull more files into the reachable closure on that corpus than the engine work saved; that is the next thing on the performance list.
The rule engine keeps a relation's facts in a trie over their parts, so checking whether a fact is already known is one map lookup per column instead of building and hashing a string. The Python and Ruby adapters ask the rules once per file instead of once per call site. Reading where a value was written goes through the engine's index instead of scanning every row. And the TypeScript adapter checks whether a module key is a path before asking ts-morph for it, since a bare package name made ts-morph compare path segments against every source file and find nothing. (#1137, #1143, #1144, #1145, #1147)
A job with no handler is read from the top of its file
A script a scheduler or a container runs does its work at module scope, calling the functions it defines, and none of those calls used to be followed. Module scope is now a root of the reachable closure in all three languages, so suss ask "what does jobs/sync.py reach" lists the table reads and writes those calls make, and the file's module-init summary lists the calls. (#1130, #1132, #1138)
SQL a service writes itself is read as table access
New packs read node-postgres and BigQuery in TypeScript (-f pg, -f bigquery), the BigQuery client and the Airflow BigQuery hook in Python (-f bigquery-python), and the pg gem and google-cloud-bigquery in Ruby (-f pg-ruby, -f bigquery-ruby). A table kept in a module constant is followed to the string, and a BigQuery project.dataset.table name lands as a scope and a container, so it pairs with a Terraform BigQuery table. (#1113, #1115, #1119)
ActiveRecord's own ways of running a statement are read too: connection.execute, exec_query, select_values, find_by_sql and count_by_sql. (#1125)
The SQL reader covers a parameter with a cast, a select that locks its rows, a WITH clause in front of an insert or a delete, a derived table in FROM, INSERT ... SELECT, UPDATE ... FROM, set operations, and what RETURNING and ON CONFLICT touch. (#1124, #1134)
Terraform declares what it deploys
An aws_lambda_function, an aws_ecs_task_definition container, a Cloud Run service or job and a Cloud Functions function each become the runtime-config boundary the CloudFormation reader already wrote for a Lambda. The reader expands a dynamic "env" block over a map, follows a module block into the local directory it points at, and takes --code-scope api/web=services/api so a container has code to pair against. Fourteen more resources are read, among them RDS, Kinesis, Firehose, CloudWatch, BigQuery, Cloud SQL, Spanner, Firestore, Bigtable and Pub/Sub, and a store whose engine is a variable with no default is reported with no engine instead of dropped. (#1114, #1116, #1127, #1136)
Environment variables read through a schema or a factory are reported by name
A service that hands process.env to a zod, valibot, envalid, znv or t3 env schema used to be reported as one read of the environment object and no names. The node pack now reads the schema's keys, says which have a default, and finds the parse through its callers when the environment arrives as a parameter from another file. A helper a factory returned is followed in all three languages, so requireEnv("TABLE_NAME") where requireEnv = makeReader(process.env) is a read of TABLE_NAME. __dirname, import.meta.url and process.cwd() are no longer environment reads. (#1094, #1095, #1129, #1135)
An intent document says what a boundary receives
A receives block on every boundary kind declares the fields a boundary is handed, and suss check --intent compares it against what the unit reads. For a REST route the block is written as headers, query, params and body, and each REST pack says where its handlers read those. @suss/intent-ir publishes a JSON Schema for intent documents, and suss intent outcomes --from intent/ lists the outcome ids a PRD scenario can link to. (#1108, #1109, #1111, #1118)
The CLI reads suss.json when given nothing
suss extract with no -f reads the packs from suss.json, or picks the ones suss init would when there is no file, and suss inspect and suss check given nothing read the current project first. (#1090)
Ruby follows dataloader sources and model callbacks
A GraphQL field that loads through dataloader.with(Sources::CampaignSource, Campaign) reaches the source's fetch, and a write through a model reaches the callbacks its class registers for that event, including one registered on ApplicationRecord. (#1128)
Fixes
- A Prisma client generated into the project's own directory is read, and a pack whose library is installed but whose gate selected no file says so in the pack health report. (#1106)
- An axios instance's
baseURLis put in front of every path it sends. (#1093) - A client path through
encodeURIComponent(id)reads as/messages/{id}instead of/messages/undefined. (#1092) - A shorthand property in an object argument is read by a pack's property pick. (#1123)
--fileswith a tsconfig gives the same summaries a full walk gives those files, and--files a bkeeps both. (#1126)suss inspectsays which variable a config read takes, andinspect --diffno longer folds two variables read through one chain into one line. (#1089)- The node pack asks the resolution rules whether a value is the environment instead of reading the syntax itself. (#1142)
- A finding no longer says "makes POST POST /v1/charges", and the retired-option warning stops naming a release that never refused them. (#1105)
- The petstore example has a manifest and calls its spec
openapi.json, sosuss initfinds both. (#1091) - The docs are at suss.sh, and every page has been rewritten with its output blocks run through the built CLI. (#1096 through #1104, #1110, #1112, #1117, #1120)
0.32.0 (2026-09-17)
0.32.0 reads an environment variable through a project's own settings helper, in all three languages, through one rule instead of three walks.
An env var read through a helper is reported at the call that passes its name
A helper like def env(key, default=None): return os.environ.get(key, default) used to hide every variable behind it in Python and Ruby, and TypeScript found them through a walk of its own in the node pack.
Each adapter now states one fact at the read site, and one rule in @suss/resolution works out which calls supply the name, through any number of helpers. suss ask "what does settings.py reach" lists DATABASE_URL and AWS_REGION where it used to say the file crosses no boundary.
The TypeScript walk is gone with it, and a cold extract of this repository takes 26.4s where it took 33.7s, finding the same reads. A helper that is a value rather than a function definition, functools.partial or a lambda in a constant, is not followed yet. (#1086)
Fixes
- A bare call inside a Ruby module constant is looked up on that module. (#1087)
0.31.0 (2026-09-14)
0.31.0 follows a value to the place it was actually written: the construction of a class, the caller of a wrapper, the module that defines a GraphQL fragment.
A class handed part of its request when it is built is read once per construction
A class that takes a path prefix in its constructor, constructor(private base: string), calls client.get(this.base) in one method and interpolates the same prefix into a longer path in another. Both used to come out with an empty path: GET ? and GET {base}/{id}. The prefix is written as two different literals, one per construction, and a value with two writes and nothing to choose between them comes down to nothing.
Each adapter now asks where the surrounding class was built, reads the path again under each construction, and reports one client call per path it finds. A file that builds new Resource("/users") and new Resource("/orders") gives GET /users, GET /orders, GET /users/{id} and GET /orders/{id}, and each of those pairs with the route that serves it where before none did.
The same class in Python, with self.base set in __init__, goes from one summary to three. In Ruby, with @base read in a method that calls Faraday.get, it goes from nothing at all to one route per construction. The request method is read the same way when a pack takes it from an argument, so a Ruby class handed Net::HTTP::Get.new by one construction and Net::HTTP::Post.new by another reports a GET and a POST.
A class the run never builds is unchanged, two constructions that ask for the same thing stay one call, and a class built by a factory function rather than by its own name has no construction this question can find. (#1069, #1070, #1071, #1075, #1076)
A request through a generated HTTP client is read as one request per service method
A client generated from an OpenAPI schema, and a hand-written one in the same style, builds its request out of a config object. UsersService.readUsers() calls client.get({ url: "/api/v1/users/" }), client.get is an arrow on an object literal that adds method: "GET" and calls the project's own request, and only request reaches axios. suss reported one summary for request with no path and the verb its pack defaults to, which was wrong for every call that was not a GET, and nothing at all for the service methods where the paths are written.
Each hop is now a question about a value. The resolution facts say which calls filled the wrapper's parameter and what each wrote there, the request object at the library call is evaluated again with the parameter bound to that, and it repeats outward while a hole is left.
On a three-file client in that shape suss now reports readUsers as GET /api/v1/users/, createUser as POST /api/v1/users/ and deleteUser as DELETE /api/v1/users/{user_id}, with the verb read off the caller's own object rather than the pack's default. The callers come from the same rules the GraphQL hook expansion uses, so a wrapper reached through an alias or a barrel is followed, three deep. A method on an object literal is now found from its callers, and a receiver written as options?.client ?? client resolves through the fallback. A client whose path is built by a helper that loops, or one whose parameter is destructured in the signature, still comes back with its path open. (#1047)
A component that calls the project's own GraphQL hook is read as the operation it sends
Production frontends rarely call useQuery directly. They write one useAppQuery that picks the client or adds telemetry and have every component call that, so the document argument inside the hook is the hook's parameter. suss reported one summary, useAppQuery.query, with a gap saying the argument did not resolve, and nothing at all for the several hundred components that name an operation.
Such a hook is now read as one operation per caller, with the caller's document and at the caller's location, so operationScopes applies per component file and the caller's const { data, error } = useAppQuery(Doc) reads the way it reads off useQuery. The callers come from the resolution facts, which follow an alias and a re-export barrel, and a hook in front of another hook is followed to the component at the top, three deep.
The hook's own summary goes away once every caller has one. Where a caller cannot be followed it stays, and metadata.graphql.unresolvedDocument.reason says how many were read and how many were not. The same applies to client.query({ query: doc }) written behind a project function. Which client the hook's other arguments pick is not read. (#1039)
A GraphQL operation that spreads a fragment by name gets the definition from the file that writes it
A project generated with graphql-codegen's client preset keeps fragment UserCard on User { ... } in the component that renders the card and spreads ...UserCard from a query in another file, importing nothing: codegen finds the fragment among the project's documents and puts it in the document it emits. suss read the template as written, so the operation's document came back with ...UserCard and nothing behind it. The spread was then taken off the summary, because the project did define the fragment somewhere, and the operation passed as checked with the fields selected through the fragment never walked. On a project written this way that is most of the operations.
A document built by the project's own generated tag now takes each spread's definition from an index of every fragment the project defines, in a TypeScript document or a .graphql file, following a fragment that spreads another, so a field the schema does not declare is reported wherever it was selected.
A document built by a library's tag (@apollo/client, graphql-tag) is still read as written, since that text is what ships, and a spread it does not define stays in metadata.graphql.unresolvedFragments. A name the project defines twice with different bodies is taken from neither and comes back in metadata.graphql.ambiguousFragments, with the finding saying which it was. (#1038)
A shared field list is spliced into the operation that interpolates it
A frontend that wants the same selection in several operations writes const RESULT_FIELDS = `id title publishedAt author { id name }` and puts ${RESULT_FIELDS} inside the selection set of a gql template. The string is neither a document nor a fragment, and every rung of the document reader wanted a document, so the interpolation was dropped, no document was stored, and metadata.graphql.unresolvedDocument said the expression inside a selection set did not resolve to a GraphQL document. On a frontend that shares field lists this way that was the largest remaining reason an operation went unread.
Such an interpolation is now spliced as the text it is, whether it is written in place, declared in the same module, or imported from another module or through a barrel. A constant built out of other constants is assembled the same way, and a document interpolated inside one still splices as a document. Whether the result is GraphQL the schema accepts is left to the parse that already runs on the assembled document, so a field the schema does not declare is reported wherever it was selected. A name the reader cannot settle on a single write, and a pair of constants whose text interpolates each other, leave the operation unread with the reason they gave before. (#1040)
0.30.0 (2026-09-11)
0.30.0 stops a route's summary multiplying out by its middleware, and teaches the Ruby adapter what a database call actually is.
A route lists what its middleware responds with, once, instead of multiplying its own outcomes
A Rails action behind ten before_action filters, or an Express route behind a chain of app.use, used to get every one of its outcomes paired off against every pass-through of every filter, with each filter's effects copied onto each pair. On Discourse that made PublishedPagesController#check_slug, a four-line action with two outcomes, a summary of 254 transitions and 9,994 storage effects, 3.4 MB for one action, and the run's whole output was 2.55 GB.
Composition now keeps the unit's own transitions and lists each wrapper's responses beside them, tagged with which wrapper produced them under wrappers.from. A pass-through contributes nothing, because the unit's outcomes already say what happens once the request gets through, and metadata.wrappers.applied gives the chain in the order it runs.
What a filter does on the way in stays in the filter's own summary, and the CLI follows the chain the way it follows a call: inspect shows a filter's table reads once under Reaches: with the filter as the hop, inspect --diff compares them there, and why prints the hop as "registered around".
check_slug comes out at 23 transitions and 256 effects, and its inspect block at 320 lines where it was 10,840. Across Discourse the summary file goes from 2.55 GB to 211 MB, transitions from 108,444 to 26,416 and storage effects from 166,584 to 20,129, with the distinct storage accesses across the whole run unchanged at 315. Anything that reads one summary on its own now has to follow wrappers.applied for the effects along the whole request, the same as for a callee.
The Ruby adapter also builds the filter chain the way Rails does: a before_action declared again in a subclass moves that filter to the end of the chain with its new options rather than running it twice, and a skip_before_action followed by a new declaration puts the filter back. (#1035)
A Ruby storage effect is recorded only for a method ActiveRecord defines, with what it was given
Tag.new(name: x), ApplicationRecord.transaction do, and a project's own SiteSetting.login_required? were each reported as a database read, because every call on a chain that started at a model constant counted. The effect said nothing about what was read either: no selector and no fields. Discourse's most common storage line was a project method, Notification.read.
A call is database work now when the activerecord pack lists its method as a read or a write, the class behind its receiver reaches ActiveRecord::Base, and the project does not declare that method itself. A constructor, a transaction and a project method say nothing here, and the reach walk follows them instead. The same rule covers a receiver written as a constant and one the rules settle, so a read through @account is recorded the way a write already was.
The effect says what the chain was given to pick rows by, id for find(params[:id]) and the keyword keys of every where and find_by along it, and which columns a write sets or a pluck or select asks for. A chain counts once, at the outermost call the library defines, so Status.find(id)&.proper keeps its find.
On Mastodon 266 distinct storage lines go away and 124 arrive. Api::V1::StatusesController#create used to say Status read find and Status read proper, and now says Status read find | id twice, from set_thread and set_quoted_status. On dispatch (Python) a call to a project function annotated -> Session is no longer a read against no table. A Rails scope :name, -> { ... } reports nothing now, since a scope is not a def and the walk cannot follow it yet. (#1033)
A Ruby method a mixin declares is found from the class that includes it
include Account::Associations said nothing to the facts the resolution rules join on, so a method a concern declares was never found from the model, and the statements inside included do and with_options do were lost with it.
A mixed-in module is now part of the class's ancestry, beside its superclass, and the statements of those blocks are read as statements of the class around them. has_many :statuses becomes a fact, one shared rule makes @account.statuses settle on Status, and find on it composes the way Account.where(x).first already does. Chains like @account.statuses.destroy_all, whose receiver can now be settled, are recorded as writes.
The sqlalchemy and sqlmodel packs say the same for a field given relationship(...) or Relationship(...), with the target read from list[Item], Optional[Item], Mapped[list["Item"]] or the first argument. (#1031, #1032)
Fixes
- A Ruby
define_methodloop over a method that gives back a literal list is read, so Discourse'sDiscourse.filters.each { |filter| define_method(filter) { ... } }no longer stops every lookup onListController. Its gaps that mentiondefine_methodgo from 72 to 29. (#1029) - The calls whose block runs as part of a Ruby class body,
included,prepended,class_methods,concerningandwith_options, come from the rails pack rather than a table in the adapter, and adefinsideclass_methods dois a class method. The adapter also readsconfig/initializers/inflections.rbin full, so a project that registersinflect.irregular "person", "people"getshas_many :peoplepointed atPerson. (#1034)
For contributors
Wrapper composition adds responses and never multiplies. composeWrappers in @suss/extractor keeps a unit's own transitions and puts each covering wrapper's non-delegate transitions beside them with wrappers.from; the splice, the pass-through product, the error-handler product and the MAX_PATHS budget are gone. Pairing a WrapperReference with its summary moved to @suss/behavioral-ir as wrapperIndex, wrapperFor and wrapperChain, so the extractor and the CLI share one. readCallFacts in the CLI states a wraps(unit, wrapper, name) fact per chain link, calls has a fourth record kind wraps, and the reach rules follow it; directCallers leaves it out, since a wrapped unit does not call its wrapper.
RbStoragePattern states what the library defines and what each call means. reads is required beside writes, and a method in neither is not database work, whatever it is called on. byPrimaryKey gives the finder methods and the column a positional argument picks by, columnArguments the read methods whose symbol arguments are columns, and associations the class-body calls that declare an association, split by singular and plural spelling. The Python side gained PyModelQueries.relationships, and declaresAssociation(cls, n, t) is the shared fact both adapters supply and one rule in @suss/resolution reads.
A Ruby class body's dynamic declarations are facts, and its body blocks come from the pack. The value-fact walk states definesMethodFrom(classKey, exprKey) and nameTurnsOn(exprKey, element, index, overKey) for each define_method and the loops around it, and defineMethod.ts reads no source: it evaluates each name with the shared evaluator and adds declaresName(classKey, name). RubyPack.bodyBlocks replaces the adapter's own table, each entry saying whether the call is module-only and whether a def in it is a class method. RubyPack.inflections is what the project taught its inflector, and include and prepend emit extends(class, module).
0.29.0 (2026-09-10)
0.29.0 follows a Ruby call through the places a Rails app actually puts one: an instance variable, a no-argument method, the value a finder gave back.
A Ruby write through an instance variable or a local is recorded against the model
A Rails controller sets @account = Account.find(params[:id]) in a before_action and the action writes through it, @account.update!(suspended_at: Time.now) or @account.save, and none of that reached the summary: the storage recognizer found the model by the constant a chain starts at, and there is no constant here. Mastodon had 168 such write calls saying nothing.
A call whose method a pack counts as a write now asks the same rules the reach walk asks about its receiver, and records a write against the class they settle on, so long as that class reaches a base the pack lists. Across 0.29.0 as a whole, Mastodon goes from 39 writes to 163 and Discourse from 54,698 to 57,657; this change on its own leaves reads, summaries and gaps unchanged.
Only a write is recorded, because a read on an instance is as likely an attribute read or a project method the walk follows. A call whose method the project writes itself is left out, since the walk steps into that body and the body reports its own effects. A block parameter is still not bound in the facts, so orders.each { |o| o.save } says nothing. (#1025)
A Ruby or Python call on what a database finder gave back is followed
@account = Account.find(params[:id]) in a before_action and @account.suspend! in the action said nothing, because find runs inside ActiveRecord, which suss does not read, so the value had nothing behind it.
A pack now says which of its library's methods give back one of the class. The activerecord pack lists the finders and the relation methods ActiveRecord defines, and a call on a class that reaches ActiveRecord::Base settles on that class, so @account.suspend! reaches Account#suspend! and a chain such as Account.where(x).first composes. The sqlalchemy and sqlmodel packs say the same for session.get(User, id), session.query(User) and select(User), where the class is an argument rather than the receiver, and a model under DeclarativeBase, SQLModel or a declarative_base() base is recognised. TypeScript already read the return type from the library's typings.
On Mastodon summaries go from 1807 to 1822 and on Discourse from 3381 to 3530; on dispatch (Python) gaps go from 135 to 127. dispatch runs about 13% slower, since the rules now walk the longer query chains. (#1021, #1023)
Fixes
- A Ruby call written with no arguments is read as a method call when the rules settle its receiver on a class or a function the run defines, and as a property read otherwise.
Filter.new(scope).resultsnow reachesFilter#results. Mastodon summaries go from 1822 to 2157 and Discourse from 3530 to 4699, and the gaps rise with them because each newly reached body reports its own stops. Discourse takes about 145s where it took 120s. (#1024) - A Ruby method a
define_methodloop writes is looked up by name instead of stopping the lookup with a gap, which used to hit every admin settings controller in Mastodon through oneForm::AdminSettings.new. Mastodon gaps go from 125 to 117. (#1020)
0.28.0 (2026-09-10)
0.28.0 gives all three adapters one shared answer to "what does this name come down to", and Ruby gets instance variables with it.
A Python or Ruby call through a reassigned local is followed
query = Entity.new followed by query = query.filter(2) and render json: query.all, or the same in Python, left a gap saying the value could not be settled, and so did a name written through parentheses, an alias chain (a = Entity.new; b = a; c = b; c.run) and a builder method that returns self. Each adapter had its own reading of what a reassigned name comes down to, and only the TypeScript one followed these.
The three adapters now record every write to a name as a fact and one shared policy decides: a write that narrows the name (x = x.filter()) is set aside, a name whose writes run once each in order comes down to the last one, and a name two branches write differently is reported with both sources. Where a call still stops, the summary says which of these it met: the value came from a dependency, from a caller's argument, from more than one place, or from nothing the run declares.
On the dispatch corpus (Python) gaps go from 491 to 135 and three more units are bound. On Mastodon 122 more calls are followed, summaries go from 1745 to 1801 and gaps from 914 to 121; on Discourse summaries go from 3003 to 3355 and gaps from 3562 to 1030. Most of the vanished Ruby gaps are calls on a value a Rails method built, params.require or user.orders say, which used to be reported as unsettled and are now classed as declared nowhere in the run, the same class the TypeScript adapter already leaves out. Mastodon runs about a second slower (7.4s to 8.4s) and Discourse about thirty seconds slower (102s to 133s), because every reassigned name now goes to the rules. (#1009, #1010, #1011)
A Ruby call on an instance variable another method set is followed
A Rails controller writes @suggestions = AccountSuggestions.new(current_account) in a before_action and calls @suggestions.remove(params[:id]) in the action, and nothing tied the two methods together, so every call on an instance variable stopped with no declaration.
An instance variable is now read as a property of its class, written wherever the class body writes it and read wherever it is read, and a write in a base controller reaches a read in a subclass through the ancestry. @scope = @scope.where(a: 1) narrows the name and is set aside the way it is for a local; two methods that build the variable from different classes leave a gap saying it has more than one source.
On Mastodon 7 more calls are followed and on Discourse 21, and eight links that were wrong go away: @announcement.update used to be matched by name to the enclosing controller's own update action, and now settles on the model, where update is ActiveRecord's. Most instance variables in a Rails app are still unfollowed, because @account = Account.find(params[:id]) assigns what a finder gave back and no pack yet says a finder returns one of the class it was called on. (#1016)
Fixes
- A Ruby constant that two files open again is bound.
module Jobsopened in hundreds of files, or a class reopened in a concern, bound to nothing because two definitions used to settle nothing; every reference to such a constant now binds to the first body and the lookup by name reaches the rest. A bare assignment such asUser = Data.define(...)in a script besideclass Useris still a guess and stays unbound.
For contributors
A call's callee comes from the rules, and the rules say why it is nothing. calleeOutcomes(db, keys, program) in @suss/resolution asks the resolution program what each callee key comes to and returns a CalleeOutcome: one function, one object, several sources, a parameter a caller supplies, a value from outside the run, a value the rules could not settle, or nothing declared. The Python and Ruby adapters both read their callee from it, and the Ruby adapter asks about the receiver instead and lets ancestry.ts pick the method, because the shared contains rule has no method resolution order. A gap is recorded only for the outcomes that name something the reader failed at. (#1012)
Whether a name's writes run in order is decided by one shared walk. writesRunInOrder(scope, name, writes, rules) and startsAtName(node, name, rules) in @suss/resolution take a NameReads description of the grammar and each adapter passes its own. Python and Ruby used to carry a copy of that walk each. nameTypes is a set because Ruby spells a name two ways, bare and @name. (#1015)
A Ruby instance variable is a holdsProperty on the class. facts/values.ts collects every write to @name across a class body and emits holdsProperty(classKey, "@name", value) for the value the writes settle on, or one fact per disagreeing write; every read emits readsProperty(readKey, classKey, "@name"). Nothing orders two methods, so valueLeftByWrites(writes, false) decides. A write in a singleton method is collected with the rest, which over-approximates: def self.x writes a class-level variable, a different object. (#1016)
0.27.0 (2026-09-10)
0.27.0 is a sweep across Rails routing, Express and Hono middleware, and Python parameter annotations, driven by what a run over production applications kept missing.
A Rails app gets its routes read, however they are written
The rails pack read resources, namespace, scope and the bare verbs, and stopped everywhere else, so an app that keeps its routes in config/routes/*.rb had almost every action unbound. On Mastodon, 37 of 583 actions had a method and path before this release.
Six changes landed across the release and each moved that number:
- The pack reads
draw(:name)under the scope it was written in,constraintsblocks,with_optionsdefaults,concernandconcerns,match ... via:, andmodule:on a resource. A singularresource :settingsroutes toSettingsController. Mastodon goes to 559 of 583. (#979) - A route written inside
memberorcollectionwith its own target is placed under the resource, soget "logs/:id" => "backup_logs#show"insideresources :backupscomes out at/backups/logs/:idrather than/logs/:id. On Discourse, 474 of 1251 actions had a method and path; 654 do now. (#990) - A routes block looped over a literal word list is walked once per element with the parameter bound, so
%w[users u].each do |root_path| ... enddeclares both routes. On Discourse, 751 of 1251. (#991) - A Rails engine the project keeps in its own tree is mounted. A project says where its engines are with
engineRootsin the pack's config, and the pack reads each engine'sisolate_namespaceand routes file and serves them under the mount path. On Discourse, with{ "engineRoots": ["plugins/*"], "routesFiles": ["plugins/*/plugin.rb"] }, 1138 of 1251. (#992) - A controller under a registered acronym is found: a project that writes
inflect.acronym "ActivityPub"keepsActivityPub::InboxesControlleratactivitypub/inboxes_controller.rb, and the pack now applies the project's own acronyms the way ActiveSupport does. A controller extendingActionController::BaseorActionController::APIdirectly is a controller too. On Mastodon, 582 of 603. (#993) - A routed public method a project ancestor defines, with nothing nearer overriding it, is reported as the subclass's action, located in the ancestor's file. On Mastodon, 608 of 629. (#994)
- A call the pack does not know, whose block has a routing call at its top level, is walked under the enclosing scope, so Devise's
devise_scope :user do ... endbinds the routes inside it and the file gets one gap saying the wrapper was read as though it changed nothing. On Mastodon, 615 of 629. (#995)
An Express or Hono route lists its own middleware, and every one of them is read
router.post("/", asyncHandler(login), respond) runs login first and respond only when login calls next(), and the adapter read the last function alone, so the route's summary was respond's body and the login logic, its 400s and everything it called, was missing with no gap saying so.
Every function listed before the handler is now read as a middleware of that route, the way app.use(fn) already is, and the handler is spliced in where each one hands control on. A middleware listed as an array is read in order, one a project factory returns is followed into the factory, and one a dependency's factory returns, passport.authenticate("jwt") say, contributes nothing, as it does at the app level. One with no name of its own goes by its factory and the route, asyncHandler@POST /login.
On directus's API, where 235 of 269 routes end in the shared respond handler, 25 routes pointed at a middleware before and 258 do now, and 244 routes gained transitions or effects. POST /auth/login now includes the login itself, the throw the driver leaves for the error handler and the call into the authentication service. A factory that returns what another factory returned, validateBatch(scope) giving back asyncHandler(fn), is still one hop too many, and each route that lists one says so in a gap instead of leaving it out. (#999)
A Python or Ruby storage effect is against the model, under the same label as TypeScript
A SQLAlchemy call came out as reads postgresql:sqlalchemy/select or writes postgresql:sqlalchemy.orm/session, which is the constructor or the variable the chain started on with the library module in front of it, and a Rails call came out as reads postgresql:ActiveRecord::Base/Order.
The Python adapter now takes the model from the call: select(Item), select(Item.id), session.get(Item, 1) and session.query(Item) say it in the first argument, select_from(Item) says it later in the chain, and session.add(item) takes it from what the function declares item to be, through an Annotated alias such as current_user: CurrentUser and through a project function annotated -> User | None. On the FastAPI template, every session call but commit now says whether it is against User or Item; session.commit() works on no table and comes out with the container unnamed.
Both adapters now write the same default scope every TypeScript pack writes, so the label is postgresql:Item in all three languages. check pairs a storage access with a schema on the scope as well as the table, so before this a Python or Ruby access could never pair with one. (#982)
Fixes
- A FastAPI parameter annotated with a name from another module gets its role.
item_in: ItemCreateandsession: SessionDepboth came out as query parameters, because the Python adapter read an annotation inside one file only. It now asks the import facts where the name comes from, through a re-export in an__init__.py. (#1005) - A Python function that calls a method on something a dependency built has no gap for it.
logger = logging.getLogger(__name__)followed bylogger.info(...)left an "unsettled value" gap on every function that logs. On the FastAPI template the run goes from seven gaps to one. (#1006) - A router mounted or a route declared under a condition is read. Both the Python and Ruby readers walked only the top-level statements of a body, so the FastAPI template's
if settings.ENVIRONMENT == "local":mount came out with no path. All three languages agree now. (#1004) - Two middleware written out at
app.usein one file are told apart. Both go byuse, and composition paired each reference with the first summary of that name, so a 401 check followed by a 403 check read as 401, 401, 200. A reference to a wrapper with no name of its own now includes the line it starts on. (#1003) - An Express route registered with
search,headoroptionsis read. The express pack routed six verbs; directus has 22 routes on the others. (#1002) - A
--filesrun composes a route's listed middleware the way a full run does, andinspect --diffkeys a block by boundary as well as by function, so three routes served by onerespondno longer collapse into one block. (#1000) - An Nx monorepo is read with its path aliases, at the root or in one app. The base file is read when the command points at the directory that keeps it, and a tsconfig with no files of its own takes its file list from the union of its references. (#997)
- A
fetchwritten throughglobalThis,window,selforglobalis read the same as the bare spelling. Afetchmethod on some other object is still left alone. (#998) - A Prisma call through a project's own client subclass is read. A NestJS app writing
class PrismaService extends PrismaClienthad no storage effect on any call through the service; the pack now follows the type's base classes. (#996) - An axios request written as one config object is read:
axios({ url, method }),api({ url })on an instance built byaxios.create, andaxios.request(config). The object is evaluated, so a spread from a name the evaluator can follow contributes its fields. (#983) - A FastAPI project on SQLModel gets its database calls read. A new
sqlmodelpack says the same things about thesqlmodelmodule, with-f sqlmodel=suss.sqlmodel.jsonsaying which database is behind the engine. On the template, 84 storage effects come out where there were none. (#981) suss initfinds a Python or Ruby service beside the frontend. It took its list of projects from the npm workspace file, and apyproject.tomlor aGemfileis listed in none. (#980)suss initreads a Rails app whose frontend packages are npm workspaces. A root that declares workspaces was taken to be a container and not a project itself. The rails pack is also suggested for an app that depends onrailtiesrather thanrails. (#987)suss inspectprints a Python or Ruby storage read. The tree took its reads from a field only the TypeScript adapter wrote, and now prints the effect from the summary itself, so a unit that reads a table shows+ reads postgresql:Userin every language. (#978)
For contributors
A Ruby unit is deduplicated by what it is reported as. alreadyDiscovered keyed a unit on its file and range alone, so two controllers inheriting one show would have collapsed to a single unit. The key now includes the export path, so one method body can be reported once per controller that routes it, while a filter two controllers share still appears once.
A Ruby ancestry walk says when it reached the library. superclassChain used to return nothing at all both when a chain ended at one of ancestryRootClassNames and when it ended at a class the run could not read. The chain now ends in a root entry with the library class's name, and underscoreConstantPath and resolveConstantFile take the project's inflector acronyms.
The Ruby value evaluator takes bindings for the enclosing block's parameters. stringValueOf(node, db, bindings) and evaluatedValue(node, db, bindings) accept a map from a parameter name to the string to read it as. A reader that replays a block per element supplies x and gets the interpolated string back spelled, without a second reading of Ruby strings in the pack.
0.26.0 (2026-09-09)
0.26.0 pairs a GraphQL schema against the resolvers behind it, and teaches the graphql-ruby pack what a dataloader call reads.
A graphql-ruby resolver that reads through the dataloader gets a storage read
A field that resolves with dataloader.with(Sources::ActiveRecordBelongsTo, ::User).load(object.user_id), or with one of the dataload shortcuts, used to have no storage effect and a gap saying the call went through a value the run could not settle. The model is one of the call's arguments, so the field now records a read against User and the gap is gone.
It takes @suss/framework-activerecord in the same run to say which constants are models, as -f activerecord=suss.activerecord.json with a config giving storageSystem; the graphql-ruby pack alone does not record the read. dataload_association is still not read, because the model behind an association is declared on another class. (#976)
A GraphQL schema is compared against the resolvers behind it
A schema read with suss contract and the code that implements it both provide gql:Mutation.articleApprove, and check used to report those as "claimed by more than one file": 210 of them on one repository, with nothing compared.
A document describes a boundary rather than serving it, the same way an OpenAPI document does, and the two are paired now. The schema reader also writes out the record behind each named type, so the two sides have structure to compare: a field's return type used to be the name ArticleApprovePayload, and a name has nothing to compare against the record a resolver declares.
Fixes
suss initprints commands that run. A directory of GraphQL documents is suggested only when a file under it has a query, a mutation or a subscription; Storybook stories are suggested once, at the directory they share; a project with more than one schema file gets one output file per schema; and a document is told apart from a schema by parsing it. (#970)- A Ruby model spelled
::OrderorShop::Orderis recognized. The storage recognizer matched a bare name against every binding in the run, which never matched a compound path, and a leading::was read as part of the name.
For contributors
The corpus runs what suss init prints. A target used to come with a pack list written in this repository, a third copy of what a project needs and not the path anybody takes. A target now says which directory to point init at; the run writes the config files init said to write, runs the commands it printed, and fails when one of them fails. Every defect the last sweep found sat between what init printed and what running it did. init suggests packs the hand-written lists left out, so every target reads more: saleor-dashboard went from 5903 summaries to 8739, twenty-server from 4505 to 6747. (#974)
0.25.0 (2026-09-08)
0.25.0 is three small fixes a sweep over two codebases turned up. (#971)
A pack config kept outside the project says so
A relative path in a pack config is read against the config file, so a config written somewhere else resolves to a directory that is not there. Every class lookup through it came back empty and the run reported hundreds of gaps naming a base class the project defines. The run now says which value points where, before it starts:
suss.graphql-ruby.json says root is app/graphql, and there is nothing at
/elsewhere/app/graphql. A relative path is read against the config file, so a
config kept outside the project points at nothing.Fixes
export const Thingbesideexport default Thingis not a pack fault. Every run of a React codebase printeddouble-match react 1 unit matched twice, second dropped. The dedup key is the function's own file and span, so a collision inside one file only ever means one function reached twice, which ordinary code does. Nothing was lost when the second claim dropped, and the check is gone.suss askwith no question exits 0. Printing the questions is whatsuss --helpsays to do, and it exited 1 for it. A question suss cannot answer still does.
0.24.4 (2026-09-08)
0.24.4 fixes two ways init and the GraphQL reader lost a file.
Fixes
suss initsays which contract files it found, all of them. It kept one suggestion per reader, so the first.graphqlfile the walk reached won and the rest were dropped; on one project a fragment file was named as the schema andschema.graphqlat the root was never mentioned, and two SAM templates in one repository lost the same way. A GraphQL file that declares types is read as a schema, and one with only operations goes to--from graphql-documents, which takes the directory. (#968)- A fragment written in a
.graphqlfile counts as registered. graphql-codegen scans.ts,.tsxand.graphqlalike for documents, and the reader looked in the TypeScript alone, so a project keeping a fragment in a file of its own got an error saying its query throws. (#966)
0.24.3 (2026-09-08)
0.24.3 stops check reporting a query that works as one that throws.
Fixes
- A GraphQL query that spreads a fragment the codegen preset registers is left alone. The client preset registers a fragment by writing it in a document of its own and inlines it by name, so nothing interpolates it;
checkread the query on its own, saw a spread with no definition, and reported that the query throws when it runs. On one React codebase that was 20 errors, every one of them wrong. A fragment written on its own in agql(...)call now counts as registered, and a query that spreads a fragment nothing defines is still an error. (#964)
0.24.2 (2026-09-07)
0.24.2 fixes a Ruby service object that reported nothing, and a setTimeout that threw away a whole run.
Fixes
- A Ruby service object's calls come back, however it keeps its connection. A class that builds its connection once in
def self.connand calls it from every request method used to report nothing, because the reader followed a name assigned in the same method and no further. It now reads what a method of that name in the same file comes back with,@conn ||= Faraday.new(...)included, and takes a base URL written as the first argument as well as underurl:. On one Rails application with four vendor services, the calls it finds went from 4 to 12. (#960) - A run with a
setTimeoutin it is readable again. Two packs give the function handed tosetTimeouta unit of its own and the summary schema did not allow that kind, socheckandinspectrefused the whole fileextracthad written, and one such callback anywhere in a repository threw away the run.checkalso printed the reason it skipped a file up to the first newline, which is exactly where the reason starts. (#958)
0.24.0 (2026-09-07)
0.24.0 gives Python and Ruby a calling side, so suss check can compare what a service asks for against what the other side sends.
A Python function that calls out over HTTP is a client of the route it calls
A FastAPI or Flask project used to report its own routes and its database calls, and nothing it reached, so suss check had no consumer side in Python at all. @suss/client-requests reads the calls requests gives a project, and a function that makes one comes back bound to the method and path it states:
GET /orders/{order_id} (requests client | line 8)That pairs with whatever serves the route in the same run: a route in the same project, a handler from another repository read into the same folder, or an OpenAPI document read with suss contract. The seven verb functions, requests.request("PATCH", url) and a Session all read the same way, and the URL goes through the same value evaluator a route's path does, so an f-string and a name defined elsewhere both resolve to the path they build. (#933)
A Python caller says which statuses it handles, and check compares them
A function that calls another service and tests the response used to come back with one path and no conditions, so nothing could be compared against what the other side sends:
def fetch_order(order_id: str):
response = requests.get(f"{ORDERS_BASE}/orders/{order_id}")
if response.status_code == 404:
return None
return response.json()suss check now reports Consumer expects status 404 but provider never produces it when the route on the other side does not send one. The requests and httpx packs say which members of the response mean the status, the success flag and the body, and the caller's own body is walked the way a route's is. To pair the two sides the checker has to know which member a condition read and what it compared against, so Python conditions now record both, in a route as much as in a client. (#939)
A Ruby condition says what it tests, and a Ruby caller says which statuses it handles
Every condition on a Ruby summary used to be the text it was written as, so nothing could read the status a guard names. A comparison, nil?, a member read and a negation each come out as themselves now, and a member read says the name it starts from and the members read off it. That is what the Faraday and Net::HTTP packs needed:
response = conn.get("/orders/#{id}")
return nil if response.status == 404Against a Rails action that never sends a 404, suss check now says Consumer expects status 404 but provider never produces it. Net::HTTP hands the status back as a string, and response.code.to_i == 404 is read as a test on code. (#941)
check warns when a queue that redelivers reaches a consumer that posts
An SQS queue that is not FIFO can deliver one message more than once, and a handler draining it that calls another service to create something makes that call again on the second delivery. That is the charge-twice bug:
[WARNING] repeatUnsafeConsumer
SQS queue "OrdersQueue" can deliver one message more than once, and handler
makes POST /v1/charges through stripe.charges.create while handling it. A
second delivery makes that call again.PUT, PATCH and DELETE land on the same resource twice, so they are left alone, and so is a FIFO queue. A call that sends an idempotency key is safe and still reported: a summary does not record the headers a call sends, and the finding says that it cannot tell. The consumer's call is found whether the pack recorded it as an effect, as the TypeScript clients do, or as a unit bound to the route it calls, as the Python and Ruby clients do. (#949)
inspect --diff reports what changed at each boundary
The diff used to list the units that moved, a block each. A reviewer reading that learned a file gained a function, which the pull request's own diff shows better. The report now opens with the boundaries: what each route, consumer or Lambda responds with, and what a request reaches or stopped reaching through the calls it makes.
1 boundary changed: 1 outcome, 1 effect. 2 units inside the project also changed.
~ serves GET /orders/{id} src/app.ts::show (1 outcome, 1 effect)
outcomes
+ responds 404 { error } when order == null
effects
+ reads mongodb:orders through loadOrder -> readOrderAn outcome that reached several routes from one filter, middleware or error handler is said once, above the blocks, with how far it reaches and which routes it missed:
From require_login app/controllers/application_controller.rb
+ responds 401 when session[:user_id].nil?
at 14 of the 15 boundaries it runs on
not at GET /health, which it also runs onA route the filter covers that already responded the same way is not one of the exceptions, since nothing about it moved. A route on the second line is one where the filter runs and the outcome is still missing, and that is the line a reviewer checks.
A unit further down the call chain gets no block of its own, since the boundaries that reach it already show what its change did. Under the boundaries, the files with units that moved. A unit with a couple of lines to its name has them written out, one with more gets a count of the outcomes and the effects that moved, and one with a block of its own above is only named. A chain longer than three calls prints its first and last with (2 intermediate units collapsed) between them; --chain full prints every call and --chain 0 prints none. Two more flags cap what the report costs: --changed-files takes the paths a change touched, one per line, so those files come last, and --budget stops the report at a number of characters and counts what it left out. The action passes both. (#943, #944)
Fixes
suss initsuggests every pack, including the ones that shipped last week. The list it suggested from was a table somebody edited by hand, so the Python and Ruby client packs that arrived in 0.23.0 were never mentioned. Each pack now says which libraries it reads, andinitmatches your dependencies against that. (#952)@suss/client-faradayreads a request method on the Faraday module itself and on a connectionFaraday.newbuilt, so a Ruby service object comes back bound to the route it reaches. A connection built withurl: "https://api.example.com/v1"serves its calls under/v1. Run it as-f faradaybeside the rails pack. (#934)-f httpxand-f aiohttpcover the other two libraries a Python service calls out with, including a client or a session opened as a context manager. The Python binder now binds whatwith X() as nameopens, and every name the adapter resolves goes through that binder. (#935)-f net-httpcovers Ruby's own HTTP client, in both the ways it is written: a module call such asNet::HTTP.get(URI(...)), and the longer form where a project builds a request object and sends it.URI(...)andURI.parse(...)belong to Ruby, so they sit in the value tables next toFile.join. (#936)
For contributors
A pack says what it is, beside its own patterns. declares on a pack gives its kind, the package it ships in, the libraries a project depends on for it to read anything, the sentence about what it reads, and any per-project config it needs. suss init reads that instead of a table in the CLI, and the pack tables on the pack catalog are generated from it by npm run docs:packs. Adding a pack used to mean three edits in three places, two of which failed quietly; check:packs now requires the declaration and check:pack-tables fails when the page has drifted from it. (#952)
A package page reads like a package page. What npm and jsdelivr render is the README, and several of ours had grown into design documents with no link back to the project. A README now says what the package does, how to install it, and what it produces, and the reference prose lives in DESIGN.md beside it, in the repository and out of the published tarball. check:readmes keeps every published package to that. (#953)
0.23.0 (2026-09-06)
0.23.0 makes a route report what the code around it responds with, in Ruby and in Python.
A Rails action reports what its before_action filters respond with
A controller that writes before_action :require_login used to report only what the action's own body does, so a route behind a login check looked like it always returned 200.
Each filter method now gets a summary of its own, saying what it responds with and where it hands the request on, and every action it covers reports the two together: 401 under the filter's own test, and the action's outcomes under the negation of it. only:, except: and skip_before_action decide which actions a filter reaches, a filter written on ApplicationController reaches every controller that inherits it, and a rescue_from ... with: handler is reported on the paths that raise. Looking up a superclass by name follows Rails' autoloading now, so a base class in app/controllers is found; before this, only a file sitting directly under app was. (#930)
A Python route reports what its dependencies, middleware and error handlers respond with
A FastAPI dependency that raises 401, an @app.middleware("http") that returns 429 and an @app.exception_handler that turns an error into a 500 used to be invisible from the route, and so did a flask-restx service's @app.before_request hook and @api.errorhandler.
Each now gets a summary of its own, inspect says which of them wrap the route, and the route's own transitions include theirs, each marked with where it came from. A dependency is read from the app or router construction, the decorator's dependencies=[...], a parameter default and an Annotated[...] parameter alike, and a router's dependencies reach a route written on it from another file. A route whose whole body is one return {"status": "ok"} used to be reported without a response; it now reports the library's default status with that body. (#923)
0.22.4 (2026-09-06)
0.22.4 puts the behavior diff on a pull request, and pairs a handler with the OpenAPI document for its route.
A GitHub Action posts the behavior diff on a pull request
Point a workflow at nimbuscloud-ai/suss/.github/actions/inspect-diff@main and it reads both sides of the pull request, runs inspect --diff over them, and posts one comment that it edits again on every push. The comment says which units changed behavior and how they changed, whether or not the pull request edited the lines they are on. It keeps both summary files as an artifact of the run.
The action README has a workflow to copy. Runs share suss's per-file cache, so reading the head costs about what the pull request touched, and if the workflow also runs when something lands on main, it reads each of those commits as it goes and a later pull request compares against what it already read. (#916)
A handler is compared against the OpenAPI document for its route
A service with its own OpenAPI file and no client in the run used to come back from suss check as Nothing was compared., with a note that the route was claimed by two files. The handler and the document are now paired on the route, so a status the code produces and the document does not declare is an error, and a response the document declares that no path produces is a warning. (#915)
Fixes
inspect --diffreports every caller of a route, and a narrowed branch as one line. Several clients of one route used to collapse to one entry, so a change to any of the others printed as no change; each client now pairs by its own name under the route, asclient:GET /pet/{petId}::getPetById. A transition that kept its output and changed its guard prints as one~line with the guard before and the guard after. (#914, #918)- Middleware and hooks the project builds itself reach the routes they wrap. A Hono middleware returned by a project factory such as
requireCaller({ header: "x-caller" })used to be skipped, and a zod-openapidefaultHookgiven to the app constructor was never read, so every route's 401 or 400 was missing. A route's declared contract is compared against what the handler and its wrappers produce together, and a status spread into a route object'sresponsesfrom a shared object is read as declared. (#904, #906, #908, #909) - A Prisma implicit many-to-many has a boundary for its join table.
connect,disconnectandsetthrough such a relation used to go unrecorded, since the join table is not in the schema. (#905)
0.22.3 (2026-09-06)
0.22.3 gives a Rails action and a Python route one transition per branch, with the status each branch responds with.
A Rails action reports what it responds with, on each branch
render ..., status:, head and redirect_to each set the status Rails sends, and an action that responds differently down two branches gets one transition per branch with the test that leads to it.
The routes file is read for scope, namespace, controller:, path:, nested and singular resources, and a bare verb inside a resource block. A helper an action calls by its bare name is followed into its own summary, and the methods Rails gives every controller are left off the effect list, so what remains is what the action reaches in the project. (#881, #894, #895)
A Python route that raises reports the status it raises with
FastAPI's HTTPException and Flask's abort end a path with their status, so a route with a guard reports 404 under the guard and 200 under its negation. A SQLAlchemy session the handler takes as a parameter, builds with Session(), or gets from a helper is recognized, and a 2.0 update(...).values(...) is classified as a write. (#884, #896)
For contributors
A pack declares the calls that respond, and the adapter walks them. responseStatusCalls on a Ruby controllerActions pattern or a Python route pattern says which calls end the request and where each takes its status. The adapter hands every one to the shared path engine as a terminal and builds one branch per path, the same walk the TypeScript adapter has always done, so a status reading and the effects that reach a branch come out per branch. ambiguousReading is gone from the Ruby adapter with nothing left to produce it. (#895, #896)
0.10.0 (2026-08-20)
0.10.0 spells a store, a bus and a metric the way OpenTelemetry's semantic conventions do, so a summary and a span say one boundary the same way.
A store and a bus go by the name OpenTelemetry uses
A summary now says postgresql where it said postgres, aws.dynamodb where it said dynamodb, aws_sqs where it said sqs, and aws.sns where it said sns, which are the values a trace gives db.system.name and messaging.system. Summaries written before this read back with the new names, so nothing published has to be regenerated, and the format is at schema version 5.
Two things to update by hand: a suppression that says which bus (bus:sqs order.placed becomes bus:aws_sqs order.placed), and a storageSystem in pack config for @suss/framework-sqlalchemy, @suss/framework-activerecord, @suss/framework-prisma or @suss/framework-drizzle, where postgres becomes postgresql. (#453)
A metric's measurement words are OpenTelemetry's
A summary now says histogram where it said spread, and gauge, delta, cumulative where it said point, interval, sinceStart. Those are the metrics data model's point kinds and aggregation temporalities, and Cloud Monitoring's own metric kinds spell the last three the same way. Summaries written before this read back with the new words, and the format is at schema version 6. Nothing needs updating by hand: no suppression rule or pack config spells these words. (#472)
For contributors
Each protocol says which of its words are OpenTelemetry's. A boundary protocol module declares semconv, the attribute each identity field goes under, and semconvAttributes(binding) reads a binding as the attributes a span would state. A field is in that projection only when our value is the value a span gets, so storage.accessPath, a "default" scope and a "*" REST method stay out of it. Protocols nobody crosses at run time, function-call and metric among them, declare an empty mapping, and the compiler makes a new protocol answer the question. Boundary semantics has the table.
0.5.0 (2026-08-14)
0.5.0 follows a Python or Ruby handler into the database work behind it, and reports what each route's body does.
Both languages classify database calls
@suss/framework-sqlalchemy and @suss/framework-activerecord compose onto whichever route pack a run already uses. Python matches a query by what the method behind the call says it returns, which reads through a project's own base class; Ruby matches by what the receiver's class inherits, which reads through ApplicationRecord.
A chain like Model.query().filter_by(id=x).first() is one read, saying which model, which rows (selector) and which columns (fields). A handler that hands off to a service function reports the call, and the service function's own summary reports the work, so suss ask "what does GET /orders reach" follows the call and why does says which function the work is in. (#284, #287, #289, #292)
A Python route mounted through a shared framework package reports its path
The chain many production Flask services use, an entry file handing a loader object to a library function, the loader returning a written-out list of namespaces, a loop mounting whatever comes back, is followed the whole way. That took a class becoming a value containing its methods, an argument reaching the parameter it is passed to, and imports written inside functions being read. On two measured services every route now has its full path where none did.
Fixes
- A Python route reports what its body does. Each return is a branch with its status and the conditions that reach it, and the calls a body makes are invocation effects with the conditions that gate each one. A route that declares no response keeps a transition anyway, so the work has somewhere to be recorded. (#283)
- Gap messages point at the thing. A mounted list declined over one unmatched entry says which entry, what it resolved to, and that the rest matched. A body the path engine declines keeps its route, path and method, and says what was declined, the budget cap included. (#294)
- A handler with a run of guards reports one transition where it reported many. Two paths that differ only over a branch they both pass through become one, in the engine, for every language. The two handlers that used to lose everything to the path budget no longer come near it.
For contributors
Every adapter runs the same fact contract. @suss/resolution ships six executable cases stating how a fact has to be keyed, each adapter supplies its own source per case, and none declares a known gap. The kit caught three bugs on its first Ruby run that had already been fixed in Python and never carried across, and one mistake in itself.
The rule profiler is the tool for slow extractions. A CPU profile bottoms out at unify and lookup and cannot say which rule asked for the work; profileEvaluationAsync charges time and tuples to each rule. It found a join that derived 30k demand tuples to produce 224 rows, and an index that built a string out of every node id on every lookup. The measured extract went from 25.2s to 4.2s across three changes, and three plausible optimisations were measured slower and written down so nobody redoes them.
Containment with inheritance is its own relation. Deriving into holdsProperty turns a stated fact into a derived one, and the on-demand rewrite gates it behind demand nothing generates. contains reads holdsProperty and adds what a base class declares.
A version bump no longer breaks the workspace. preparePublish left sibling devDependencies pinned at the old version, so npm fetched them from the registry instead of linking the workspace. They are * now.
A new package has a checklist. The coverage list, the packages table, the doc counts, a LICENSE force-added past the gitignore rule, and a trusted publisher on npm before the release workflow can push it, bootstrapped with a prerelease under a non-latest tag.