Read a pull request
inspect --diff reads the summaries from before a change and the ones from after, and prints the boundaries whose behavior moved.
src/routes/users.ts on the base commit has one route with three outcomes:
import { Hono } from "hono";
type User = { id: string; name: string; email: string; deletedAt: string | null };
declare function findUser(id: string): Promise<User | null>;
export const users = new Hono();
users.get("/users/:id", async (c) => {
const user = await findUser(c.req.param("id"));
if (!user) {
return c.json({ error: "not found" }, 404);
}
if (user.deletedAt) {
return c.json({ error: "gone" }, 410);
}
return c.json({ id: user.id, name: user.name, email: user.email });
});npx @suss/cli extract -f hono -o summaries/before.jsonThe pull request rewrites two of those returns. A deleted account used to get a 410, and now it gets a 200 with a status field in it. email is also gone from the success body:
import { Hono } from "hono";
type User = { id: string; name: string; email: string; deletedAt: string | null };
declare function findUser(id: string): Promise<User | null>;
export const users = new Hono();
users.get("/users/:id", async (c) => {
const user = await findUser(c.req.param("id"));
if (!user) {
return c.json({ error: "not found" }, 404);
}
if (user.deletedAt) {
return c.json({ id: user.id, status: "deleted" });
}
return c.json({ id: user.id, name: user.name });
});npx @suss/cli extract -f hono -o summaries/after.json
npx @suss/cli inspect --diff summaries/before.json summaries/after.json1 boundary changed: 3 outcomes.
~ serves GET /users/{id} src/routes/users.ts::get (3 outcomes)
outcomes
+ responds 200 { id, status } when findUser() && findUser().deletedAt
- responds 410 { error } when findUser() && findUser().deletedAt
~ responds 200 { id, name, -email } otherwise
Changes by file
src/routes/users.ts
~ getThe text diff is two edited lines in one file, and those two lines change what callers get. A caller that treated 200 as a usable account now receives deleted accounts as well, and a caller that read email gets undefined. The response still matches the User type and the OpenAPI document still lists 200 | 404 | 410, so the compiler and the schema both pass.
--diff reports the units whose behavior moved, whatever lines the pull request touched. When a large change moves the behavior of one unit, you get a report about that one unit.
The findings it introduced
inspect --diff says what moved. check --base says which of those moves break something on the other side of a boundary. Say the project also has a client for the route, in src/web/profile.ts:
export async function loadProfile(id: string) {
const res = await fetch(`/users/${id}`);
if (res.status === 404) {
return null;
}
if (res.status === 410) {
return { deleted: true };
}
const user = await res.json();
return { name: user.name, email: user.email };
}On the pull request's branch, check --base reads the commit the branch started from and reports the findings that are new since then:
npx @suss/cli check --base origin/mainCompared with eb902e5, where this branch left origin/main. 1 file changed:
1 boundary changed: GET /users/{id}
4 new findings, 0 resolved.
────────────────────────────────────────────────────────────
[WARNING] deadConsumerBranch
Consumer expects status 410 but provider never produces it
provider: src/routes/users.ts::get (src/routes/users.ts:8)
consumer: src/web/profile.ts::loadProfile (src/web/profile.ts:1)
boundary: hono (http) GET /users/:idThe client still waits for a 410 that the route stopped sending, and the report goes on to say that nothing in the client tells the new status: "deleted" body apart from a live account. Each finding gives the file and line on both sides. The findings the base already had are left out, and so are the ones that touch nothing the branch changed. Findings the branch fixed are listed after the new ones, one line each.
The checkout is left alone: the base is written out of git into a temporary folder and read there with the same packs. An agent reviewing the branch gets the same result from the MCP server's suss_check with base: "origin/main". suss check says which findings count as part of the change.
Put it on every pull request
The action runs those two extracts for you, one on the base commit and one on the head, and posts the diff as a comment. When you push again, it edits that same comment.
name: suss
on:
pull_request:
push:
branches: [main]
jobs:
behavior-diff:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- uses: nimbuscloud-ai/suss/.github/actions/inspect-diff@v0.34.1@v0.34.1 pins the action to a release. A commit SHA in place of the tag pins it to code you have read.
The action reads the packs from your suss.json, the file suss init writes. If you do not have one, it picks the packs init would have picked. Five inputs cover most projects:
extracttakes the arguments you would pass tosuss extractafter the command, so you can choose your own packs:-p tsconfig.json -f hono -f prisma.working-directoryis the directory to runsuss extractin, for a service kept in a subdirectory of the repository.installis a shell command that installs dependencies in the base checkout, such aspnpm install --frozen-lockfile. If you leave it empty, the base checkout shares the head checkout'snode_modules, and that works as long as the pull request does not change dependencies.findingsadds the findings the pull request introduced to the comment, after the diff, the waycheck --basereports them. It isfalseby default.commentistrueby default. A pull request from a fork gets a read-only token, so the comment step fails there. You can still read the diff in the job log and in the run's artifact, so if you would rather not see that failure, turn the comment off for pull requests from forks:
with:
comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }}The comment on the change above looks like this:
1 unit changes behavior in this pull request
1 boundary changed: 3 outcomes. ~ serves GET /users/{id} src/routes/users.ts::get (3 outcomes) outcomes + responds 200 { id, status } when findUser() && findUser().deletedAt - responds 410 { error } when findUser() && findUser().deletedAt ~ responds 200 { id, name, -email } otherwise Changes by file src/routes/users.ts ~ getRead by suss at 3f2a1c9. The summaries it compared are the
suss-diffartifact of the run;suss inspect after.jsonon them says where suss could not follow a call.
Run suss in CI covers the rest of the inputs, the second job that fails the build on a finding, and the caches that stop a large repository being read twice on every run.