~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
337 lines · 15.2 KB Raw History
# Development

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

## Layout

| Path | What |
| --- | --- |
| [`crates/knotless`](../crates/knotless) | the CLI, `git-remote-knotless`, and the library the others share |
| [`crates/knotless-mcp`](../crates/knotless-mcp) | the MCP server |
| [`crates/knotless-serve`](../crates/knotless-serve) | the cache, appview, gateway, moderation service and the pages' XRPC queries |
| [`crates/knotless-run`](../crates/knotless-run) | the workflow runner |
| [`web`](../web) | the SvelteKit pages |
| [`docs-site`](../docs-site) | these docs as the site the pages serve at `/docs` |
| [`lexicons`](../lexicons/dev/knotless) | the `dev.knotless.*` schemas |
| [`harness`](../harness) | the test PDS, coverage, screenshots, end-to-end flows and scenarios |
| [`deploy`](../deploy) | how knotless.dev is deployed |
| [`.knotless/workflows`](../.knotless/workflows) | this repository's CI |

## Tests

Tests run against the reference PDS from npm, started by
[`harness/pds.mjs`](../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.

```sh
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.

```sh
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.

## Web pages

The pages are the SvelteKit app in [`web`](../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`, `/<authority>/<repo>/raw/` and `/archive/` 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](reference/api.md#web-data).

## The docs site

The pages serve these docs at `/docs`, as a [Starlight](https://starlight.astro.build)
site built from [`docs-site`](../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.

## Screenshots

The screenshot tests ([`harness/shots`](../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`](../harness/shots/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.

```mermaid
flowchart LR
  world[(World: a reference PDS,<br/>seeded the same each time)] -->|just shots-record| fixtures[fixtures.json:<br/>each page's XRPC answers]
  basefix[The base's web/<br/>and fixtures.json] -->|shot first| before[target/shots/before]
  fixtures -->|just shots,<br/>just shots-check| cmp{Same pixels?}
  before --> cmp
  cmp -->|no| moved[What moved:<br/>target/shots/results]
```

```sh
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`](../harness/shots/look.mjs) lists the options):

```sh
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`](../.knotless/workflows/look.yml)): `LOOK` is `just look`'s
arguments, and its pictures are the check's outputs.

```sh
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:

```sh
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`](../harness/shots/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.

## End to end

The shots show what the pages draw. The end-to-end flows
([`harness/e2e`](../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.

```sh
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`](../harness/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`](../harness/e2e/flows.spec.mjs).

### Private repositories

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](design/access.md#private-repositories)). So the private flows
([`private.spec.mjs`](../harness/e2e/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.

```sh
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

Scenarios ([`harness/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:

```sh
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`](../.knotless/workflows/scenarios.yml)).
The report is at the end of the check's log:

```sh
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
```

## Lexicons

A new or changed schema under `lexicons/` follows the [Lexicon Style
Guide](https://atproto.com/guides/lexinomicon) 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.

## Contributing

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

```sh
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:

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