Skip to content

suss extract ​

suss extract reads your TypeScript, Python or Ruby source and writes a behavioral summary for every code unit it finds. It is the first command you run on a project, and every other command works from the JSON it produces.

suss extract [-p <tsconfig> | --dir <directory>] [--lang typescript|python|ruby]
             [-f <pack>[=<config.json>] ...] [-o <output.json>]
             [--files <f1> <f2> ...] [--gaps strict|permissive|silent]
             [--explain] [--timing] [--datalog-profile] [--no-cache]
             [--cache-dir <path>] [--allow-empty] [--fail-on-pack-error] [--intent <directory>]

suss extract --out-dir <directory> [--dir <project>] [--allow-empty]
             [--jobs <n>] [--read-timeout <minutes>] [--read-memory <MB>]
FlagDefaultWhat it does
-f, --framework <name>the packs in suss.json, or what init would pickWhich pack to read with. Repeatable. See Pack names and Configuring a pack.
-p, --project <path>the nearest tsconfig.json or jsconfig.json at or above the directory being readThe tsconfig covering the code to read, so suss resolves types the way your compiler does. When suss finds one above that directory, it uses the tsconfig's settings but reads only the files under the directory.
--dir <path>the working directoryRead this directory, for a project with no tsconfig, or for one package under a tsconfig that covers the whole repository. With -p, suss reads only the tsconfig's files under this directory.
--lang <name>worked out from what the directory contains, the packs you asked for, and the nearest tsconfigtypescript, python or ruby. When suss cannot work the language out for itself, it stops and asks you to pass this flag.
-o, --output <path>stdoutWrite the summary JSON to a file. Parent directories are created. When suss.json lists several reads, the file contains all of them. See A bare extract in a project with several reads.
--out-dir <path>noneRun every read suss.json lists, contracts included, and write each one to its own file in this folder. See The whole project into a folder. Takes no -f, -o, -p, --lang, --intent or files. Each entry runs with its default settings, so --out-dir also refuses --gaps, --explain, --timing, --datalog-profile, --no-cache and --fail-on-pack-error.
--files <f1> <f2> ...every file the tsconfig or directory coversRead only these files, resolved against the working directory. Bare arguments with no flag in front of them mean the same thing when --files is absent.
--gaps <mode>permissivepermissive records in the summary the returns and declared statuses a pack could not account for. strict records the same and then exits non-zero. silent skips gap detection.
--explainoffPrint where the summaries came from, file by file and pack by pack. A run that found nothing prints it either way.
--timingoffPrint how long each phase took, to stderr.
--datalog-profileoffPrint what the Datalog evaluator spent its time on, rule by rule, with how many rows its joins read. Use it when --timing shows the rules phase is the slow one.
--no-cacheoffSkip the on-disk cache for this run.
--cache-dir <path>.suss/cache under the folder being read, or SUSS_CACHE_DIRKeep the cache here instead. Use it to read a checkout you may not write to. See Where suss writes. With --out-dir, it applies to every entry.
--allow-emptyoffExit 0 even when the run produced nothing. Without it that run fails, because a silent zero looks the same in CI as a passing check.
--fail-on-pack-erroroffExit non-zero when a pack throws while it reads, or was left out because an option it needs was not given. By default the run reports it and keeps going with the other packs.
--jobs <n>, --read-timeout <minutes>, --read-memory <MB>up to 4, 20, at most 8192With --out-dir, or a bare extract of a project with several reads: how many reads run at once, and the time and heap limit on each. An extract of one read refuses them.
--intent <path>noneA folder of intent documents. A test pack (-f vitest, -f pytest, -f rspec or -f minitest) reads only the test files its PRDs list under coveredBy, instead of every test, so check --intent can check them. A pack given its own files in a config file keeps that list.

--fail-on-empty is gone. A run that finds nothing now fails by default, and passing the old flag stops the run and points you at --allow-empty.

What it writes ​

Without -o, the summary JSON goes to stdout and nothing else does, so suss extract ... | jq works. With -o, the JSON goes to the file and one acknowledgement line goes to stderr.

With -o, a run that could not read part of the project also writes a note beside the summaries, at summaries.incomplete.json next to summaries.json. It has one key per reason: filesWithUnreadableExports for re-export chains suss could not follow in a TypeScript run, submodulesNotCheckedOut for a submodule with nothing in it, which any language can hit, packsNeedingConfig for a pack that was left out because an option it needs was not given, and packPathsMissing for a path in a pack config where nothing exists. suss check --json turns the last two into packNeedsConfig and packPathMissing run findings. A run with nothing to report deletes a note an earlier run left, so a stale file never fails a job that has since been fixed.

Example ​

bash
$ suss extract --dir fixtures/express -f express -o summaries/api.json
Wrote 4 summaries to /home/dana/shop/summaries/api.json in 0.61s

The whole project into a folder ​

--out-dir reads the project the way a bare suss check does: every entry in suss.json, or what init would pick when there is no file. Each entry's summaries go to their own numbered file, 0-extract.json, 1-contract.json and so on, in the order suss.json lists them. suss check --dir over the folder then compares everything the project declares.

bash
$ suss extract --out-dir .suss/now
Reading what suss.json says.
  suss extract --lang typescript -p tsconfig.json -f express -f fetch
Wrote 2 summaries to /home/dana/shop/.suss/now/0-extract.json in 0.74s

The reads run in their own processes, several at once, with a time and heap limit on each, and --jobs, --read-timeout and --read-memory set those the way they do for a bare check. A read that fails leaves a <n>-extract.incomplete.json note in place of its file, which check --dir reports as a readFailed run finding. Files an earlier --out-dir run wrote in the folder are removed first, notes included, so an entry that fails this time leaves nothing stale behind. The run exits non-zero when any entry failed, because the folder then describes only part of the project. It also exits non-zero when an extract entry didn't write a summary, the same as a single extract does, unless you pass --allow-empty.

A bare extract in a project with several reads ​

When suss.json lists more than one read and you pass none of -f, -p, --lang, --intent or files, suss extract runs every entry, the same reads a bare suss check runs. Their summaries are merged into one array, which goes to stdout, or to the file given with -o. A project with a Python API at the root and a TypeScript client in frontend/ gets both in that array. Use --out-dir instead to keep each read in its own file.

bash
$ suss extract -o summaries/code.json
Reading what suss.json says.
  suss extract --lang python -f fastapi -f httpx: 3 summaries
  suss extract --lang typescript --dir frontend -f axios -f fetch -f node: 2 summaries
Wrote 5 summaries to /home/dana/shop/summaries/code.json

If one read fails, the run says which one and why on stderr (failed: suss extract --lang typescript ...), still runs the others, writes what they produced, and exits 1. It also exits 1 when a read produced no summaries, unless you pass --allow-empty. The flags --gaps, --explain, --timing, --datalog-profile, --no-cache and --fail-on-pack-error apply to a single read, so a bare extract of several refuses them. With one read in suss.json, a bare extract runs that read with every flag available.

Modules of one application ​

An application split into modules inside one package can list them in suss.json. Each entry has the name your team uses for the module, the folder its code is in, and optionally public, the file or files other modules are meant to call it through:

json
{
  "version": 1,
  "read": [
    {
      "kind": "extract",
      "language": "typescript",
      "project": "tsconfig.json",
      "packs": ["express", "pg"]
    }
  ],
  "modules": [
    { "name": "billing", "root": "src/billing" },
    { "name": "catalog", "root": "src/catalog", "public": "src/catalog/api.ts" }
  ]
}

Paths are relative to the directory suss.json is in, and extract uses the nearest suss.json at or above the project. When public is left out, the language decides which file is public: index.ts or index.tsx under the root in TypeScript, the root's __init__.py in Python, and in Ruby the file named for the module, inside the root or next to it, as lib/billing.rb is next to lib/billing/.

With the list in place, extract does two things:

  • It writes the module on every summary whose file is under a module's root, as location.module.
  • It writes a library summary for each public export, keyed fn:<module>::<export>. That is fn:billing::chargeInvoice for a function, fn:billing::InvoiceService.charge for a class method in TypeScript or Python, and fn:billing::Billing.charge_invoice or fn:billing::Billing::Invoice#total for a class method or an instance method in Ruby. A boundary intent with module and exportName pairs with it, so a PRD scenario can link to one of its outcomes.

The name is the key, so moving a module's folder renames no boundary. A name can use letters, digits, _, - and .. A module named like a package in the workspace is refused, since both would key their exports fn:<name>::.

suss enforces nothing about how modules call each other. inspect --diff says when a change adds a module that writes a store, or a call that enters another module somewhere other than its public exports, and suss ask takes a module's name as a subject.

Hosts the project serves ​

A client call to an absolute URL records the URL's host. A call to a host that could be the project itself pairs with the project's routes: localhost, a private address, a name with no dot such as a container's service name, or a name under .local, .internal, .test or the example domains. A call to any other host goes to another company's API and pairs with nothing here.

When a client calls the project by its public name, list that host in suss.json, and extract records the call with no host so it pairs the way a relative URL does:

json
{
  "version": 1,
  "read": [],
  "hosts": ["api.example.com"]
}

A listed host matches with or without a port.

Pack names ​

-f takes these 62 names out of the box. Every one of them ships inside the CLI, so there is nothing else to install. The pack catalog describes what each one reads.

Frameworks. These discover the units a run is about: a route, a resolver, a component, a deployed function.

activejob, apollo, aws-lambda, aws-lambda-python, aws-lambda-ruby, bullmq, celery, cloudflare-workers, express, fastapi, fastify, flask-restx, graphql-ruby, hono, nestjs-graphql, nestjs-microservices, nestjs-rest, nextjs, package-exports, rails, react, react-query, react-router, sidekiq, ts-rest

Tests. These read the tests a PRD scenario lists under coveredBy, one pack per runner, and read only the listed files when the run is given --intent.

vitest, pytest, rspec, minitest, and next to the Ruby ones, factory-bot and fabrication, which say what class a test's factory builds

Clients. These discover the calls your code makes out.

aiohttp, apollo-client, axios, faraday, fetch, httpx, net-http, requests

What your code reaches. These do not discover units. They read the calls inside a unit another pack found, so run one alongside a framework or client pack or it comes back empty.

activerecord, aws-dynamodb, aws-eventbridge, aws-s3, aws-secrets-manager, aws-sns, aws-sqs, aws-ssm, bigquery, bigquery-python, bigquery-ruby, drizzle, gcs, mongoose, node, pg, pg-ruby, prisma, redis, sqlalchemy, sqlmodel, typeorm, zustand

Twenty-five of them read something other than TypeScript, and a run reads one language at a time. fastapi, flask-restx, aws-lambda-python, celery, sqlalchemy, sqlmodel, bigquery-python, pytest, requests, httpx and aiohttp read Python; rails, graphql-ruby, aws-lambda-ruby, activerecord, activejob, sidekiq, bigquery-ruby, pg-ruby, rspec, minitest, factory-bot, fabrication, faraday and net-http read Ruby. Naming one in a TypeScript run stops the run and prints the command to run it separately. See Read Python or Ruby.

A name that is not on the list gets treated as a module to import, and that is how you run a pack of your own. A name starting with @ or containing a / is imported exactly as written, so -f @acme/suss-pack works. Any other name is tried as @suss/packs/<name>, then @suss/framework-<name>, then @suss/<name>. If none of the three import, the run stops and prints the built-in list.

Configuring a pack ​

Write -f <pack>=<config.json> and the file's contents go to the pack as its options. The CLI parses the file against the pack's own schema before the pack runs, so a key the pack never declared stops the run instead of being quietly ignored. The error gives the key you wrote and the keys that pack does take.

The options can also go in the spec itself, as JSON: -f activerecord='{"storageSystem":"postgresql"}'. A relative path in them is read against the directory the extract reads, as it is when there is no config file.

A relative config path typed after -f is read against the working directory. The same spec written in suss.json is read against the directory suss.json is in, so the file works whichever directory a command or the MCP server starts from. suss init writes the config file for each pack that takes one when it writes suss.json, and lists the pack with its file.

A pack config describes your own project: which database is behind a connection, or which directory your schema lives in. A fact about a package you depend on goes in a dependency stub instead, and every pack in the run reads it from there.

Four packs read nothing until they are given an option: graphql-ruby needs root, and activerecord, sqlalchemy and sqlmodel need storageSystem. Run without it, the pack is left out and the pack health block says so, with an example to copy:

Pack health (1):
  no-config  graphql-ruby  reads nothing until root is set, for example {"root":"app/graphql"}. Write that to a JSON file and pass -f graphql-ruby=<config.json>.

When no other pack in the run produced a summary, the run fails like any other empty one. See Fix a run that found nothing.

Most packs have nothing to configure. aws-dynamodb takes requiresImport, the modules whose presence, directly or through a file the project imports, makes a file one the pack should read. react-router takes errorHelpers, the project's own helpers that turn an error into a response.

Five option names describe a dependency rather than your own project, so they belong in a stub and a config file may not set them. Setting one stops the run and tells you which stub kind takes it over:

PackOptionStub kind
nestjs-rest, nestjs-graphql, nestjs-microservicesclassDecoratorscomposes-decorator
aws-sqs, aws-eventbridgeproducersperforms-call
axiosfactoriesperforms-call
fastapi, flask-restxwrapperModulesre-exports
graphql-ruby, railsbaseClassNamesextends-base

Three more described the project's own code, and suss reads those off the code itself now: registrationHelpers on express, fastify and hono, requestFunctions on aws-dynamodb, and subjectFactories on aws-lambda. A config that still sets one keeps running. suss drops the key and warns you what replaced it.

Exit codes lists what extract returns to the shell.

Released under the Apache-2.0 License.