Skip to content

Development

How to build and test knotless. Agents working here should also read AGENTS.md, which says what to run locally and what to leave to CI.

PathWhat
crates/knotlessthe CLI, git-remote-knotless, and the library the others share
crates/knotless-mcpthe MCP server
crates/knotless-servethe cache, appview, gateway, moderation service and the pages’ XRPC queries
crates/knotless-runthe workflow runner
webthe SvelteKit pages
docs-sitethese docs as the site the pages serve at /docs
lexiconsthe dev.knotless.* schemas
harnessthe test PDS, coverage, screenshots, end-to-end flows and scenarios
deployhow knotless.dev is deployed
.knotless/workflowsthis repository’s CI

Tests run against the reference PDS from npm, started by harness/pds.mjs. They run again against the permissioned-data alpha build of that PDS, for private repositories. The alpha migrates an account’s database every time it opens it, which made the same tests cost its PDS about a fifth more CPU than the stock one; the harness has it migrate each database only the first time. Every line and function is covered across the two runs, and CI fails below that.

just setup # npm install in harness/ and web/, llvm-tools for coverage
just test # every test
just test-spaces # against the alpha: KNOTLESS_DEV_ENV names its dev-env
just cov # both runs side by side, failing under 100% lines or functions
just lint # rustfmt, clippy and svelte-check
just lex # lint the lexicons, and check they only grow from main's
just bench 50 # the performance table, at 50 ms RTT
just bench-cards # what a social card costs to draw, with the PNGs

Each test binary otherwise starts a PDS of its own, which takes most of a short run. While working, keep one PDS up and run only the tests a change needs. On a shared PDS, tests make the accounts they need.

just pds # leave a reference PDS up for test binaries to share
just changed # files changed since tests last passed, and their tests
just t-changed # run those, and mark them passed
just t -p knotless-serve --test web # or any cargo test arguments

The pages’ TypeScript types are made from the lexicons under lexicons/ by @atproto/lex: npm run lex in web/ writes them into web/src/lib/lexicons, and npm run dev, build and check run it first. The few atproto lexicons ours refer to are copied into web/lexicons, built beside them. CI checks every answer the serve tests receive against those lexicons.

The pages are the SvelteKit app in web, built with adapter-node. knotless-serve, given --web, hands it every request it does not answer, and the app reads its XRPC queries back from knotless-serve.

  • The proxy drops hop headers and sets x-forwarded-host and x-forwarded-proto, so the app is run with HOST_HEADER=x-forwarded-host PROTOCOL_HEADER=x-forwarded-proto, and KNOTLESS_SERVE naming knotless-serve (default http://127.0.0.1:2590).
  • The app’s handleFetch sends its own-origin /xrpc fetches straight to KNOTLESS_SERVE, since knotless-serve can share the site’s origin, which Kit would answer itself.
  • vite dev proxies /xrpc and /<authority>/<repo>/raw/ to KNOTLESS_SERVE.
  • An element is marked data-drawing until its diagrams are drawn, and the root gets data-ready once mounted. Screenshots wait for both.
  • Components are layered as atoms, molecules, organisms and templates; a component uses its own layer and those below, and just lint checks it.

The queries are listed in the API reference.

The pages serve these docs at /docs, as a Starlight site built from docs-site. It reads docs/ as it is, so each page still reads as Markdown here:

  • Each page’s # heading is its title, and a README.md its directory’s index. docs/design.md, which only says where its sections went, is left out.
  • Links between pages go to the site’s pages, and links out of docs/ to the file’s page in the repository.
  • Mermaid blocks are drawn by Pele as the site is built, in the pages’ colours.
  • The reference reads as reference (docs-site/reference.mjs): a table whose rows each start with code, a command, flag or route, and say a sentence or more of it, or that has four columns or more, is drawn as a list, each name on a line of its own and the columns between as labelled lines. A route or method shows its HTTP method as a badge, and an XRPC method of dev.knotless’s has its lexicon folded away under it: parameters, input and output, from lexicons/. Write the table as ever; the site does the rest.
  • The sidebar is listed in docs-site/astro.config.mjs: a new page goes there too.
  • Its colours and type are the pages’ tokens, from web/src/tokens.css.

just docs-site serves it at http://localhost:4321/docs/ as you write. The image builds it into the pages’ static/docs before the pages are built, as adapter-node serves only the files it finds as it builds; the docs check builds it too.

The screenshot tests (harness/shots) take every page in light and dark, at desktop and phone widths, with Playwright’s Chromium.

The app in web/ draws each page from what knotless-serve answered for it, as recorded in harness/shots/fixtures.json, so a run needs no PDS or server. The recording comes from a world: a reference PDS seeded with the same repositories, pulls and issues each time, with fixed commit dates and record times.

No screenshot is kept. A run compares the pages as a base draws them with the pages as this checkout draws them, shot in the same browser in the same run (compare.mjs). The base’s web/ and lexicons/ come out of git into target/shots/base and are built there, with its own fixtures.json. Only the pages whose HTML differs are shot: first the base’s, as the snapshots, then this checkout’s, against them to the pixel.

just shots-recordshot firstjust shots,just shots-checknoWorld: a reference PDS,seeded the same each timefixtures.json:each page's XRPC answersThe base's web/and fixtures.jsontarget/shots/beforeSame pixels?What moved:target/shots/results
just shots # what moved since main, to look at
just shots -g ' profile$' --project=desktop-light # or only some
just shots-check # the same, failing on a page moved but not named
SHOTS_BASE=<commit> just shots # against another commit

A page moved on purpose is named on a line of its own, Shots-moved: home, pull-open, or Shots-moved: all. The shots check reads the line from the pull’s body, as it runs; shots-check here reads it from the commit messages since the base, as a merge-queue batch does from its squashed pulls’ bodies. A page that moved and is not named fails the check, and every page that moved, named or not, is among the check’s outputs.

The shots say what moved, and only in the states the world holds. To see what a page is, in any state, just look draws it from the same recording as pictures in target/look, changing what a method answers where asked, so long as the answer is still as its lexicon says (look.mjs lists the options):

just look pull-open # desktop light and phone dark
just look / --signed-in --set 'getFeed.events=[]' # an empty feed, logged in
just look repository --click 'header details.popover summary' --all

With no checkout, as in a chat over MCP, the runner looks instead, at a branch, when the owner asks for the look workflow (look.yml): LOOK is just look’s arguments, and its pictures are the check’s outputs.

knotless rerun knotless://chadtmiller.com/knotless --ref refs/heads/<branch> \
--workflow look -e LOOK="profile --phone --signed-in"
knotless checks knotless://chadtmiller.com/knotless --ref refs/heads/<branch> \
--workflow look --wait 300 --images target/look

The ui skill (.claude/skills/ui) has the states to look at and the house rules to hold them to.

When web/ changes, every page’s HTML differs, as the built assets are named for their contents, so every page is shot on both sides. A change to the recording alone shoots only the pages whose answers changed.

After a change to what the server answers, record again:

just shots-up # seed a world once, and keep it up for the runs below
just shots-record # record each page's answers from it
just shots-live # the recording against the pages through knotless-serve over the world
just shots-cards # the social cards, which knotless-serve draws, to look at

shots-live draws this checkout’s pages twice, from the recording and from the world, so a page that moved there is one whose recording is out of date. Without shots-up, a recording or live run seeds a world of its own. DIDs, which the PLC directory makes new for each world, are masked.

A few pages, such as a repository’s settings, only show what they are to someone signed in. viewers in world.mjs names those views and who sees them. Their answers are recorded signed out, and the stand-in for knotless-serve answers getViewer as that account. A world has no session to sign them in, so shots-live skips them.

just shots-linux runs the check as CI does, in Playwright’s Linux image under Docker.

The shots show what the pages draw. The end-to-end flows (harness/e2e) show that what they do lands. In Chromium, as bob and then alice, they:

  • log in through the PDS’s own approval page
  • star, follow, open an issue, comment, react, close and reopen
  • fork, merge a pull, revert it, and leave the merge queue and join it again
  • open a pull from a fork, retarget it, rerun a check, and run a workflow when asked, at a tag with a variable
  • edit, delete, report and hide comments
  • create a repository, set a profile, notifications and the theme
  • start an organization, invite to it, join it, leave it and end it
  • label, as the repository’s owner logged in to a second account
  • set a description and topics, labels, branches, collaborators, the triager, secrets, and compact
  • merge into a protected branch, rename a repository and offer it to another account, who takes it up
  • archive and delete a repository, and delete an account’s knotless data
  • switch accounts, and log out

Each write is checked three ways: on the page at once, as a record in the PDS, and on the page read anew from the index.

just e2e # a world of its own, then every flow, in order
just e2e -g 'logging in through|starring' # or only some, the login first

The run seeds a world as the shots’ is, on ports of its own, so it can run beside shots-up. It writes to that world as it goes, so the world is never one the shots read. knotless-serve then serves the world again as a gateway, behind the harness’s https front (tls-proxy.mjs), as a PDS sends a login back only over https. Chromium reaches that front at the harness’s gateway hostname and takes its certificate. A failed flow leaves a screenshot and a trace under target/e2e/results (target/e2e/private for the private flows).

A new control that writes from the pages gets a flow in flows.spec.mjs.

A private repository is built differently all the way down. Its records are in a space rather than anyone’s public repo, and its pages read them from this server’s own index of that space rather than the appview’s (Access). So the private flows (private.spec.mjs) run over a world on the spaces alpha, with a private repository of alice’s pushed from the command line. As alice and then bob, a reader she adds, they:

  • create a private repository from the pages, rename it and delete it
  • define a label, open a pull from a branch, label, close and reopen, hide a comment, and merge
  • add a reader and take them out again, who then reads it no more
  • open an issue, comment, react and edit as the reader, and comment on the pull
  • hear of the reader’s issue as the owner
  • fork it, privately, as the reader

Each write is checked on the page at once, from the space with the command line, as missing from the writer’s public repo, on the page read anew, and as not there at all to a browser logged in to no one.

KNOTLESS_DEV_ENV=<alpha>/packages/dev-env just e2e-private

KNOTLESS_DEV_ENV names a build of the alpha at test.yml’s ATPROTO_COMMIT, made with that workflow’s steps. A control that writes to a private repository differently from a public one gets a flow there too.

Scenarios (harness/scenarios) put agents to a day’s git work through the MCP server. Each track is a few steps by different people, such as a contributor’s fix going through review. Each step is a fresh Claude session, logged in as that person, with only the knotless tools, on a fresh PDS and appview. Each agent reports what tripped it up, and the run collects those reports along with every failed call:

just scenarios # every track; uses the `claude` CLI and costs model usage
just scenarios contribution # or only some

Transcripts and report.md land in target/scenarios/<time>/. SCENARIO_MODEL picks the model.

The runner also runs scenarios on main, only when asked (.knotless/workflows/scenarios.yml). The report is at the end of the check’s log:

knotless rerun knotless://chadtmiller.com/knotless --ref refs/heads/main \
--workflow scenarios -e TRACKS="contribution"
knotless checks knotless://chadtmiller.com/knotless --workflow scenarios --wait 7200 --log

A new or changed schema under lexicons/ follows the Lexicon Style Guide and passes just lex. A schema already published is evolved, not changed: new fields are optional, and nothing is removed, renamed, retyped, made required or narrowed. AGENTS.md lists everything that is checked.

Push a branch to a fork, or to this repository if you collaborate on it, and open a pull:

knotless template knotless://chadtmiller.com/knotless pull > body.md # then write it
knotless pull knotless://chadtmiller.com/knotless main \
knotless://<you>/knotless <branch> "<title>" -m - < body.md

The title and body follow .knotless/pull.md; an issue, .knotless/issue.md.

Seven checks run on it: check (fmt and clippy), test (100% coverage), web, lexicons, shots, docs and e2e. To see how they went:

knotless checks knotless://chadtmiller.com/knotless --pull <at-uri> --wait 600 --log