diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index f1788ea..bfb706e 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -32,7 +32,9 @@ jobs: persist-credentials: false - uses: actions/setup-go@v5 with: - go-version: "1.24" + # Matches the `go` directive in services/parser/go.mod. Behind it, + # every job silently downloads a second toolchain before it can build. + go-version: "1.25" cache-dependency-path: services/parser/go.sum - name: Install CGO toolchain run: sudo apt-get update && sudo apt-get install -y gcc diff --git a/CLAUDE.md b/CLAUDE.md index ade4122..32049db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ The old working name "CodeCanvas" is retired; do not reintroduce it. - [x] Phase 4 — Webhooks, queue, hardening - [x] Phase 5 — Go, Rust, Python, JavaScript, Java (extraction only; per-language resolution stays cut) -Active task list: `TASKLIST.md`. +What is still open, and why each gap is deliberate: `PLAN.md` § Still open. ## How to work here @@ -39,7 +39,7 @@ You implement, phase by phase, with tests. The user reviews at each phase gate. "generated with" footers anywhere, including PR bodies. - **Stop at every phase gate.** Run the phase's exit test from `PLAN.md`, mark the PR ready, and wait for the user before opening the next branch. -- **A test ships in the same commit as the code it tests.** Keep `TASKLIST.md` checkboxes current. +- **A test ships in the same commit as the code it tests.** - **Ask when a choice changes the product**, not for routine judgment calls. ### Code style the user has asked for @@ -156,7 +156,7 @@ guarantee. ## Known gaps -Phase 3b is closed. `TASKLIST.md` is the chunk-level truth; this is what outlives it. +Phase 3b is closed. This is what outlived its chunk list. - **Node data is compared by value, not by reference.** `buildGraph` rebuilds every node's data on any change, so a `memo` keyed on reference re-rendered every card whenever one changed — and each @@ -226,7 +226,6 @@ make test # TypeScript AND Go. `pnpm -r test` silently skip make lint && make typecheck make go-vet # not part of `make test` git log --oneline -5 # what happened last -grep -c '\[x\]' TASKLIST.md # how far into the current phase gh run list --branch $(git branch --show-current) --limit 2 # is CI green? ``` @@ -246,8 +245,7 @@ commands are cheaper and more honest. |---|---| | What are we building, and what counts as done? | `PRD.md` | | What's the phase order and what closes each phase? | `PLAN.md` | -| What am I working on right now? | `TASKLIST.md` | -| How do I run it? | `DEVELOPMENT.md` | +| How do I run it, and how does someone contribute? | `CONTRIBUTING.md` | | What's the schema? | `docs/DATA_MODEL.md` | | How does extraction and resolution work? | `docs/PARSING_STRATEGY.md` | | What's still undecided? | `docs/RISKS.md` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1453ea7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,209 @@ +# Contributing to funcatlas + +Thanks for looking. This document is the short version of how a change gets made here and what +review will ask about. [`CLAUDE.md`](CLAUDE.md) is the long version — it is addressed to an +assistant, but it is the honest map of this repository and worth ten minutes before a first change. + +## Get it running + +You need **Docker**, plus `make` and `openssl`. + +```bash +git clone https://github.com/ARCoder181105/funcatlas.git +cd funcatlas +make setup +docker compose up +``` + +Then open , paste a public repository URL, and watch it chart. + +`docker compose up` has no hot reload. To work on the code you want the services running natively, +which needs three more things: + +- **Node 24+** and **pnpm** (`npm i -g pnpm`) — pnpm 11 does not run on Node 20 +- **Go 1.25+** with a C toolchain (`gcc`) — tree-sitter uses cgo +- **golang-migrate**, or the `migrate/migrate` Docker image that CI uses + +```bash +pnpm install +make start # infra in compose, api + web + worker natively, with watch +``` + +Stop the compose worker before running tests. It consumes the same queue the queue tests assert on; +`make test` refuses to run rather than failing obscurely. + +## Driving the API by hand + +Under the default single-user setup there is no session to obtain, so `curl` works directly: + +```bash +curl localhost:3000/healthz +curl localhost:3000/auth/me +curl -X POST localhost:3000/api/repos \ + -H 'content-type: application/json' \ + -d '{"githubUrl":"https://github.com/sindresorhus/ky"}' +``` + +That returns immediately — the parse runs on the queue, so watch `parseStatus` rather than the +request. Then walk the graph: + +```bash +curl localhost:3000/api/repos +curl localhost:3000/api/repos/1/tree +curl localhost:3000/api/files/1/functions +curl 'localhost:3000/api/functions/1/edges?depth=3&direction=out' +curl localhost:3000/api/functions/1/source +curl 'localhost:3000/api/repos/1/search?query=get&limit=10' +``` + +With real GitHub sign-in turned on instead, every `/api` route needs the `funcatlas_session` cookie. +Sign in through a browser, copy it out of devtools, and pass it in a jar: + +```bash +printf 'localhost\tFALSE\t/\tFALSE\t0\tfuncatlas_session\t\n' > jar +curl -b jar localhost:3000/auth/me +``` + +A 401 means the jar is empty or the session expired. + +## Before you open a pull request + +```bash +make test # TypeScript AND Go. `pnpm -r test` silently skips the parser +make lint +make typecheck +make go-vet # deliberately not part of `make test` +``` + +Integration tests read `TEST_DATABASE_URL`, falling back to `DATABASE_URL`, and **skip** when +neither is set. A green run that never touched Postgres proves nothing, so check that `make setup` +created `funcatlas_test`. + +**CI can fail while every command above passes.** `apps/api/src/env.ts` validates the environment at +module scope. Locally `dotenv` finds your `.env`; CI has none, so a required key missing from +`.github/workflows/node-ci.yml` takes down every API test file at import, with a ZodError that names +`env.ts` and never mentions the workflow. `apps/api/src/env.test.ts` guards it: add a key without a +default and it fails locally until `.env.example` and the workflow both carry it. + +Three more that bite, kept here because the symptoms mislead: + +- **`make test` truncates whatever `TEST_DATABASE_URL` points at**, falling back to `DATABASE_URL` + when it is unset. Without it the suite deletes the repositories you charted. +- **Redis has to be up before the API**, or sign-in returns a 500 from `ioredis` and nothing in the + error mentions Redis. +- **The parser binary is spawned by path**, so a stale one runs happily against a newer schema. + `make start` rebuilds it; `make go-build-bin` on its own if that is all you need. + +## Checking a UI change + +The canvas cannot be fully covered by tests, and some of what a headless browser reports is untrue. +Both are worth knowing before chasing a phantom. + +- **React Flow draws an edge only once both nodes are measured**, via a `ResizeObserver`. jsdom has + no layout engine, and a stub that reports a size drives `react-resizable-panels` into a re-layout + loop that fails most of the suite. So **edges cannot be asserted in a test** — *what* the edges are + is covered in `apps/web/src/lib/graph.ts`; whether they paint is a browser check. A headless pane + that never fires `ResizeObserver` shows nodes with no edges, which looks exactly like a bug. +- **A click has to land on the button, not the node.** React Flow reads a press on a node as the + start of a drag; the `nodrag` class is what lets the click through. A row that stops responding is + usually a missing `nodrag`. +- **A function with no calls is not a broken expansion.** Roughly half the functions in a real + repository are leaves. A node shows a chevron when it opens and a dot when it calls nothing. + +`sindresorhus/ky` is a good repository to verify against: small, TypeScript, and it produces all +three confidence tiers with several ghost nodes. + +## The four things review will ask about + +These are not style preferences. Each one is a bug this repository has already had. + +**1. A test ships in the same commit as the code it tests.** Not the commit after. If the change is +genuinely untestable — edge rendering is, because jsdom has no layout engine — say so in the PR and +say what you checked in a browser instead. + +**2. The second occurrence gets extracted.** Not the third. Shared helpers live in a `utils` +package, one file per concern, and every shared literal lives in a constants file +(`services/parser/internal/utils/constants.go` on the Go side, `constants.ts` per module on the +TypeScript side). No magic strings inline. `CLAUDE.md` has a table of where each kind of shared code +belongs. + +**3. Install the component, do not write it.** Reach for shadcn (`npx shadcn@latest add `) +before writing a dialog, a tree row, or a button by hand. Hand-rolled markup is more to review, more +to maintain, and worse on accessibility than the published thing. If nothing in the registry does +the job, say which one you looked for. + +**4. Comments explain *why*, in one line.** Never restate what the code plainly says. Long comments +go stale and then mislead. + +Three more that are not negotiable because a mistake is silent: + +- **Migrations are append-only.** Never edit one that has been applied; add a numbered file. Every + `*.up.sql` needs a matching `*.down.sql`. The single source is `services/parser/migrations/`, read + by both the Go writer and the TypeScript reader. +- **Grammar versions are pinned** in `services/parser/go.mod`. A bump is a deliberate change with a + test run behind it, never a `go get -u`. +- **Shared TypeScript types live only in `packages/shared`.** The Go parser cannot import them and + keeps its own IR types in `internal/ir/ir.go`. That duplication is deliberate — R9 in + [`docs/RISKS.md`](docs/RISKS.md). + +## Adding a language + +This is the most self-contained way to contribute something real. It is three files, and the third +is not optional: + +1. A `Spec` in `services/parser/internal/extract/.go`, registered in `spec.go` +2. A tree-sitter query in `services/parser/queries/.scm`, with all three captures — + `spec_test.go` fails if one is missing +3. A fixture in `services/parser/testdata/` that pins the **calls** inside the language's hardest + construct, not just the function names + +That third point is the whole reason a language ever ships broken. **One grammar per extension, +never shared.** A mismatched grammar fails *silently*: the body parses as an `ERROR` node, the +declaration still matches, and every call inside is dropped. A fixture that only asserts function +names passes anyway. `tree-sitter-javascript` is the one exception — it reads JSX in any file, so +`.js` and `.jsx` share it. + +Read [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) first, especially "Per-language +extraction limits". + +## The one rule about resolution + +`resolution_confidence` is `exact`, `name_match` or `unresolved`, drawn solid, dashed and dotted. +**Ambiguity resolves to `unresolved`, never to a guess.** A wrong edge is read as fact and costs +more than the missing one it replaced. If a change makes the resolver more confident, the PR needs +to explain why the new confidence is earned. + +The resolver *partitions* candidates by language group; it does not filter by them. Do not write a +test that decides what to allow by calling `utils.ResolutionGroup` — it agrees with itself when +broken. See R36 in [`docs/RISKS.md`](docs/RISKS.md). + +## Commits and pull requests + +- One concern per commit, imperative subject: `add a fixture for Rust macro calls`, not + `fixes + cleanup`. +- **No `Co-Authored-By` trailers and no tool-attribution footers**, in commit messages or PR bodies. +- Branch off `main`. Never push to `main`. +- The PR body should say what changed and, if the change is subtle, what would have gone wrong + without it. + +## Where to look + +| Question | File | +|---|---| +| What are we building, and what counts as done? | [`PRD.md`](PRD.md) | +| How is the repository organised, and what bites if ignored? | [`CLAUDE.md`](CLAUDE.md) | +| What is the schema? | [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | +| How do extraction and resolution work? | [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) | +| What is still undecided or risky? | [`docs/RISKS.md`](docs/RISKS.md) | +| Why does the canvas behave like that? | [`docs/CANVAS_DECISIONS.md`](docs/CANVAS_DECISIONS.md) | +| What should the UI look like? | [`docs/UI_GUIDE.md`](docs/UI_GUIDE.md) | + +## Reporting a security issue + +Do not open a public issue. Email the address on the maintainer's GitHub profile. +[`docs/SECURITY.md`](docs/SECURITY.md) records what the parser does and does not protect against — +in particular, the isolation harness is **not** the path the product runs on (R38). + +## Licence + +By contributing you agree your work is licensed under the MIT licence in [`LICENSE`](LICENSE). diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 628bd6a..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,247 +0,0 @@ -# Development - -How to set up, run, and contribute to funcatlas. The phase order is in [`PLAN.md`](PLAN.md); the -current work is in [`TASKLIST.md`](TASKLIST.md). - ---- - -## Prerequisites - -Needed now: - -- **Node 20+** and **pnpm** — `npm i -g pnpm` -- **Go 1.24+** with a C toolchain (`gcc`) — tree-sitter uses cgo -- **Docker** and **Docker Compose** — Postgres, Redis, and testcontainers -- **golang-migrate** CLI — or use the `migrate/migrate` Docker image, as CI does - -Needed from Phase 3 onward, so you can defer them: - -- A **GitHub OAuth App** (see [`docs/RISKS.md`](docs/RISKS.md) R4) — register it, note the client id - and secret, set the redirect to `http://localhost:3000/auth/callback`, generate a webhook secret. -- A **webhook tunnel** for Phase 4 — `ngrok http 3000` or `smee.io`, because GitHub cannot reach - `localhost`. - -## First-time setup - -```bash -pnpm install -cp .env.example .env # then fill in DATABASE_URL and REDIS_URL at minimum -docker compose up -d postgres redis -migrate -path services/parser/migrations -database "$DATABASE_URL" up -make go-build-bin # the binary the API spawns to register a repository -``` - -Never commit `.env`. Only `.env.example` is tracked. - -`make go-build-bin` is easy to forget and the failure is not obvious: `POST /api/repos` answers 502 -because there is no binary at `PARSER_BIN`. Rebuild it after any change under `services/parser/`. - -For a real login you also need a GitHub OAuth app (Settings → Developer settings → OAuth Apps) with -its callback URL set to exactly `GITHUB_REDIRECT_URI`, and `GITHUB_CLIENT_ID` / -`GITHUB_CLIENT_SECRET` in `.env`. `SESSION_SECRET` is any 32 random bytes: `openssl rand -hex 32`. - -The OAuth app is now required. `/auth/dev-login` used to stand in for it and was deleted in Phase 4 -(R30): it was a login with no credential, gated only by `NODE_ENV`, so one non-production deployment -on a reachable host was a session for the asking. - -## Layout - -``` -/packages/shared Drizzle schema + Zod schemas, shared by api and web -/packages/eslint-config, /packages/typescript-config -/apps/api Fastify + Drizzle + postgres.js + arctic/oslo -/apps/web Vite + React + React Flow + Tailwind + TanStack Query + Zustand -/services/parser Go — tree-sitter, sqlx/pgx, zap - /cmd/parser entry point - /internal/clone local path or shallow git clone - /internal/security path containment, size and depth caps, symlink rejection - /internal/extract tree-sitter extraction: the language-agnostic driver, the - qualified-name scope walk, and one Spec per language - /internal/ir Go-native intermediate representation - /internal/resolver call resolution and confidence tagging - /internal/db Postgres writer - /migrations the single source of schema - /queries tree-sitter .scm patterns, embedded at build - /testdata fixtures and golden output -/docs architecture, data model, security, risks -``` - -## Daily loop - -Bring up infrastructure, then run whichever services you're working on natively — you want hot -reload, and compose does not give you that. - -```bash -docker compose up -d postgres redis # infra only - -cd apps/api && pnpm dev # terminal 1 — tsx watch -cd apps/web && pnpm dev # terminal 2 — Vite HMR -cd services/parser && go run ./cmd/parser --repo ./testdata/sample # terminal 3 -``` - -`docker compose up` with no arguments runs the whole stack in prod mode, no hot reload. Use it to -check that the thing actually works in a container, not to develop in. - -Open , not the API's port. Signed out you get the sign-in card and **Sign in -with GitHub**, which needs the OAuth app above. - -### `WEB_APP_URL` - -Where the OAuth callback sends the browser once a session exists — the **web** app, `:5173`. It was -called `APP_PUBLIC_URL` and pointed at `:3000` until Phase 3b, which meant a successful GitHub login -dropped the user on a JSON endpoint. If you have an older `.env`, rename the key: - -```bash -sed -i 's|^APP_PUBLIC_URL=.*|WEB_APP_URL=http://localhost:5173|' .env -``` - -The API fails fast on start if it is missing, so you will know immediately. - -## Driving the API by hand - -Everything under `/api` needs a session, and the only way to get one is `/auth/login` in a browser. -Sign in there, then copy the `funcatlas_session` cookie out of devtools into a jar: - -```bash -printf 'localhost\tFALSE\t/\tFALSE\t0\tfuncatlas_session\t\n' > jar -curl -b jar localhost:3000/auth/me # {"userId":...,"login":"..."} -``` - -Register a repository. This returns immediately now — the parse runs on the queue, so watch -`parseStatus` rather than the request: - -```bash -curl -b jar -X POST localhost:3000/api/repos \ - -H 'content-type: application/json' \ - -d '{"githubUrl":"https://github.com/ARCoder181105/funcatlas"}' -``` - -Then walk the graph — file tree, a file's functions, one function's callees, its source, and search: - -```bash -curl -b jar localhost:3000/api/repos -curl -b jar localhost:3000/api/repos/1/tree -curl -b jar localhost:3000/api/files/1/functions -curl -b jar 'localhost:3000/api/functions/1/edges?depth=3&direction=out' -curl -b jar localhost:3000/api/functions/1/source -curl -b jar 'localhost:3000/api/repos/1/search?query=get&limit=10' -curl -b jar -X POST localhost:3000/auth/logout # 204; every route above now 401s -``` - -A 401 anywhere means the cookie jar is empty or the session expired — log in again. A 502 from -`POST /api/repos` is the parser failing; the response carries the tail of its stderr, and the API log -has the whole thing. - -## Running the whole thing - -```bash -make start -``` - -Docker (Postgres + Redis) → wait for both → migrations → build the parser binary the API spawns → -API and web. It prints the URLs. `make stop` stops the containers; Ctrl-C only stops API and web. - -Three things that used to bite and now do not, kept here because the symptoms are misleading: - -- **`make test` truncates whatever `TEST_DATABASE_URL` points at**, falling back to `DATABASE_URL` - when unset. With no `TEST_DATABASE_URL` the suite silently deletes the repositories you charted. - `.env.example` documents it; create the database once with `make migrate-test`. -- **Redis has to be up before the API**, or sign-in returns 500 from `ioredis` and nothing in the - error mentions Redis. `make start` waits for both services. -- **The parser binary is spawned by path**, so a stale one runs happily against a newer schema. - `make start` rebuilds it; `make go-build-bin` alone if you only need that. - -## Checks - -Run these before every commit. `make help` lists every target. - -```bash -make lint -make typecheck -make test -``` - -`make test` covers TypeScript **and** Go. A bare `pnpm -r test` skips the parser entirely. - -**CI can fail while all of the above pass.** `apps/api/src/env.ts` validates the environment at -module scope, and locally `dotenv` finds your `.env` while CI has none — so a required key missing -from `.github/workflows/node-ci.yml` takes down every API test file at import with a ZodError that -names `env.ts` and never mentions the workflow. `apps/api/src/env.test.ts` guards it: add a key -without a default to the schema and it fails locally until `.env.example` and the workflow both -have it. - -Useful parser commands while working: - -```bash -make go-run REPO=./services/parser/testdata/sample # writes out.json -cd services/parser && go run ./cmd/parser --repo ./testdata/golden --format summary -``` - -## Working through a phase - -Each phase has an exit test in [`PLAN.md`](PLAN.md) and a chunk list in [`TASKLIST.md`](TASKLIST.md). -Within a chunk the order that works: - -1. Schema or migration first, if the chunk needs one — everything downstream depends on its shape. -2. Core logic, with its test written in the same commit. -3. Wire it into the API or UI. -4. Run the chunk's "done when" test for real, not by inspection. -5. Tick the checkbox and commit. - -Before starting a phase, check [`docs/RISKS.md`](docs/RISKS.md) for items marked open against it. -Some are manual setup that will block you an hour in if you skip them. - -## Branches and commits - -- `main` is the default branch. Work on `phase-N/short-description`. -- One PR per phase, opened when its exit test passes. CI must be green: lint, test, build, and the - migration check. -- Squash-merge, then delete the branch. Tag phase completions: `git tag -a phase-2 -m "Storage and resolution"`. -- Commit messages are imperative and cover one concern — `add parser symlink hard-fail`, not - `updates and fixes`. - -## Verifying UI changes - -The canvas cannot be fully checked from tests, and some of what a headless browser reports is not -true. Both are worth knowing before chasing a phantom. - -- **React Flow draws an edge only once both of its nodes are measured**, via a `ResizeObserver`. - jsdom has no layout engine, and a stub that reports a size drives `react-resizable-panels` into a - re-layout loop that fails most of the suite. So **edges cannot be asserted in a test** — what - decides *what* the edges are lives in `apps/web/src/lib/graph.ts` and is covered there; whether - they paint is a browser check. A headless pane that never fires `ResizeObserver` shows nodes with - no edges, which looks exactly like a bug and is not one. -- **Clicking a node has to land on the button, not the node.** React Flow reads a press on a node as - the start of a drag; the `nodrag` class on the button is what lets the click through. A row that - stops responding is usually a missing `nodrag`. -- **A function with no calls is not a broken expansion.** Roughly half the functions in a real - repository are leaves. Nodes show a chevron when they open and a dot when they call nothing — - check the affordance before assuming the canvas is stuck. - -`sindresorhus/ky` is a good verification repository: small, TypeScript, and it has functions with -genuine unresolved calls, so all three confidence tiers and several ghosts appear. - -## Conventions worth knowing before you trip on them - -- **Migrations are append-only.** Never edit one that has been applied; add a numbered file. Every - `*.up.sql` needs a matching `*.down.sql`. -- **Shared TypeScript types live only in `packages/shared`.** The Go parser cannot import them and - keeps its own IR types in `internal/ir/ir.go` — this duplication is deliberate, see - [`docs/RISKS.md`](docs/RISKS.md) R9. -- **Grammar versions are pinned** in `go.mod`. A bump is a deliberate change with a test run, not a - `go get -u`. -- **Secrets come from the environment.** Rotate the webhook secret if it ever leaks. - -## Quick reference - -```bash -pnpm install -docker compose up -d postgres redis -docker compose up -d # full stack, prod-like -migrate -path services/parser/migrations -database "$DATABASE_URL" up -make down # roll back one migration -pnpm -r lint && pnpm -r typecheck && pnpm -r build && pnpm -r test -make go-test && make go-vet -make go-run REPO=./services/parser/testdata/sample -curl localhost:3000/healthz -``` diff --git a/PLAN.md b/PLAN.md index dedfb48..4088461 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,8 +6,7 @@ that closes it. - **What** we're building and **why** → [`PRD.md`](PRD.md) - **Which** technologies and why → [`docs/TECH_STACK.md`](docs/TECH_STACK.md) -- **How** to run the tooling day to day → [`DEVELOPMENT.md`](DEVELOPMENT.md) -- The **current** phase's task breakdown → [`TASKLIST.md`](TASKLIST.md) +- **How** to run it, and how to contribute → [`CONTRIBUTING.md`](CONTRIBUTING.md) A phase is finished when its exit test passes — not when its files exist. @@ -56,7 +55,7 @@ emits the expected functions, calls, and imports; the symlink-escape fixture is **Known carry-over into Phase 2** — the IR is correct for inspection but not yet sufficient for resolution. Call sites record no file, and method calls lose their receiver. Both are fixed as the -first chunk of Phase 2; see [`TASKLIST.md`](TASKLIST.md) C0. +first chunk of Phase 2. --- @@ -105,7 +104,7 @@ confidence tiers present in `edges` with non-zero counts. functions, 5,906 edges — exact 1,106 / name_match 150 / unresolved 4,650), walk tree to card to mind-map, read all three edge styles with unresolved ghosts at the boundary, open the source at the right line numbers, and land on a function by name with ⌘K. `pnpm -r build`, `test` and `lint` clean. -The full run, with the six defects it surfaced, is recorded in [`TASKLIST.md`](TASKLIST.md) §B8. +It surfaced six defects, listed in the paragraph below. **What running it changed.** The gate is worth more than the chunks it closes: across 3b it caught edges that silently never rendered, three confidence tiers flattened into one dash pattern, a clone @@ -155,7 +154,7 @@ correctness, not parse time. **Exit test:** pushing a commit updates the graph **without rewriting unchanged rows** — the function ids of untouched files survive; a replayed webhook is ignored; a webhook flood is -throttled; the parser still works with no network egress. — passed; see `TASKLIST.md` D8. +throttled; the parser still works with no network egress. — passed. **Known carry-over into Phase 5** — R34: `store/ui.ts` restores a file and its open branches through `zustand/persist`, and this is the first phase whose re-parse can delete the rows behind them. @@ -218,3 +217,15 @@ Each of these was considered and deliberately deferred, not forgotten. | Neo4j | Recursive CTEs over an edge table handle this scale. Revisit when traversal is measurably the bottleneck. | | Saved canvas layouts | Positions resetting on reload is a papercut, not a blocker. | | Multi-tenancy and RBAC | Single-user MVP. Each repo is already an isolated workspace, so this stays cheap to add. | + +--- + +## Still open + +The MVP is done. Nothing below blocks it; each is a known gap rather than a bug. + +| | Gap | +|---|---| +| **NFR-1** | The performance targets have never been measured. `honojs/hono` is the fixed benchmark repository (`docs/RISKS.md` R3); the timings are not. | +| **FR-7** | Several file cards on the canvas at once, cut deliberately in Phase 3b (`docs/UI_GUIDE.md` §6). | +| **FR-9** | An incremental re-parse still re-parses the whole repository; only the write is scoped (R35). | diff --git a/README.md b/README.md index 0cb36ac..03a92c4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,14 @@ extracts every function and call site with tree-sitter, resolves each call to th reaches, and draws the result as a graph you can walk: file tree → file card → function mind-map → highlighted source. + + + The funcatlas canvas showing hono's accepts function branching into one solid exact call, one dashed name match, and three dotted unresolved calls. + + +hono's `accepts` helper, opened from its file card. Three calls leave it, drawn for how well +each is known. + **The part that matters: it tells you what it does not know.** Every call gets one of three answers, and each is drawn as a different line. @@ -156,9 +164,11 @@ make help # every target Integration tests read `TEST_DATABASE_URL` and skip when it is unset, so a green run that never touched Postgres is possible — check that you created the test database above. +[`CONTRIBUTING.md`](CONTRIBUTING.md) is the place to start: what a good first change looks like, +how to add a language, and the four conventions that will fail review if you miss them. [`CLAUDE.md`](CLAUDE.md) is the load-bearing summary of how this repository is organised and which conventions bite if ignored; it is worth reading before a first change even though it is addressed -to an assistant. [`DEVELOPMENT.md`](DEVELOPMENT.md) has the daily loop. +to an assistant. ## Layout @@ -190,10 +200,9 @@ Reasoning for each pick, and the rejected alternatives, is in | Document | Owns | |---|---| | [`PRD.md`](PRD.md) | What we are building and what counts as done | -| [`PLAN.md`](PLAN.md) | The phase order and what closed each one | -| [`TASKLIST.md`](TASKLIST.md) | The live task list | +| [`PLAN.md`](PLAN.md) | The phase order, what closed each one, and what is still open | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | How to make a change here, and what review checks | | [`CLAUDE.md`](CLAUDE.md) | Conventions, and what bites if ignored | -| [`DEVELOPMENT.md`](DEVELOPMENT.md) | Setup and the daily loop | | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Components and how they connect | | [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | Postgres schema | | [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) | Extraction, resolution, per-language limits | diff --git a/TASKLIST.md b/TASKLIST.md deleted file mode 100644 index 1463b02..0000000 --- a/TASKLIST.md +++ /dev/null @@ -1,222 +0,0 @@ -# Phase 5 — Go, Rust, Python, JavaScript and Java - -The live task list. Phase 4 made the graph self-updating. It also left the parser reading exactly -one language, with `files.language` hardcoded to `"typescript"` and a resolver that had never heard -of a language at all. Phase 5 teaches it five more — extraction and same-file resolution only — and -makes the language boundary something the resolver cannot cross by accident. - -**Branch:** `phase-5/polyglot-extraction` -**Reference:** [`PLAN.md`](PLAN.md) Phase 5 · [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) · -[`docs/RISKS.md`](docs/RISKS.md) R34, R36 - -## How to work through this - -Claude implements; you review at the phase gate. The order is load-bearing: E0 makes a language a -value rather than a hardcode, E1 closes the boundary before any second language exists to leak -across it, E2–E6 add one language each, and E7 is the gate. - -Write the test in the same commit as the code it tests. Commit one chunk at a time with an -imperative message. `[ ]` todo · `[~]` in progress · `[x]` done. - -## What this phase actually teaches - -| Chunks | Concept | Where else you'll meet it | -|---|---|---| -| E0 | Pulling a hardcoded assumption out into data, without inventing a plugin framework for it | Every "we only support X" that becomes "we support X, Y, Z" | -| E1 | Partitioning an index instead of filtering its results | Multi-tenant queries, per-shard caches, anything where a missed filter leaks | -| E2–E6 | A parser that silently produces *less* rather than failing | Every codegen, every linter rule, every migration that skips rows | -| E7 | A test that measures the thing under test with the thing under test | Assertions built on the helper they are supposed to be checking | - -E7 is the one worth slowing down for. The first version of the exit test compared -`ResolutionGroup(caller)` with `ResolutionGroup(callee)` — and passed with `ResolutionGroup` -returning a constant, because both sides moved together. Breaking the function on purpose is what -found it. - ---- - -## E0 — Generalise the extractor behind a Spec - -- [x] **Why.** `internal/ts` was the only extractor and `files.language` was a constant. Everything - about the per-file loop was already language-agnostic; four things were not. -- [x] **Where.** `services/parser/internal/extract/` (was `internal/ts/`), - `internal/utils/constants.go`, `internal/utils/nodes.go`, `cmd/parser/main.go`. -- [x] **Do.** Rename the package. Add `Spec`: name, extensions, grammar, `.scm`, `ScopeSegment`, - `CalleeReceiver`, `Imports`. Re-express TypeScript as two specs. Delete `utils.Language` and - the callerless `utils.IsSourceFile`. -- [x] **Done when.** Every existing TypeScript test passes unchanged, and `.tsx` reports - `files.language = "tsx"`. -- [x] **Watch for.** `forFile` was a suffix scan; with several languages registered one extension - swallows another. It is an exact `filepath.Ext` lookup now. - -## E1 — Partition resolution by language group - -- [x] **Why.** `byName` and `byPkgName` were repo-wide and keyed on name alone. A call in `main.go` - would have matched a same-named function in `main.py`. -- [x] **Where.** `internal/resolver/resolver.go`, `internal/utils/constants.go`. -- [x] **Do.** Key both maps by resolution group, `package_path` included. Gate imports at index - time on `utils.ResolvesModules`. -- [x] **Done when.** Same-named functions in two languages never link, in either direction. -- [x] **Watch for.** Partition at build time, not filter at lookup: a filter is something a later - code path can forget. - -## E2 — JavaScript and JSX - -- [x] **Why.** `.js` and `.jsx` were skipped entirely, and the TypeScript spec already knew how to - read them. -- [x] **Where.** `queries/javascript.scm`, `internal/extract/javascript.go`, `internal/utils/paths.go`. -- [x] **Do.** Reuse the TypeScript scope, receiver and import functions. Add CommonJS `require()`. - Join the ECMAScript resolution group. -- [x] **Done when.** Calls inside a `.jsx` JSX body are all present, and `require()` binds what it - destructures. -- [x] **Watch for.** `ModuleCandidates` stripped `.js` to find the `.ts` behind it and never - restored it, so a specifier naming a real `.js` file matched nothing. Both are candidates now. - -## E3 — Go - -- [x] **Why.** Extraction only; Go resolves through package clauses and capitalisation. -- [x] **Where.** `queries/go.scm`, `internal/extract/golang.go`. -- [x] **Do.** Methods named after their receiver type. Imports bind a qualifier. -- [x] **Done when.** Calls inside a goroutine literal, a `defer`, and a two-type-argument generic - call are all present. -- [x] **Watch for.** `Map[int](xs)` — one type argument — parses as `type_conversion_expression`, - the same shape as `int(x)`. Not captured, on purpose, and the fixture pins that. - -## E4 — Rust - -- [x] **Why.** Extraction only; Rust resolves through `mod`, `use`, crate paths and traits. -- [x] **Where.** `queries/rust.scm`, `internal/extract/rust.go`, `internal/extract/spec.go`. -- [x] **Do.** Methods named after their `impl` target. `use` declarations into the IR. -- [x] **Done when.** Calls in `impl` blocks, closures, match arms and method chains are present. -- [x] **Watch for.** A macro body is a `token_tree` — nothing inside `println!` is parsed as an - expression. Pinned rather than wished away. Rust also has no quoted specifier, which is why - `Spec.Imports` returns the module as well as the symbols. - -## E5 — Python - -- [x] **Why.** Extraction only; Python resolves through `sys.path` and `__init__.py`. -- [x] **Where.** `queries/python.scm`, `internal/extract/python.go`. -- [x] **Do.** Class nesting in the qualified name. Both import statement forms. -- [x] **Done when.** Calls in decorators, f-string interpolations, comprehension filters and behind - `await` are all present. -- [x] **Watch for.** `decorated_definition` wraps the `function_definition`, so the recorded source - must be the function's and not the decorator's. - -## E6 — Java - -- [x] **Why.** Extraction only; Java resolves through the classpath and picks overloads by type. -- [x] **Where.** `queries/java.scm`, `internal/extract/java.go`. -- [x] **Do.** Every enclosing type in the name; anonymous inner classes and lambdas as anonymous - scopes. -- [x] **Done when.** A call inside `new Runnable(){...}` is present, and a call to a genuinely - overloaded method resolves `unresolved`. -- [x] **Watch for.** This is the first language where `overload_index` does real work — TypeScript's - overload *signatures* were never captured, so the post-pass had nothing to number. - -## E7 — The polyglot fixture and the exit test - -- [x] **Why.** The phase's whole claim is that no edge crosses a language boundary. -- [x] **Where.** `testdata/polyglot/`, `internal/resolver/polyglot_test.go`, - `internal/security/config.go`. -- [x] **Do.** One directory, a file per language, each defining and calling `helper`. `main.go` - calls `python_only`; `main.py` calls `go_only`. Skip-path defaults for the new languages. -- [x] **Done when.** Every language yields functions **and** calls; no edge crosses a boundary; - cross-file is never `exact` outside the ECMAScript family. -- [x] **Watch for.** Two traps, both hit. `helper` alone proves nothing — it exists in seven files, - so ambiguity answers `unresolved` whether the partition works or not. And an assertion built - on `ResolutionGroup` agrees with itself: verified by breaking that function and watching the - test fail. - -## E8 — Language per node, and R34 - -- [x] **Why.** A reader following a call out of their language should see that they have, since that - is also where the resolver stops being able to say anything exact. And R34 has been open since - Phase 4 made it reachable without anyone touching the browser. -- [x] **Where.** `apps/web/src/components/MindMap.tsx`, `FunctionNode.tsx`, `Sidebar.tsx`, - `lib/graph.ts`, `lib/graph-constants.ts`, `lib/highlight.ts`, `store/ui.ts`. -- [x] **Do.** Badge a callee only when its language differs from the file being read. Drop branch - roots the open file no longer has. -- [x] **Done when.** `make test`, `make lint`, `make typecheck`, `make go-vet` clean. -- [x] **Watch for.** A card that grows a badge it was not measured for truncates its own name — - the same bug the "start" badge caused. `functionCardWidth` allows for it. - ---- - -## The exit gate - -`make go-run REPO=./services/parser/testdata/polyglot` — 7 files, 7 languages, every one yielding -functions and calls, no edge crossing a boundary. - -`make test` (127 api / 146 web / Go, Postgres up), `make lint`, `make typecheck`, `make go-vet` — -all clean. - -Still to do by hand at the gate: a real polyglot public repository charted end to end in real -Chrome, checking the tree language labels, the per-node badge, and Shiki highlighting for each -language. - ---- - -## The landing page (`landing-page`, not a phase) - -A second full surface that no phase's exit test touches — `docs/UI_GUIDE.md` §3.1 specified it -during Phase 3b and named it the next branch after that gate. Phases 4 and 5 went first. - -- [x] **Route.** `lib/router.tsx` over `pushState`; `/` is the landing page, `/app` is the canvas, - `APP_ROUTE` is shared so the OAuth callback stops returning people to the marketing page. -- [x] **Hero.** Plain SVG drawing itself under a mask sweep, never an animated `pathLength`, with a - ghost node at the map's edge. -- [x] **Sections.** Title block, hero, resolution, pipeline, index, coverage, closing, footer. Each - section rule is the confidence tier that is true of that section. -- [x] **Installed, not written.** animate-ui `effects/fade`, `texts/sliding-number` and - `components-base-files`; `lenis` for smooth scrolling, mounted from `Landing` alone. -- [x] **Palette.** Ember/Vellum replaced product-wide by Ultramarine/Letterpress. `UI_GUIDE.md` - §1.1 rewritten, §7.1 records the old one as shipped and replaced. -- [x] **Done when.** `make test`, `make lint`, `make typecheck`, `make go-vet` clean. - -**Verified by hand in real Chrome:** both themes, the full page, the finished graph, `/app` still -resolving its session. **Not verified, and stated as such in the PR:** the draw animation and the -mobile breakpoints — `requestAnimationFrame` ran at roughly one frame per half-second in the -available browser window and `resize_window` was ignored, so neither could be observed. - -## Next - -- [ ] **NFR-4 — `docker compose up`.** Four verified faults: `apps/web` has no Dockerfile; there is - no `worker` service, so a webhook enqueues a job nothing consumes; the API image runs `node - dist/index.js` against `packages/shared` exports that point at `.ts`; and the `parser` service - sets both `network_mode: none` and `depends_on` Postgres health. Do **not** resolve the last - by giving the parser a network — that undoes a Phase 1 guarantee. - ---- - -## NFR-4 — `docker compose up` (`clone-and-run`, not a phase) - -The promise in `PRD.md` since Phase 0, never once tested. Eight faults, not the six on record: -the `api` build context and the missing `.dockerignore` turned up on re-reading, and three more -only appeared when the images were actually built and run. - -- [x] **`.dockerignore`.** First and alone: `COPY . .` with no ignore file put `.env` — a real - client secret — into every image layer. -- [x] **API and worker image.** Root build context, `packages/shared` copied, runs under `tsx`. - Go stage builds the parser with cgo; `git` installed, because `clone.go` shells out to it. -- [x] **Worker service.** Nothing consumed the queue: a webhook answered 202 and the graph never - moved. -- [x] **`migrate` service**, gating api and worker on `service_completed_successfully`. -- [x] **Web image** — Vite build behind nginx with a `try_files` SPA fallback. -- [x] **Parser service** — vestigial `depends_on` dropped, isolation kept, `tools` profile so - `docker compose up` does not start it. -- [x] **`make setup`** and **`FUNCATLAS_SINGLE_USER`**, so a clone needs no GitHub OAuth app. -- [x] **Every port on `127.0.0.1`**, Postgres and Redis included. - -**Exit test passed** from a clean clone in /tmp with `make start` never run: `make setup`, -`docker compose up`, register `sindresorhus/p-limit` through the API with no cookie, worker parses -it to `ready` — 6 files, 36 functions, commit SHA recorded. - -Found by running it rather than reading it: pnpm 11 will not start on Node 20; `pnpm run` verifies -the workspace before executing, so a build stage needs every manifest; `.env`'s `localhost` means -the container itself; `` values broke every `set -a && . ./.env`; and `.optional()` -rejects a present-but-empty variable, which is the state every fresh `.env` is in. - -## Next - -- [ ] **NFR-1 — the performance targets have never been measured.** `honojs/hono` is the fixed - benchmark (R3); the timings are not. -- [ ] **FR-7 — several file cards at once.** Cut deliberately in 3b (`UI_GUIDE.md` §6). diff --git a/apps/web/public/canvas-dark.png b/apps/web/public/canvas-dark.png new file mode 100644 index 0000000..9c3cf04 Binary files /dev/null and b/apps/web/public/canvas-dark.png differ diff --git a/apps/web/public/canvas-light.png b/apps/web/public/canvas-light.png new file mode 100644 index 0000000..e7cc432 Binary files /dev/null and b/apps/web/public/canvas-light.png differ diff --git a/apps/web/src/components/MindMap.tsx b/apps/web/src/components/MindMap.tsx index 873baf3..ae36472 100644 --- a/apps/web/src/components/MindMap.tsx +++ b/apps/web/src/components/MindMap.tsx @@ -391,6 +391,9 @@ export function MindMap() { maxZoom={1.75} nodesDraggable nodesConnectable={false} + // Default is bottom-right, on top of the minimap. The other two corners + // hold the controls and the minimap, so centre is the free one. + attributionPosition="bottom-center" > {graph.truncated > 0 ? ( diff --git a/apps/web/src/components/landing/CanvasShot.tsx b/apps/web/src/components/landing/CanvasShot.tsx new file mode 100644 index 0000000..d5e2cfd --- /dev/null +++ b/apps/web/src/components/landing/CanvasShot.tsx @@ -0,0 +1,50 @@ +import { Bezel } from "./Bezel"; +import { Section } from "./Section"; + +/** + * The product itself, photographed rather than described. + * + * Two files, not one with a filter: the canvas is not a screenshot that can be + * recoloured, and a dark image inverted for a light page would misstate the + * three tier colours -- which are the one thing this picture is here to show. + * Class-based `dark:` rather than `prefers-color-scheme`, because the theme + * toggle sets the class and the media query would ignore it. + * + * `hidden` on the inactive one rather than an opacity swap: both are decoded + * either way, but only one is laid out, so the section has a single height. + */ +const SHOT_ALT = + "The funcatlas canvas: hono's accepts function branching into a solid exact " + + "call, a dashed name match, and dotted unresolved calls."; + +export function CanvasShot() { + return ( +
+ + {/* Intrinsic size given so the row does not reflow when the file + lands -- the images are 2720x1440. */} + {SHOT_ALT} + {SHOT_ALT} + +
+ ); +} diff --git a/apps/web/src/components/landing/Landing.test.tsx b/apps/web/src/components/landing/Landing.test.tsx index 6aad6f9..2a40110 100644 --- a/apps/web/src/components/landing/Landing.test.tsx +++ b/apps/web/src/components/landing/Landing.test.tsx @@ -97,6 +97,19 @@ describe("the landing page", () => { expect(within(index as HTMLElement).getByText("23")).toBeInTheDocument(); }); + it("ships a canvas shot for each theme", () => { + renderLanding(); + + // Two files rather than one filtered: the tier colours are the subject of + // the picture, and a recoloured dark shot would misstate them. A single + // image here means one theme is showing the other theme's palette. + const shots = screen.getAllByRole("img", { name: /funcatlas canvas/i }); + expect(shots.map((s) => s.getAttribute("src"))).toEqual([ + "/canvas-dark.png", + "/canvas-light.png", + ]); + }); + it("links to the source even when GitHub will not answer", async () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("rate limited")); diff --git a/apps/web/src/components/landing/Landing.tsx b/apps/web/src/components/landing/Landing.tsx index dc226c1..7eddc6c 100644 --- a/apps/web/src/components/landing/Landing.tsx +++ b/apps/web/src/components/landing/Landing.tsx @@ -1,5 +1,6 @@ import { useMotionEnabled } from "../../lib/motion"; import { useSmoothScroll } from "../../lib/useSmoothScroll"; +import { CanvasShot } from "./CanvasShot"; import { ClosingCta } from "./ClosingCta"; import { Hero } from "./Hero"; import { HowItWorks } from "./HowItWorks"; @@ -30,6 +31,7 @@ export function Landing() {
+ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1b68a04..e80cb9c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -211,3 +211,18 @@ body, } } } + +/* React Flow's attribution ships a hardcoded half-white background, which on + the dark canvas is a bright chip in the corner. It stays -- removing it is a + Pro-licence feature -- so it is themed instead. + * + * `!important`, and outside `@layer base`, because `reactflow/dist/base.css` is + * unlayered and imported from the lazily-loaded canvas chunk -- so it arrives + * after this file and wins on both layering and source order. */ +.react-flow__attribution { + background: transparent !important; +} + +.react-flow__attribution a { + @apply text-muted-foreground/50; +} diff --git a/docs/CANVAS_DECISIONS.md b/docs/CANVAS_DECISIONS.md index cff1c1c..33ad272 100644 --- a/docs/CANVAS_DECISIONS.md +++ b/docs/CANVAS_DECISIONS.md @@ -1,6 +1,6 @@ # Canvas decisions -Decisions taken **during** Phase 3b, after the plan in `TASKLIST.md` was written. They are here +Decisions taken **during** Phase 3b, after the phase plan was written. They are here because they changed the shape of the canvas rather than the look of it, and a commit message is a bad place to look them up six weeks later. @@ -440,10 +440,10 @@ because nothing is broken and a migration mid-phase buys tidiness at the cost of canvas behaviour by hand, none of which has an automated edge test to catch a regression. Worth doing when there is a second reason. -**A router.** The Back button leaves the app entirely, because there are no routes — `TASKLIST.md` -cut them from 3b. The reader noticed and it is a fair complaint: URL-addressable repo, file and -function would make Back walk the selection and make a function linkable. It belongs with the -landing-page PR, which introduces a second route anyway. +**A router that addresses the selection.** There is one now — `lib/router.tsx`, added with the +landing page — but it only separates `/` from `/app`. Inside the canvas the Back button still leaves +the app entirely, because repo, file and function are in the store rather than the URL. Putting them +there would make Back walk the selection and make a single function linkable. **Collapsing individual nodes inside a branch** works (§1b). What does not exist is any way to close *everything* at once, or to remove a branch entirely rather than collapse it. Nobody has asked yet. diff --git a/docs/RISKS.md b/docs/RISKS.md index 77f5b4f..02a47d1 100644 --- a/docs/RISKS.md +++ b/docs/RISKS.md @@ -26,7 +26,7 @@ it was. | **R29** | Every repository defaulting to `master` was recorded as being on `main`. | The parser's `--branch` defaulted to `"main"` while a shallow clone checks out whatever the remote's default actually is. Branch and commit are now read off the checkout with `git rev-parse`; the caller hands over a URL and never clones, so it could not have known either. | | **R9** | The Go parser cannot import `packages/shared`, so it has no shared types. | The parser keeps **Go-native IR types** in `internal/ir/ir.go`, mirroring the schema by hand. The duplication is accepted; generating Go structs from the migration was judged more machinery than the drift is worth at four tables. | | **R10** | Single migration source, so the parser and API can't drift. | **`services/parser/migrations/`**, plain SQL via golang-migrate. Both the Go writer and the TypeScript reader use it. CI runs the migrations against a live Postgres on every PR. | -| **R11** | Dev and prod modes differ — prod is `docker compose up`, dev needs HMR and watch. | Both documented in `DEVELOPMENT.md`. Dev runs infra in compose and the three services natively; prod runs everything in compose. **True as of the clone-and-run branch** — it had been the intent since Phase 0 and was never tested, which is how eight separate faults accumulated in a file nobody ran (NFR-4). | +| **R11** | Dev and prod modes differ — prod is `docker compose up`, dev needs HMR and watch. | Both documented in `README.md` and `CONTRIBUTING.md`. Dev runs infra in compose and the three services natively; prod runs everything in compose. **True as of the clone-and-run branch** — it had been the intent since Phase 0 and was never tested, which is how eight separate faults accumulated in a file nobody ran (NFR-4). | | **R16** | Grammar versions drift and break parsing silently. | Pinned in `go.mod`: `tree-sitter/go-tree-sitter` **v0.25.0**, `tree-sitter/tree-sitter-typescript` **v0.23.2**. The golden extraction tests fail loudly if a bump changes node kinds. | | **R17** | CI needs Docker for the integration tests and the migration check. | `ubuntu-latest` runners include Docker. `.github/workflows/go-ci.yml` runs a Postgres service container and a `migrate/migrate` container; the Go tests connect to it via `DATABASE_URL`. See R22. | | **R27** | Registration runs the parser inline, so a large repository holds an HTTP request open for the length of the parse. | **Closed in Phase 4.** `POST /api/repos` writes the row, enqueues, and answers 202. The row has to exist before the job does, or a worker picks it up with nothing to mark as parsing -- which reversed who owns the insert, since the parser used to. `repos.parse_status` is how the client follows a parse it is no longer waiting on. |