Findings catalog
A run produces up to three lists of findings, and they have different shapes.
The behavioral findings go under findings in the JSON, and each one reports that two sides of a boundary disagree. Most of the kinds below are these. Intent findings go under intent, where code disagrees with a document your team wrote. Run findings go under run, for a run that could not get far enough to compare anything. A parser that reads only findings misses the other two.
FindingKindSchema in packages/behavioral-ir/src/schemas.ts and IntentFindingKindSchema in packages/intent-ir/src/findings.ts are the authoritative lists, and npm run check:findings fails when this page and those enums disagree.
What a finding looks like
| Field | Type | Meaning |
|---|---|---|
kind | string | Which failure mode this is, one of the values below. |
severity | error | warning | info | The default. A .sussignore rule can downgrade it. |
boundary | BoundaryBinding | Which boundary this is about: a REST endpoint, a table, a channel, a runtime's config, and so on. |
provider | FindingSide | The provider's summary, as { summary, transitionId?, transitionIds?, field?, location }, where summary reads ${file}::${name}. transitionIds is set when several transitions give the one finding, such as two filters that each send a status the document leaves out, and transitionId is the first of them. field is the field the finding is about, as this side's code spells it, and is set when the finding has no transition to point at. |
consumer | FindingSide | The consumer's summary. Always set, even where the finding is about a provider against its own contract; there the two often resolve to the same summary. |
description | string | One line of human-readable text. |
aspect | BoundaryAspect? | Which side of the field this concerns: read, write, send, receive, construct or selector. Absent where the aspect is irrelevant or spans several. |
sources | string[]? | Set only when the dedupe pass collapsed identical findings. It lists the providers, or, when one handler was judged against several contract documents that describe the same operation, the documents. |
suppressed | FindingSuppression? | Set only when a .sussignore rule matched. See Accept a finding. |
How a kind gets its severity
Every default severity comes from one question: can you name an input and the wrong result it produces?
- Error: the code will misread or lose data on an input the other side produces. The claim is about behavior, both sides are in the run, and measured over the pinned corpus the kind is usually right when it fires.
- Warning: the two sides disagree and a person has to judge it. The disagreement is there in the files, and whether it is a defect depends on intent the repository does not state.
- Info: suss is reporting on its own reading. The finding is about confidence, coverage, or something suss could not read. It does not claim the code is wrong.
A kind with no such input and result is a warning. A kind at error whose measured precision over the pinned corpus falls under half moves down until its model improves. unhandledProviderCase is a warning for that reason.
Generic boundary findings
These four kinds are not tied to one protocol. The first three come out of every per-domain checker, and the fourth so far only out of the storage one. Read binding.semantics.name to see which domain you are in (storage, runtime-config, graphql-operation, message-bus), and aspect to see which direction the failure runs in.
They replaced the per-domain enums earlier versions had, where storageReadFieldUnknown, envVarUnprovided and graphqlSelectionFieldUnknown were three names for one thing.
boundaryFieldUnknown
Severity: error for a read or a write against a contract in the run, and for a column that a copy of a table and the database disagree on having. Warning for a construct or send aspect, where suss never extracted the provider, and where two declarations of a table disagree and neither is known to match the database.
The consumer references a field the provider's contract does not declare. A read of a missing field comes back undefined, nothing raises an error, and the code goes on to branch on a value that was never there. The construct and send aspects and the missing-provider case cannot state that outcome, so they stay warnings.
Runtime config:
[ERROR] boundaryFieldUnknown
process.env.STRIPE_API_KEY read by chargeCard is not declared in the environment of a deployment that runs it. At runtime it resolves to undefined there, changing which execution paths the code takes.
read: src/lib/payments.ts::chargeCard (src/lib/payments.ts:14)
not declared by: Checkout, Refund
to silence this one, add to the rules in .sussignore.yml:
- kind: boundaryFieldUnknown
boundary: "runtime-config STRIPE_API_KEY"
consumer: { field: "STRIPE_API_KEY" }
deployments: ["Checkout","Refund"]
reason: TODO say why you accept thisOne finding covers one read, however many deployments run it without the variable. The rule it prints accepts the variable in the deployments the finding lists, and keeps matching when the code that reads it moves to another file. A deployment the rule leaves out, including one added later, is still reported. A deployment runs a read when a call from its handler reaches the function that makes it, so a client file every handler imports is charged only to the handlers that call into it. A read in a file a deployment loads that no call reaches is listed under unchecked.runtimeConfig instead.
GraphQL:
[ERROR] boundaryFieldUnknown
GraphQL operation "GetUser" selects "User.email" but the provider's schema doesn't declare that field on "User". The selection is probably left over from before a schema change.The server rejects the whole operation at validation, so every operation using that selection fails outright. A selection kept in a shared fragment breaks every operation that spreads it.
A message bus, where the consumer reads a field off the message body:
[WARNING] boundaryFieldUnknown
OrderConsumer.handler reads "totalAmount" off a message on aws_sqs channel "OrdersQueue" but no producer in this run sends "totalAmount". Either a producer renamed or removed the field, or the consumer expects a field that was never sent.A queue takes a string, so nothing on either side type-checks the payload and the consumer throws on every message. A producer whose body suss cannot read takes the channel out of the comparison, so a missing finding here does not mean the two sides agree.
Storage, where a project declares a table twice, such as a Drizzle copy of the tables a Rails schema owns:
[ERROR] boundaryFieldUnknown
orders.producttype: src/db/schema.ts declares the column and db/schema.rb has no such column. Every query through the copy that selects it fails.A dump of the database, such as Rails' db/schema.rb, is the provider, and the other declaration is the copy. Two cases are errors: the copy declares a column the database lacks, or it leaves out a NOT NULL column with no default, so the database refuses every insert through it. A copy that leaves out a nullable or defaulted column is how Drizzle and Prisma are meant to be used, and is not reported. When neither declaration is known to match the database, a column on one side only is a warning, since either side could be out of date. Two declarations are compared only when their primary keys agree, as described under boundaryShapeMismatch. The rule suss prints for one of these keys on the store and the field, such as boundary: "postgresql:orders" with consumer: { field: "productType" }.
Legitimate when: the provider is in a service or a contract source you have not extracted. Suppress with .sussignore and effect: mark.
A bug when: a typo, a rename nobody finished, or stale code reading a field that was removed. Fix the consumer, or restore the contract.
boundaryFieldUnused
Severity: warning, dropping to info where suss could not run the check at all.
The provider declares a field no consumer references. No input produces a wrong result here: an unread field breaks nothing at runtime, and whether it is dead or reserved is intent the repository does not state.
[WARNING] boundaryFieldUnused
Checkout declares environment variable STRIPE_KEY but no code in its codeScope reads process.env.STRIPE_KEY.The info form means the run could not run the check at all:
[INFO] boundaryFieldUnused
cloudformation:template.yaml declares environment variables and these summaries record no environment read anywhere, so whether code reads them was not checked. If the code reads process.env, extract with the node pack in the framework list (-f node) so the reads are in the summaries.For storage, suss stays quiet when any caller reads the table with a default shape (["*"]), because then it cannot tell whether those callers use the column. A query that asks for some columns is not default-shape, so a Prisma call with a select or an include still leaves the check running over the columns it did not ask for. A column a query filters on counts as read, whether the query reads the rows it picks or writes them, so deleteMany({ where: { expires: { lt: cutoff } } }) reads expires. An aspect of read means the field has writers but nothing reads it.
Legitimate when: the field is reserved for something that has not landed, or read by code outside the extracted scope. Suppress.
A bug when: it is dead config from a removed feature, or a renamed field the contract still declares. Take it out of the contract, or restore the consumer.
boundaryShapeMismatch
Severity: per emitter.
Both sides declare the value and disagree about its form: its type, its nullability, its content type. The aspect says which side found the disagreement.
The metric checker and the storage checker emit it. A monitoring alert compares a series against a single number, and the resource declaring that series says its measurements are a histogram of buckets, so the comparison has nothing to run against:
[ERROR] boundaryShapeMismatch
google_monitoring_alert_policy.sweep_refused_sustained#0 compares logging.googleapis.com/user/sweep-refused against a single number, and google_logging_metric.sweep_refused declares that metric's measurements as a histogram of buckets, so the comparison has nothing to run against unless the reading reduces each window to a single number first, by setting aggregations.per_series_aligner to one of ALIGN_PERCENTILE_99, ALIGN_PERCENTILE_95, ALIGN_PERCENTILE_50, ALIGN_PERCENTILE_05.Both sides have to say what the value is. When a metric's summary has no metadata.metricContract.values, or a reading has no metadata.metricReading.comparesTo, nothing is compared.
Storage, where a project declares a table twice and the two declarations of a column disagree:
[ERROR] boundaryShapeMismatch
attachments.blob_id: db/schema.rb declares uuid and src/db/schema.ts declares bigint. The database contains uuid values the copy cannot read, and refuses the bigint it writes.
[ERROR] boundaryShapeMismatch
attachments.created_at: src/db/schema.ts declares a database default and db/schema.rb declares none on a NOT NULL column. An insert through the copy that leaves it out is refused.Two declared stores are one table when the storage pass would pair them with one query: the engines agree, the scopes are equal, and a name of one agrees with a name of the other, such as a Prisma model's @@map. Each column is compared under the name the database stores it by, so blobId: bigint("blob_id") is the column blob_id. Relations and other derived fields are left out. A dump of the database is the provider, then a schema with its own migrations beside it, then the file whose path sorts first. A declaration the project marks as the source outranks all three.
- A type family that differs is an error. Lengths, precisions and enum values are ignored, so
varcharandtextare one family, and so aretimestampandtimestamptz, andjsonandjsonb. A type suss has no family for leaves the column unchecked. - Nullability that differs is an error in either direction.
- A database default the copy declares and the database lacks is an error on a NOT NULL column, where an insert that leaves it out is refused, and a warning on a nullable one, where NULL is stored and nothing says so. A default the database has and the copy lacks is not reported, since the copy supplies a value every time.
- A foreign key that points at a different table on each side is a warning.
- When neither declaration is known to match the database, a default on one side only is a warning, since either side could be out of date.
Columns are compared only when the two primary keys agree on their columns and families. Two databases can each have a users table, such as an auth library's beside an application's own, so keys that differ give one warning for the table in place of every column finding:
[WARNING] boundaryShapeMismatch
users: db/schema.rb keys it on id (uuid) and prisma/schema.prisma keys it on id (String). These may be two databases that each have a table of this name, so no column was compared. If they are, give each its own scope.A side that declares no primary key leaves the table unchecked.
This kind is where the message-bus body-shape pairing will report, along with the type-aware runtime-config and GraphQL checks. It takes the place of the per-domain shape kinds earlier versions reserved: storageTypeMismatch, graphqlVariableTypeMismatch, requestBodyShapeMismatch and the rest.
Legitimate when: the consumer coerces the value before using it, so the difference never reaches anything that cares. For two declarations of a table, when the copy only ever reads the column and handles both forms.
A bug when: it does not. The side that reads the value will act on something other than what arrives. For two declarations of a table, change the copy to match the database, or regenerate it from the database.
boundarySelectorMismatch
Severity: error.
The consumer picks items by something the provider does not key on. A store that accepts only its key attributes refuses the request outright, so every run of this query fails.
[ERROR] boundarySelectorMismatch
InvoiceListFunction.handler picks items on InvoicesTable by "customerId", which is not one of its key attributes (invoiceId). aws.dynamodb refuses a request keyed on anything else, so this fails when it runs.A contract with no metadata.storageContract.identifies is not checked, and neither is an access with no selector. A query through a secondary index pairs against that index's own summary, so it is checked against the index's key rather than the table's.
Legitimate when: never. The store refuses the query outright.
A bug when: always. Query by an attribute the store keys on, or add an index for the one you want.
REST findings
unhandledProviderCase
Severity: warning, or info for several situations under one status.
The provider produces a status, or a body case within a status, that no consumer branch tells apart. The consumer hits its fall-through path, throwing, returning undefined, or ignoring the answer.
[WARNING] unhandledProviderCase
Provider produces status 404 but no consumer branch handles it
provider: src/api.ts::get (src/api.ts:13)
consumer: src/orderPanel.ts::loadOrder (src/orderPanel.ts:1)
boundary: express (http) GET /orders/:idYou cannot name an input and a wrong result here. The fall-through may be exactly what the author intended, and over the pinned corpus this kind was wrong far more often than right. The narrower case, a path that will misread a response, is misreadProviderResponse below, and that one is an error.
A consumer can handle a failure by what the body says instead of by its status. if (res.error) showError(res.error) handles a 404 that sends { error } when no 2xx body has an error. The else of if (data.exists) { ... } else { showError() } handles a 400 whose body has no exists, or sends it as false, when a 2xx body has one. That else has to do something: with no else written, the 400 is still reported.
A provider response declared as a range, such as an OpenAPI 4XX, is one declared response that may arrive with any status in it. It counts as covered when the consumer covers any member, whether that is a branch on 404, a !res.ok guard, or a catch on a throwing client. When nothing covers any member it reports once, saying Provider produces statuses in the 4XX range but no consumer branch handles any of them.
When the provider returns one status under several conditions and the consumer has a single branch for it, the finding says Provider returns status 200 in 3 different situations, and the consumer treats them all the same, at info. It appears only when suss read every one of those bodies and they differ in a field or a literal, because that difference is the only thing a client could tell them apart by. Bodies that match, or one suss could not read, give nothing to tell apart, and no finding.
A consumer suss read only in part, where no pack terminal matched some of what it does, gets one lowConfidence finding at info instead, since the part it did not read may be where it handles the status. That includes a consumer whose read threw.
Legitimate when: the consumer does not care, because it has a try/catch or because the throw path is right.
A bug when: the consumer ignores the status. Add a branch, such as if (res.status === 404) return null.
misreadProviderResponse
Severity: error.
The path runs on a response the provider sends, reads a field that response's body does not include, and nothing on the path tells that response apart from one that does include it. Whatever the path does with the value runs on undefined.
[ERROR] misreadProviderResponse
The consumer's fall-through path reads "customerName", but the 200 body the provider sends does not include it, and neither does any other response.
provider: src/api.ts::get (src/api.ts:13)
consumer: src/orderPanel.ts::loadOrder (src/orderPanel.ts:1)
boundary: express (http) GET /orders/:idunhandledProviderCase is about coverage. This kind is about behavior: whether the code reads something the other side never supplies, the same test the storage and GraphQL read checks make. It is narrow on purpose.
- A field any of the consumer's guards test is never reported.
if (res.error)is how the consumer tells the failure body apart, soerrorcoming back undefined on the 200 is what the consumer was testing for. - A body with spreads or an opaque shape is not checked, and a status the provider returns with several bodies fires only when every one of them lacks the field.
- The branch has to run on the response: a status guard, a range such as
!res.ok, or the fall-through over the 2xx class. A branch guarded on a body field never runs on a response whose body cannot satisfy the guard. - A response declared as a range is one response that may arrive with any status in it, so a branch on 404 is judged against the
4XXbody and the finding says which.
Legitimate when: the provider sends the field through a path suss could not read, a wrapper or a spread it flattened away. Suppress.
A bug when: a rename or a copy-paste left the consumer reading a field this endpoint never sends. Fix the read, or fix the provider.
deadConsumerBranch
Severity: warning.
The consumer has a branch for a status the provider never produces. It usually comes from a consumer copy-pasted off another endpoint.
[WARNING] deadConsumerBranch
Consumer expects status 410 but provider never produces it
provider: src/api.ts::get (src/api.ts:5)
consumer: src/client.ts::loadOrder (src/client.ts:1)
boundary: express (http) GET /orders/:idThe branch never runs and nothing misreads because of it, so there is no wrong result to name. Whether to delete the branch or fix the provider is a judgement.
A status inside a range the provider declares (404 against an OpenAPI 4XX) is produced, and a provider with a default response produces any status, so neither makes a branch dead.
A provider suss read only in part may send a status no path shows. A route behind a wrapper suss could not read, such as one a library factory built, can send a redirect or a failure from it, and a route that throws something no error handler catches gets a status from the framework. A branch on one of those statuses is reported as lowConfidence.
Fix: delete the branch, or add the missing status to the provider's contract.
providerContractViolation
Severity: error, and warning for a declared status the handler never produces.
The provider produces a status or a body its declared contract does not include. A caller built to the contract meets something the contract never told it about and takes a path written for something else.
[ERROR] providerContractViolation
Handler produces status 422 which the openapi document does not declare
provider: src/api.ts::post (src/api.ts:14)
consumer: openapi:openapi.yaml::POST /orders (openapi:openapi.yaml:0)
boundary: express (http) POST /ordersThe other direction is a warning, because a document routinely declares the 401 the middleware sends or the 404 the router sends:
[WARNING] providerContractViolation
The openapi document declares response 410, and no path in the handler produces itWhere the contract is written in the handler's own code, as with ts-rest or hono-openapi, the provider and consumer fields point at one summary, and the checker skips the comparison when the contract source is derived from the implementation. Where the contract is a separate document read with suss contract, the document is the consumer side. A declared 5XX is not reported at all. A status that several paths send, such as two filters that each refuse with 403, is reported once, and the description says which code sends it.
A handler suss could not read at all is not compared with the document, and gets one lowConfidence finding at info instead. A handler suss read in part, where part of what it returns or one of its statuses could not be read, is never said to leave out a declared status of any class. Behind a filter suss could not fully read, it is never said to leave out a declared redirect or failure. A handler that throws something no error handler on the route surely catches is never said to leave out a declared 4xx.
When two functions serve one operation's route, such as two API versions a header picks between, an operation named after one of them, the way a generator writes OrdersController_2024_06_11_getOrder, is compared with that one. Otherwise each function is compared, and only a finding every one of them shows is reported.
Every unhandledCase gap on the provider is reported here. An unreadOutcome gap is not. It comes out as lowConfidence at info, because the pack has no pattern for what the handler returns.
Fix: add the status to the contract, or take it out of the handler. For a declared status the handler never produces, suppress it when something in front of the handler sends it.
consumerContractViolation
Severity: warning, and info for a read of a field the contract declares optional.
The consumer's expected statuses or body reads disagree with the contract. It handles a status the contract does not declare, fails to handle one the contract requires, or reads a body field the contract does not promise.
[WARNING] consumerContractViolation
Contract declares response 404 but consumer does not handle it
provider: openapi:openapi.yaml::GET /orders/{id} (openapi:openapi.yaml:0)
consumer: src/client.ts::loadOrder (src/client.ts:1)
boundary: openapi (http) GET /orders/{id}No outcome can be stated against a contract alone: a branch for an undeclared status never runs, and a missing branch may be the intended fall-through. A contract's range and default entries widen what is declared, so a branch on 404 agrees with a declared 4XX, and nothing is undeclared against a contract with a default. A declared range the consumer handles no member of reports once.
Legitimate when: the consumer does not care, because a wrapper throws on the status and something above catches, or because the declared response never arrives on this caller's path.
A bug when: the consumer falls through to a path written for a different answer. Handle the status, or take it out of the contract if nothing serves it.
contractDisagreement
Severity: warning.
Two or more contract sources describe the same boundary and declare different things. sources lists every contributor.
[WARNING] contractDisagreement
Sources disagree on status 500 at GET /pets/{id}: declared by [GetPet], not declared by [GET /pets/{id}]
also from: openapi:public.yaml::GET /pets/{id}
boundary: apigateway (http) GET /pets/{id}At most one of them is right, and nothing in the repository says which.
Legitimate when: the sources describe different deployments of the same route, so one really does serve a status the other cannot.
A bug when: they describe one deployment. One of them is stale and the run cannot say which, so somebody who knows which document is maintained has to decide.
contractOperationUnimplemented
Severity: warning.
A contract source declares an operation and no extracted provider implements it.
[WARNING] contractOperationUnimplemented
The openapi contract declares POST /orders/{id}/refunds and no extracted provider implements it.
boundary: openapi (http) POST /orders/{id}/refundsThe handler may be in a repository suss did not read, so no outcome can be stated.
An operation meets a route of the same shape however each side spells the parameter, so /orders/{order_id} is served by /orders/:pk. The exception is a route that describes a different resource: when the parameter names disagree and the code serves the document's other methods for that path only at a longer path below the route, as /relations/:collection/:field does for a document's /relations/{id}, the operation is reported.
Legitimate when: the handler is in another repository or another service. Suppress.
A bug when: the operation was removed from the code and the contract still declares it. Take it out of the contract.
GraphQL findings
Most GraphQL failures come through the generic kinds: boundaryFieldUnknown for a selection the schema does not declare, ambiguousProvider for one field two services implement. This kind is about how GraphQL clients ship documents.
graphqlUnknownFragment
Severity: error.
The operation ships a document spreading a fragment nothing defines, and no fragment registry is configured, so the query throws when it runs.
[ERROR] graphqlUnknownFragment
GraphQL operation "<anon>.CheckOrderInvoicesStatus" ships a document spreading "...Invoice" with no definition, and no fragment registry is configured, so the query throws `Unknown fragment: Invoice` when it runs.
consumer: fixtures/apollo-client/dangling-fragment/provider.tsx::<anon>.CheckOrderInvoicesStatus
boundary: apollo-client (http)Three things have to be true before it fires. The document that reaches the call site is the one with the dangling spread, so a codegen-composed version that defines the fragment never fires it. Every client construction in the project was read and none installs a fragment registry, which is the one runtime mechanism that could supply the definition. A client whose construction the pack cannot see counts as unknown, and an unknown client gives the info-level lowConfidence finding instead. And the spread has no definition anywhere in the shipped document.
Legitimate when: the call site never runs, as with dead code behind a disabled flag. Suppress, or delete the code.
A bug when: the import points at the raw source document instead of the codegen output. Import the composed document, or register the fragment on the cache.
React and Storybook findings
scenarioCoverageGap
Severity: warning.
A component has a conditional branch that turns on a prop, and no story supplies that prop.
[WARNING] scenarioCoverageGap
Component "Badge" has a conditional branch on prop "urgent" but no story supplies it (stories: Plain). The branches depending on "urgent" have no declared scenario exercising them.
provider: src/Badge.tsx::Badge (src/Badge.tsx:1)
boundary: react (in-process)Nothing misbehaves at runtime. The branch has no story declaring it, and whether it needs one is a judgement.
Fix: add a story that exercises the branch.
Message-bus findings
messageBusProducerOrphan
Severity: warning.
Code sends to a queue or a topic that no provider in the analyzed scope declares.
[WARNING] messageBusProducerOrphan
handler sends to aws_sqs channel "https://sqs.us-east-1.amazonaws.com/123456789012/AuditQueue", but nothing in this run declares the channel and no handler receives from it. The channel may be declared in a stack or repository this run did not read, or not be set up yet. If neither is true, the channel name is wrong.The queue may be declared in a stack suss did not read, so the provider side is not in the run and no outcome can be stated.
An EventBridge send reaches a rule when the rule's pattern lets it through, so a send whose literal Source the pattern does not list is an orphan even when a rule routes its detail-type. A send whose source is not a literal, or a rule that filters with a prefix, may match, and then no finding is reported.
A background job is never reported here. A job sent by its name, such as Celery's send_task, usually runs in another service, and one sent through its code always has that code in the run.
Fix: add the contract source that declares the queue, or suppress.
messageBusConsumerOrphan
Severity: warning.
A consumer is wired to receive from a channel that no code in the project sends to.
[WARNING] messageBusConsumerOrphan
ChargeWorkerFunction.FromCharges is wired to receive messages from aws_sqs channel "ChargesQueue" but no code in the project sends to this channel. Either nothing uses the channel any more, or the producer lives outside this repository.
boundary: cloudformation (aws_sqs)A delivery the template declares counts as a producer: a queue's redrive policy fills its dead-letter queue, and a rule target or an SNS subscription fills its queue. So a consumer of a dead-letter queue, or of a queue a rule delivers to, is not reported. Neither is a rule whose pattern matches events AWS services send, such as source: [aws.s3], or a rule that lists no detail-type.
Legitimate when: the producer is in another repository, which one run cannot see.
A bug when: the producer was meant to be here. Nothing sends on the channel, so the consumer never runs and nothing says so at deploy time.
A background job on ActiveJob, Sidekiq, Celery or BullMQ never gets this finding. A scheduler such as sidekiq.yml, Solid Queue's recurring.yml or Celery beat can enqueue a job by its name from a config file, and suss does not read those files. A job is also often enqueued by another service, or through code suss cannot follow to the queue. So a job no code in the run enqueues may still run.
messageBusUnused
Severity: warning.
A queue or a topic is declared in infrastructure and neither produced to nor consumed from anywhere in the project.
[WARNING] messageBusUnused
aws_sqs channel "LeftoverQueue" is declared in infrastructure, but nothing in this run sends to it or receives from it. It is often left over from a feature that was removed.
boundary: cloudformation (aws_sqs)Nothing breaks at runtime, so removing it is a judgement.
A queue that another queue redrives into, that a rule or a subscription delivers to, or that a function uses as its dead-letter queue or destination has a producer, so it is not reported. A rule's channel is not reported when the rule has any target, even one suss does not read, such as a state machine.
Legitimate when: something outside the project uses it, or it is kept on purpose for a consumer that has not landed.
A bug when: the feature it belonged to is gone. This is cleanup.
messageBusConsumerDisabled
Severity: info.
A rule or a subscription deploys switched off, so its target receives nothing until somebody enables it.
[INFO] messageBusConsumerDisabled
ChargeWorkerFunction.NightlyRule is wired to eventbridge channel "schedule:NightlyRule" through "NightlyRule", which is deployed disabled. It receives nothing until someone switches it on, so it is not counted as a consumer of the channel.
boundary: cloudformation (eventbridge)The subscription counts as a consumer nowhere in this pass. A producer whose only subscriber is disabled comes out as messageBusProducerOrphan, the disabled rule is never reported as a waiting messageBusConsumerOrphan, and its channel is not reported as messageBusUnused, because a rule somebody switched off on purpose is not an orphan resource.
Legitimate when: somebody switched it off deliberately. That is the common case, so this kind is info.
A bug when: somebody meant to enable it and did not. No other finding will say so.
repeatUnsafeConsumer
Severity: warning.
An SQS queue that is not FIFO can deliver one message more than once, and the handler draining it makes a POST to another service while handling it. A second delivery makes that call again, so the customer gets charged twice, or gets two orders.
[WARNING] repeatUnsafeConsumer
SQS queue "ChargesQueue" can deliver one message more than once, and handler makes POST /v1/charges while handling it. A second delivery makes that call again. If the far side takes an idempotency key and this call sends one, the repeat is harmless and the finding can be accepted. A summary does not record the headers a call sends, so suss cannot tell.
consumer: src/handlers/chargeWorker.ts::handler (src/handlers/chargeWorker.ts:3)
boundary: cloudformation (aws_sqs)A call that sends an idempotency key is safe and still reported, because a summary does not record the headers a call sends. Suppress those. A FIFO queue is left alone, and so are GET, PUT, PATCH and DELETE, which land on the same resource twice. A storage write is left alone as well: whether a repeat overwrites the same row or appends a new one turns on where the key's value came from, and a summary does not say that today.
Both gaps come down to the same missing piece: a summary cannot yet state that a call is idempotent, and on what. Issue #516 describes what that would take.
Unit-invocation findings
unitInvocationTargetUnknown
Severity: warning.
Code invokes a deployed unit by name and no deployment source in the run declares a unit by that name. The name is read off the call: a literal, the resource segment of an ARN, or whatever the invoking unit's environment points the variable at.
[WARNING] unitInvocationTargetUnknown
OrderApi.handler invokes the lambda "legacy-pricing", but nothing in this run deploys a unit by that name. The unit may be deployed by a stack this run did not read, or not be set up yet. If neither is true, the name no longer exists.Legitimate when: the callee is deployed by another stack, or the infrastructure has not landed yet.
A bug when: the name is stale. The call fails at runtime with ResourceNotFoundException and nothing at deploy time says so.
Runtime-config findings
runtimeScopeUnknown
Severity: info.
suss could not tell which code a runtime runs, so it paired that runtime's environment contract against nothing. The run skipped that check. Nothing here says the code is wrong.
[INFO] runtimeScopeUnknown
ReportBuilder (lambda) has no codeScope, so suss cannot tell whether code in this runtime reads its declared environment variables. Add Metadata.SussCodeScope to the resource, or give it a SAM CodeUri, so its environment can be paired with the code that reads it.
boundary: cloudformation (os)There are two ways to get here. The provider declares no codeScope, or declares one that resolved to no source files. Raw CloudFormation with an S3-built artifact is the usual case. Fix: add Metadata: { SussCodeScope: { CodeUri: "src/handlers/x" } } to the resource, or wire CodeUri through.
Or several providers declare a directory containing the same source file, and nothing in that file says which deployed unit it belongs to. A service that builds every function from the service root gives them all one directory, and attributing a shared helper to all of them would report one process.env read once per function. Fix: let a pack discover the code under a template entry so it comes with a deployed unit, or give each function a CodeUri covering only its own sources.
Meta findings
lowConfidence
Severity: info.
suss could not finish reading one side. Predicates stayed opaque, type resolution failed, or confidence dropped below medium.
[INFO] lowConfidence
GraphQL operation "<anon>.CheckOrderInvoicesStatus" spreads "...Invoice" but no fragment definition with that name was found, so the fields selected through it were not checked.
boundary: apollo-client (http)It also reports every unreadOutcome gap on the provider. Such a gap means a return in the handler didn't match any of the terminal shapes the pack looks for.
Fix: add that terminal shape to the pack. Until then the summary is incomplete, so this kind is info.
unsupportedSemantics
Severity: info.
A pack found a boundary but cannot work out its other side. The finding records which reading stopped.
[INFO] unsupportedSemantics
SNS subscription "FromEvents" on topic "EventsTopic" routes to AuditFunction.FromEvents, but the subscription declares a FilterPolicy, and suss pairs a subscription only on its whole topic. No producer was paired with it, so what reaches it went unchecked.
boundary: cloudformation (aws.sns)It is also reported for an EventBridge rule whose pattern filters source or detail-type with something other than a list of strings, such as a prefix. Every send on the bus may reach that rule, so neither side is reported as an orphan, and nothing sent to it is compared. It is also reported for a boundary no pack can summarise, such as a WebSocket subscription handler or a gRPC streaming method.
Legitimate when: the channel is decided outside the code, by a deploy-time value or a change somebody made in a console, so the source does not state it.
A bug when: the source does state it and the pack could not follow it. That is a gap in suss, and the reason on the finding tells you where.
ambiguousProvider
Severity: warning.
One consumer matched two providers where at most one of them can be right.
[WARNING] ambiguousProvider
GraphQL operation "GetUser" selects "Query.user", which 2 resolvers implement across 2 services (accounts-service, directory-service). The pairing key has no endpoint identity, so this operation pairs with all of them and some of those pairs are wrong.
boundary: graphql-documents (http-graphql)REST pairing reports it when routes in two different functions match one call equally well, such as two catch-all routes /:username/:view and /:feed_type/:timeframe for a call to /admin/stats. The framework picks one by the order the routes are declared, which suss does not read, so the call pairs with neither. A call that picks its URL, fetch(isInvite ? "/invites" : "/users"), reaches each route on requests of its own, so it pairs with both and nothing is reported.
GraphQL pairing reports it when one root field is served by resolvers from two workspaces. It also reports it for a project that sets the Apollo pack's unboundClients to pairWithAll, where an operation whose client suss cannot tie to one service pairs with every schema that has the field. There each schema file counts as its own service. Under the default setting that operation is listed as not checked instead.
Storage reports it for a container. A table declared as {StageName}-orders-blue and one declared as prod-orders-{Colour} both cover prod-orders-blue, and each states as much of its own name as the other, so nothing in the run says which one the code reaches. The access pairs with neither and the finding says which two were in the way. Where one states more of its name, that one takes the access and no finding is emitted.
Legitimate when: the two providers are the same route in two documents, so whichever the consumer reaches behaves the same.
A bug when: they are different services that happen to share a method and a path. The consumer is being checked against an API it never calls, so every finding on that pair is suspect until the collision is resolved.
Reserved kinds
These six are in the enum and no checker emits them today. Each is a failure mode different enough from the generic kinds to keep its own name.
restMethodOnUnknownPath: error. The consumer's call targets a(method, path)the provider does not expose, so every call to the missing endpoint returns a 404 the caller wrote no branch for. It is about which boundary the call reaches, whereboundaryFieldUnknownis about a field, so the two stay separate. Today's pairing layer leaves both summaries unmatched, which quietly hides what is probably a typo. The emitter ships once pairing has a "consumer with no provider" finding distinct from "unmatched".authPolicyMismatch: error. The provider requires authentication and the consumer's call does not supply it, so the request is rejected whenever it runs. It needs auth-policy modeling on both sides, OpenAPI security schemes against the client's own header or interceptor patterns.boundaryFieldRequired: error. The provider declares a field as required and the consumer does not supply it, so the provider rejects the request every time: a 4xx, or a component that fails to render. Theaspectwould point at the payload. It takes the place ofrequiredHeaderMissing,componentRequiredPropMissingandgraphqlRequiredArgMissing.boundaryConstraintViolation: error. The value has the type the provider declared and breaks a value-level rule it declared, such as enum membership or a length. It takes the place ofstorageEnumConstraintViolationandgraphqlEnumValueUnknown.envVarRequiredButUnmarked: warning. The code treatsprocess.env.Xas required (if (!process.env.X) throw …) and the runtime contract does not mark it required. Nothing misreads while the variable is set, so tightening the contract is a judgement. Emitting it needs a required attribute on the runtime contract's env-var entries first.opaquePredicateBlocking: info. A pairing pass refused to emit substantive findings because too many predicates on the relevant transitions are opaque. It is per pair, wherelowConfidenceis per summary.
Intent findings
These come from a second checker and go in a second list. suss check --intent DIR pairs the intent docs your team writes against the same code summaries and puts what it found under intent in the JSON rather than under findings.
They have a different shape, because one side is a document rather than code, so there is no provider and no consumer:
| Field | Type | Meaning |
|---|---|---|
kind | string | One of the kinds below. |
severity | error | warning | info | The default. |
boundary | string | A readable label, such as GET /users/:id or fn:@suss/cli::contract. The key the intent and the code paired on. |
intent | { name, outcomeId? } | The document's name for a boundary intent or title for a PRD, plus the declared outcome where the finding is about one. |
code | string? | The matched summary as ${file}::${name}. Absent on unimplementedBoundary, where no code matched. |
scenario | { title?, link?, coveredBy? }? | Set only on the scenario kinds: the scenario's title, and the link or the covering test the finding is about. |
message | string | One line of human-readable text. |
suppressed | IntentFindingSuppression? | Set only when a .sussignore rule matched. |
One rule applies to all of them: a finding against intent suss inferred rather than a person wrote is downgraded one level. An intent doc has a source field, and inferred means suss guessed the declaration from the code. Curating the document restores the full severity, so an error you see at warning may mean nobody has confirmed the intent yet.
The severity split follows from what an intent doc is. A person wrote it deliberately, so code that fails to satisfy it is a defect, and those kinds are errors. An intent nothing can check, or a scenario pointing at nothing, is a gap in the documents, and those are warnings. Code that does more than the document claims is info.
unimplementedBoundary
Severity: error.
Somebody declared a boundary in an intent doc and no code in the run produces it.
[error] DELETE /users/{id}: Intent "delete-users-id" declares boundary DELETE /users/{id} with 1 outcome(s); no code produces this boundary.Legitimate when: the intent was written ahead of the code on purpose, or the implementation is in a repository this run did not read.
A bug when: the code is supposed to be here. Either the boundary was never built, or it is built under a key that does not match the one the intent declares, and a spelling that differs by one path parameter is the usual cause. Run suss inspect --dir and compare the key the code claims against the one the intent declares.
uncoveredOutcome
Severity: error.
The code implements the boundary and none of its transitions produce one of the outcomes the intent declares.
[error] GET /users/{id}: Intent "get-users-id" declares status 410 at GET /users/{id}; get has no transition that produces it.An outcome can also declare the effects it results in, and the same finding covers those: a queue consumer whose intent says an outcome results in a write to aws.dynamodb:Invoices, where no transition of it writes that table.
Legitimate when: the outcome is produced somewhere suss cannot follow, in a shared error handler or a framework layer the pack does not read. Check the summary's gaps before treating it as missing. For a declared effect, an access whose container the code receives as an argument is one the storage pass can resolve and this pass cannot, so it matches nothing here.
A bug when: the branch is absent, meaning nobody implemented the behavior the document declares.
outcomeShapeMismatch
Severity: error.
A branch produces the declared outcome and the body it returns disagrees with the body the intent declares.
[error] GET /users/{id}: Body shape for status 200 at GET /users/{id} disagrees with intent "get-users-id": get produces an incompatible shape.Several branches can produce one status with different bodies, so the check is satisfied when any matching branch produces a conforming shape. This fires only when none of them does.
Legitimate when: the intent doc describes the body loosely and the code is right. Fix the document.
A bug when: a caller reading the field the intent promised gets something else. This is the same failure as boundaryShapeMismatch, with a written declaration on one side instead of a second piece of code.
undeclaredOutcome
Severity: info.
The code returns a REST status the intent never mentions, or it reaches a boundary no outcome mentions.
[info] GET /users/{id}: get produces status 404 at GET /users/{id}; intent "get-users-id" does not declare it.One finding per status, so two catch arms both returning 500 produce one, and one per verb and boundary, so a unit writing the same table twice produces one. Statuses are limited to REST on purpose, because function-call returns are too numerous for every undeclared one to mean something.
Legitimate when: the status is a framework default or an infrastructure response nobody intended to write down. A 500 from an unhandled throw is not a promise anybody made. For a boundary, the intent may be scoped to one part of what the unit does.
A bug when: the status is part of the contract callers depend on and the document does not say so, or the unit writes a store the document never mentions. Add it, since a person reading the document will not know about it.
renamedBoundary
Severity: error.
The intent declares a store the unit never touches, and the unit touches an undeclared store of the same system instead, with the same verbs and outcomes.
[error] bus:aws_sqs InvoicesQueue: Intent "bus-aws-sqs-invoices-queue" declares aws.dynamodb:PaidInvoices; InvoiceWorkerFunction.handler writes aws.dynamodb:Invoices instead, with the same outcomes. If the store was renamed, update the intent.Renaming a store without updating the intent doc would otherwise produce an uncoveredOutcome for every verb and outcome declared against the old store, plus an undeclaredOutcome for every verb the code touches the new one with. This replaces that whole set with one finding. Pairing requires the two boundaries to share a system prefix, their verbs to match exactly, the new one to satisfy every declared use the old one had, and each side to have exactly one candidate on the other.
Legitimate when: never. The pairing is a guess about the cause, and the document and the code disagree either way.
A bug when: always. Update the intent if the store was renamed, and fix the code if it was not.
pathWithoutEffect
Severity: error.
The intent's always block says every outcome has an effect, and one transition that produces a declared outcome does not have it.
[error] DELETE /admin/users/{id}: Intent "admin-users-delete" says every outcome results in a write to postgresql:audit_log of actor_id, action at DELETE /admin/users/{id}; the transition of delete at line 22, which produces not-found (status 404), does not. Add the effect on that path, or list not-found under except.A results line on an outcome is satisfied when one transition producing that outcome has the effect. An always line has to be true of each transition, so there is one finding per transition, with the line it starts on and the outcome it produces. A throw counts as an outcome like any other. The checker skips a transition whose outcome is listed under except, and one that does not produce any declared outcome, which undeclaredOutcome reports instead.
Legitimate when: the outcome should not have the effect, as a request turned away before anything happened usually should not write an audit row. List its id under except. It is also legitimate when the effect happens somewhere the transition's summary cannot see, such as middleware that writes the row after the handler returns.
A bug when: the branch was supposed to have the effect and nobody wrote it, usually because somebody added an early return above the audit write. Add the effect on that path.
valueFromElsewhere
Severity: error.
A results line says where a column's value comes from with from, and the code takes it from somewhere else.
[error] GET /orders: Intent "orders-list" says listed reads postgresql:orders with tenant_id taken from input.auth.tenantId; get takes it from input.body.tenantId (in the transition at line 21). Take the value from the source the intent gives, or correct the document.The summary records, for each column an access writes or picks rows by, where the walk from its value ended. The finding fires when none of the effects the line matched takes the column from the source the line gives, and at least one takes it from a source suss can name: a path off the unit's input, or a literal. When every walk stopped at something it cannot follow, the claim goes under unchecked as unreadValue instead, since the value might still come from the source the document gives.
Legitimate when: the document is out of date, as when a route that took the tenant from a header now takes it from a verified token. Correct the from line.
A bug when: the code takes a value from the caller that should have come from somewhere the caller cannot choose, such as a tenant id read from the request body instead of the token. Take the value from the source the intent gives.
unreadInputField
Severity: warning when the field is required, info otherwise.
The intent's receives block declares a field, and no transition of the unit reads that path or anything under it.
[warning] fn:@suss/checker::checkPair: Intent "checker-check-pair" says fn:@suss/checker::checkPair receives consumer and needs it; checkPair never reads it.A read that goes deeper satisfies the declaration, and so does a read of the object the field belongs to: declaring pair.provider is satisfied by a unit that reads pair whole. The comparison is skipped altogether when the read set could be shorter than what the unit really reads, which is a rest parameter, a payload used whole, or a summary that recorded nothing.
On a REST route, what every wrapper registered around the route reads counts as what the route reads, so a header checked in middleware satisfies the declaration. A handler that passes the body to a validator has used it whole, and no declared body field is reported against it. Storage and unit-invocation boundaries accept a block and compare nothing against it yet.
Legitimate when: the field was written ahead of the code that will read it.
A bug when: the field was renamed on one side. The declaration says the caller must supply it and nothing uses it, so either the code stopped reading it or the document has the old name.
undeclaredInputRead
Severity: info.
The unit reads a path off what it was handed that the receives block does not list.
[info] fn:@suss/checker::checkPair: checkPair reads options.stream off what it was handed at fn:@suss/checker::checkPair; intent "checker-check-pair" does not declare it under receives.suss reports it only when the doc has a receives block. A doc without one says nothing about the input, the same way a doc without results says nothing about effects. One finding per path, so a field read in three branches is one.
On a REST route, a read under body is never reported when the block declares a body, since the shape is where the body gets described. Header names compare case-insensitively.
It is at info because a block lists only the fields the author wanted checked. It does not describe the whole input. A handler often reads a header for logging or tracing that no author would write down.
Legitimate when: the field is one the document deliberately leaves out. Treat the list as what the code asks for beyond what anybody wrote down.
A bug when: the code reads a field callers were never told to send. Add it to the block, or stop reading it.
unkeyableBoundary
Severity: warning.
The intent doc is well-formed and its boundary cannot be keyed for pairing, so nothing was checked against it.
[warning] function-call:intent: Intent "user-cache" has a function-call boundary that can't be keyed for pairing (a function-call boundary needs package + exportPath, or module + exportName where the module is one suss.json lists); it was not checked against code.A function-call boundary needs a package and an export path, or a module suss.json lists and an export name. A message-bus boundary needs a channel, and without those there is nothing to match the code against. The message says what the boundary's own protocol would need.
A store is the one case where filling the fields in does not help. Storage has no identity key by design, because a container name can be a pattern that only a caller or the deployment resolves, and the storage pass resolves it before pairing. Say what the store is for by putting - writes: aws.dynamodb:Invoices on an outcome of the boundary that touches it, and the checker compares that instead.
Legitimate when: the boundary is a store, or the intent was written ahead of the keying suss can do. Read the finding either way, because the author declared coverage they are not getting.
A bug when: the missing part is a field the author could write. Add it and the boundary starts being checked.
unlinkedScenario
Severity: warning. It was info until a scenario could be backed by a test as well as a link.
A scenario in a PRD has neither a link to a boundary outcome nor a test under coveredBy. The scenario makes sense as text, but nothing checks whether the behavior it describes exists.
[warning] prd:Reading a user: Scenario #3 in PRD "Reading a user" has neither a link to a boundary outcome nor a covering test, so nothing checks it; add a link, or list the test that covers it under coveredBy.Legitimate when: the scenario is still being written, or no test covers it yet. Record that with a .sussignore rule that gives the scenario's title under scenario, with the reason, so the gap is on record and a scenario added later is still reported.
A bug when: the scenario describes something the code does and a link or a test could back it. Add one.
missingCoveringTest
Severity: warning.
A scenario lists a test under coveredBy, and no test unit in the summaries has that file and that title path. The message lists the titles the file does have, so a renamed test is easy to spot.
[warning] prd:Cancel an order: Scenario "cancelled twice" in PRD "Cancel an order" lists the test "src/orders.test.ts > cancel > changes nothing the second time", and no test in src/orders.test.ts has that title. Tests there: cancel > marks the order cancelled; cancel > refunds the payment.Legitimate when: the test file was not read. Extract it with a test pack, such as suss extract -f vitest --intent <dir>. A title a runner builds per row, like it.each, is read once under its pattern, so a spelling of one expanded row is reported here too.
A bug when: the test was renamed, moved or deleted, and the scenario still claims it. Update the spelling, or find the test that covers the scenario now.
testMissesSubject
Severity: warning.
The test exists and runs, and either suss followed every call it makes and none reaches what the scenario is about, or it reaches the subject only through something the test replaced with a mock. What it has to reach is the scenario's about, or, without one, one of the boundaries the PRD's other scenarios link to.
[warning] prd:Cancel an order: Scenario "cancelled" in PRD "Cancel an order" lists the test "src/checkout.test.ts > cancels on a failed checkout", which reaches cancelOrder only through a call its mocks replace (vi.mock("./orders")), by checkout -> cancelOrder.When the subject is a class, or a file a class is written in, a call the test sends to that class or an instance of it reaches it wherever the method is written: in a module the class includes, as account.reviewed? is when Reviewable defines reviewed?, or in the library, as Account.find(1) and a column read are.
A test that doesn't reach its subject through the calls suss followed, and makes a call suss could not follow, on the way or in its own body, might still reach it through that call. suss doesn't report it as this finding. It goes under unchecked as unfollowedCall, with the calls it could not follow, and its scenario is not counted as covered. A test that reaches its subject only through a call that takes its function from a table, or picks a method by a name it computes, goes under unchecked as possibleCall for the same reason: that call may run another function instead.
Legitimate when: the test reaches the subject through something suss doesn't follow and doesn't record a gap for, such as a child process. Accept it with a rule for that scenario, saying why.
A bug when: the test was changed to call something else, or a mock was added in front of the subject. The scenario no longer rests on a test that exercises it.
coveringTestSkipped
Severity: warning.
A scenario lists a test that is marked to not run: it.skip or it.todo in vitest, @pytest.mark.skip, skipif or xfail in pytest, xit, skip or pending in RSpec, or a test under a skipped suite, class or group.
[warning] prd:Cancel an order: Scenario "skipped" in PRD "Cancel an order" lists the test "src/orders.test.ts > cancel > cancels twice without harm", which is marked to be skipped, so it does not run.Legitimate when: the test is skipped for a while on purpose. Accept it with a rule for that scenario, saying until when.
A bug when: the skip was left in. Nothing checks the scenario while the test does not run.
danglingScenarioLink
Severity: warning.
A scenario links to an intent or an outcome nothing declares. Either no boundary intent goes by that name, or the intent exists and declares no outcome with that id. The message lists the outcomes it does declare.
[warning] GET /users/{id}: Scenario #4 in PRD "Reading a user" links to "get-users-id.301-moved", but boundary intent "get-users-id" declares no outcome "301-moved" (known outcomes: 410-gone, 200-ok).Legitimate when: the boundary intent is in a directory this run did not read. Point --intent at both.
A bug when: the name is wrong, or the intent was renamed and the link was not. The scenario claims coverage of something that does not exist.
ambiguousScenarioLink
Severity: warning.
A scenario links to a name two or more boundary intents share, so the link resolves to more than one and suss does not pick between them.
[warning] prd:Reading a user: Scenario #1 in PRD "Reading a user" links to "get-users-id.410-gone", but 2 boundary intents are named "get-users-id"; rename them so the link resolves to one.Legitimate when: never. Two intent docs sharing a name is a problem whatever the link does.
A bug when: it fires. Rename the intents so the link resolves to one.
undescribedOutcome
Severity: info.
A boundary intent declares an outcome and no PRD scenario links to it.
[info] DELETE /users/{id}: Intent "delete-users-id" declares 204-deleted and no PRD scenario says why it is there.The other three scenario kinds ask whether a scenario points at something that exists. This one asks the question from the other end, the way a product reader would: which of these behaviors has nobody written down a reason for. It stays quiet until at least one PRD is loaded, since before that every outcome would be reported and the list would be useless. suss infer prd writes a scenario per outcome, so a fresh set of drafts starts with none of these.
Legitimate when: the outcome is one nobody needs a reason for, such as a 500 from an unhandled throw. Treat the count as a coverage number.
A bug when: never on its own. It becomes one when you read the list and find an outcome nobody meant to ship.
Run findings
Run findings are a third list, under run in the JSON. They are about the run itself, so they have no two sides and no boundary key:
| Field | Type | Meaning |
|---|---|---|
kind | string | Which problem this is. |
severity | error | warning | info | |
description | string | What happened. |
remedy | string | What to do about it. A run-level problem has no boundary to point at, so it says the next step instead. |
nothingPaired
Severity: error. Emitted by suss check --dir, by a bare suss check over the project, and by suss check <a.json> <b.json> when one of the two files has no summaries, unless --allow-empty was passed.
The run paired nothing. No boundary had both a provider and a consumer, so nothing was compared. Without this finding the report would look exactly like one where both sides agreed. The run exits 3, which no finding uses.
error: nothingPaired
Read 6 summaries and paired nothing. No boundary in this run had both a provider and a consumer, so nothing was compared.
Check that both sides of at least one boundary are in the directory. A provider extracted from code needs its consumer extracted too, or its contract read with `suss contract`. `suss inspect --dir` over the same files lists the boundaries each side claims, and two spellings of one boundary is the usual cause.A run that didn't read a single summary gets the same finding, and its description says so. A bare suss check reports it when the extract didn't recognize anything in the code. A run with --intent that checked at least one intent doc did compare something, so it doesn't get one.
Legitimate when: you meant to extract one side. Checking a service against a contract you have not read yet pairs nothing, correctly.
A bug when: both sides are in the directory and still nothing paired. The two sides are spelling the boundary differently, and suss inspect --dir over the same files shows both spellings side by side.
mostlyUnpaired
Severity: error. Emitted under --fail-on-unpaired.
More boundaries had nothing to pair with than the floor allows. A run that pairs three boundaries out of hundreds otherwise exits the same as one that paired everything, and a CI gate on it goes green.
error: mostlyUnpaired
4 of 5 boundaries had nothing to pair with, over the --fail-on-unpaired floor of 1. 1 paired.
The unmatched lists in this report say which side each boundary is missing. Extract the missing side, read its contract with `suss contract`, or raise the floor if this share is expected.The floor takes a count (25) or a share (50%).
Legitimate when: the corpus is one-sided on purpose and the floor was set for a different mix. Raise the floor, or drop the flag for that run.
A bug when: both sides were extracted and the share is still high. The usual causes are unpathed providers, whose routes have a gap message saying why, and two spellings of one boundary.
packNeedsConfig
Severity: warning. Emitted by suss check over a folder whose extract note (<name>.incomplete.json) lists a pack that was left out for want of an option.
An extract named a pack that reads nothing until the project gives it an option, such as the directory graphql-ruby looks classes up under, and no option was given. The summaries are missing whatever that pack would have found, and an agreeing check over them says nothing about it.
warning: packNeedsConfig
The graphql-ruby pack reads nothing until root is set, for example {"root":"app/graphql"}, so its boundaries are missing from these summaries.
Write that to a JSON file, pass it as -f graphql-ruby=<config.json> to the extract, and run the check again.It is a warning because the check that follows fails on its own account when the missing pack left nothing to pair. A run that paired other boundaries passes with this entry beside it.
Legitimate when: the pack was named by mistake and the project does not use its library. Drop it from the extract.
A bug when: the project does use the library. The extract ran without the pack's config file, so its boundaries are not in the comparison.
packPathMissing
Severity: warning. Emitted by suss check over a folder whose extract note (<name>.incomplete.json) lists a path in a pack config where nothing exists.
A pack config gave a file or directory, and nothing is at the path it resolved to. A relative path is read against the folder the config file is in, so a config kept outside the project points somewhere else. The pack ran anyway, and every lookup under that path came back empty.
warning: packPathMissing
The graphql-ruby pack's root is app/graphql in /configs/suss.graphql-ruby.json, and nothing exists at /configs/app/graphql. A relative path is read against the config file, not the project. Whatever the pack would have read there is missing from these summaries.
Set root to /work/shop/app/graphql in the config file, or move the config file into the project. Then run the extract and the check again.It is a warning for the same reason as packNeedsConfig: the rest of the run was read, and a run that paired other boundaries passes with this entry beside it.
Legitimate when: the option lists a pattern such as engines/* that is meant for a layout this project does not have. Remove it from the config.
A bug when: the folder or file exists in the project. Give the path as an absolute one, or keep the config file in the project.
readFailed
Severity: warning. Emitted by a bare suss check, and by suss check --dir over a folder suss extract --out-dir wrote, when one of the project's reads did not finish.
The read exited with an error, ran past its time limit, or ran out of heap, so the summaries it would have written are missing. The other reads finished, and the check compared what they wrote.
warning: readFailed
`suss extract --lang typescript --dir apps/api -p apps/api/tsconfig.json -f express -f pg -f node` was stopped after 20m 0s, the time limit for one read, so the summaries it would have written are missing from this check.
Pass --read-timeout with more minutes to give it longer, or 0 for no limit. Then run the check again.It is a warning, so a run that paired the other reads passes with this beside it. --fail-on warning makes it fail the run.
Legitimate when: the read is one you do not need, such as a docs site in the workspace. Leave it out of suss.json.
A bug when: the read exits with an error. Run the command on its own to see the whole error. A read that runs long or out of heap on a project of ordinary size is worth reporting too.
noPackMatched
Severity: error. Emitted by a bare suss check, and by the MCP suss_check tool, when the project has source in a language suss reads and no pack matched anything in it.
Nothing was extracted, so nothing could be compared. The run exits 4. --allow-empty does not change that, because no read ran.
error: noPackMatched
Nothing in /work/orders matched a pack, so there is nothing to read or compare.
Run `suss init` to see what suss looked for, name a pack yourself with `suss extract -f`, or pass a summaries file.Legitimate when: the project uses a framework suss has no pack for. Write a pack for it, or leave check out of this repository's CI.
A bug when: the project does use a framework suss reads. Its dependency is probably declared somewhere init does not look, so name the pack with -f.
notCovered
Severity: info. Emitted by a bare suss check, and by the MCP suss_check tool, when no pack matched and the project has no TypeScript, JavaScript, Python or Ruby source at all.
suss does not cover the repository. The run exits 0, and the text report is this one line:
suss does not cover /work/warehouse. It has no TypeScript, JavaScript, Python or Ruby source, so there is nothing to compare.Legitimate when: the repository is in a language suss does not read, such as a dbt project of SQL and YAML.
A bug when: the source is there and suss missed it. suss skips dependency and build folders such as node_modules, dist and venv, so source kept only under one of those is not counted.
unreadableInput
Severity: error. Emitted under --fail-on-unreadable.
A file in the summaries directory could not be read as summaries.
error: unreadableInput
1 file in the directory could not be read as summaries: report.json: suss could not read [...] as summaries. It should be the output of `suss extract` or `suss contract`. What did not fit:
- <root>: Invalid input: expected array, received object
Fix or remove the files, or write summaries somewhere reports are not written back to. A truncated extract output and a report saved into the summaries directory are the usual causes.Without the flag the file is skipped with a warning on stderr and the run exits by findings alone, so a truncated extract output passes the run. The --json body lists the skipped files whether or not the flag is on.
Legitimate when: the directory deliberately mixes summaries with other JSON a different tool reads. Move the other files, or leave the flag off.
A bug when: the skipped file was written by extract. A truncated output means the extract was interrupted, and a report written back into the summaries directory means an -o path pointed at the wrong place.