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>]| Flag | Default | What it does |
|---|---|---|
-f, --framework <name> | the packs in suss.json, or what init would pick | Which 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 read | The 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 directory | Read 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 tsconfig | typescript, python or ruby. When suss cannot work the language out for itself, it stops and asks you to pass this flag. |
-o, --output <path> | stdout | Write 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> | none | Run 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 covers | Read 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> | permissive | permissive 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. |
--explain | off | Print where the summaries came from, file by file and pack by pack. A run that found nothing prints it either way. |
--timing | off | Print how long each phase took, to stderr. |
--datalog-profile | off | Print 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-cache | off | Skip the on-disk cache for this run. |
--cache-dir <path> | .suss/cache under the folder being read, or SUSS_CACHE_DIR | Keep 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-empty | off | Exit 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-error | off | Exit 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 8192 | With --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> | none | A 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
$ suss extract --dir fixtures/express -f express -o summaries/api.json
Wrote 4 summaries to /home/dana/shop/summaries/api.json in 0.61sThe 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.
$ 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.74sThe 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.
$ 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.jsonIf 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:
{
"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
librarysummary for each public export, keyedfn:<module>::<export>. That isfn:billing::chargeInvoicefor a function,fn:billing::InvoiceService.chargefor a class method in TypeScript or Python, andfn:billing::Billing.charge_invoiceorfn:billing::Billing::Invoice#totalfor a class method or an instance method in Ruby. A boundary intent withmoduleandexportNamepairs 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:
{
"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:
| Pack | Option | Stub kind |
|---|---|---|
nestjs-rest, nestjs-graphql, nestjs-microservices | classDecorators | composes-decorator |
aws-sqs, aws-eventbridge | producers | performs-call |
axios | factories | performs-call |
fastapi, flask-restx | wrapperModules | re-exports |
graphql-ruby, rails | baseClassNames | extends-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.