diff --git a/CLAUDE.md b/CLAUDE.md index c1f7903..26a8249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,9 +110,16 @@ No GORM. No Prisma. No full ORM on the Go side. ## Locked product decisions GitHub OAuth from day one. Webhook-driven incremental updates in the MVP. Function-name search in -the MVP. Excalidraw annotation and LSP resolution are out. Parser isolation was built in Phase 1, -not deferred: `--network none`, read-only rootfs, non-root, no capabilities, symlinks hard-fail, -files over 1 MB skipped. +the MVP. Excalidraw annotation and LSP resolution are out. + +**Parser isolation: the harness was built in Phase 1, the product does not use it.** `--network +none`, read-only rootfs, non-root and dropped capabilities apply under `make parser-isolated` and +nowhere else — `repos/register.ts` runs the binary with `execFile` from the queue worker, so on +`make start` and in any composed stack it is a plain child process. What *is* enforced on every +path, because it lives in the parser rather than around it: symlinks hard-fail, files over 1 MB +skipped, file-count and depth caps, a `--depth 1` clone with credential prompts disabled, and no +repo scripts ever invoked. See R38 and `docs/SECURITY.md` before quoting the sandbox as a +guarantee. ## Conventions that bite if ignored diff --git a/PLAN.md b/PLAN.md index 15e9748..dedfb48 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,6 +33,10 @@ Given a local repo path, emit a correct intermediate representation for TypeScri no UI. Isolation is built here rather than deferred, because retrofitting a sandbox around a parser that already assumes host filesystem access is far more expensive than building it in. +> **Corrected later.** What Phase 1 built was the isolation *harness* — the `parser` compose service +> and `make parser-isolated`. The product spawns the binary with `execFile` and never runs it inside +> that container, so the retrofit this paragraph set out to avoid is still owed. R38. + Delivered: - `internal/clone` — local path, or `git clone --depth 1`; never runs the repo's install or build scripts. diff --git a/README.md b/README.md index 598ea96..593272b 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,216 @@ # funcatlas -An interactive visual map of a codebase. Point it at a repository and it parses every function, -links call sites into a graph, and renders it as an explorable canvas — click a file to get a card, -click the card to get a mind-map of its functions, click a function to read its code. - -Built for one problem: large codebases don't fit in your head, and grep or go-to-definition only -ever shows you one file at a time — never the shape of the whole thing. - -**Status:** in development, and usable. Log in, point it at a public TypeScript repository, and -explore the call graph on the canvas — file tree to card to function mind-map to highlighted source, -with ⌘K to find a function by name. Registering returns immediately and the parse runs on a queue; -point a GitHub webhook at it and the graph follows your pushes, rewriting only the rows a commit -actually changed. - -## Progress - -| Phase | Scope | State | -|---|---|---| -| 0 | Monorepo bootstrap, migrations, CI | done | -| 1 | Go + tree-sitter parser, sandbox hardening | done | -| 2 | Postgres persistence, call resolution | done | -| 3a | GitHub OAuth, Redis sessions, the graph API | done | -| 3b | React Flow canvas, search UI | done | -| 4 | Webhooks, job queue, security hardening | done | -| 5 | Go, Rust and Python — extraction only | not started | - -Phase-by-phase detail is in [`PLAN.md`](PLAN.md); the current chunk list is in -[`TASKLIST.md`](TASKLIST.md). +An interactive visual map of a codebase. Point it at a public GitHub repository and it clones it, +extracts every function and call site with tree-sitter, resolves each call to the function it +reaches, and draws the result as a graph you can walk: file tree → file card → function mind-map → +highlighted source. -## How it works +**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. -1. **Clone** the target repo into an isolated, network-less, read-only container. -2. **Parse** every `.ts`/`.tsx` file with tree-sitter; extract function definitions, call sites, - and imports into a Go-native intermediate representation. -3. **Resolve** each call to a definition — same file, then imported symbol, then package fallback — - and tag the resulting edge `exact`, `name_match`, or `unresolved`. -4. **Store** functions and edges in Postgres, keyed so an incremental re-parse never orphans an edge. -5. **Serve** the graph behind a GitHub login — every endpoint is session-gated, and sessions are - opaque ids in Redis rather than anything the browser can read. -6. **Explore** the graph on a React Flow canvas, where edge style reflects resolution confidence. +| | Tier | Drawn | Means | +|---|---|---|---| +| ─── | `exact` | solid | The import was followed to a declaration, and only one function could be the target. | +| ─ ─ ─ | `name_match` | dashed | A function with that name is in scope. Another one elsewhere may be the one actually called. | +| · · · | `unresolved` | dotted | The call is real and its target is ambiguous: a barrel re-export, a default import, a path alias. | -The confidence tag is the point of the design: a guess is never drawn as a fact. +Ambiguity resolves to `unresolved`, never to a guess, and an unresolved call is never coloured as +an error. A tool that guesses is worse than one that stops, because a wrong edge is read as fact +and costs more than the missing one it replaced. Unresolved callees are drawn as ghost nodes at the +edge of the map, labelled with the name the parser saw — the map showing its own boundary. -## Stack +Built for one problem: large codebases do not fit in your head, and grep or go-to-definition only +ever shows you one file at a time, never the shape of the whole thing. -| Layer | Choice | +## Status + +Phases 0 through 5 are done and merged. It works and it is usable: sign in, chart a public +repository, and explore it. Registering returns immediately and the parse runs on a queue; point a +GitHub webhook at it and the graph follows your pushes, rewriting only the rows a commit changed. + +**There is no hosted instance.** You run it yourself, so the database and the graphs are yours and +stay on your machine. + +## Languages + +Eight, at two different depths. Support is not uniform, so it is not presented as though it were. + +| Depth | Languages | |---|---| -| Parser | Go + tree-sitter, pgx, zap | -| Database | Postgres (edge tables + recursive CTEs) | -| API | Fastify + Drizzle + postgres.js + Zod | -| Frontend | Vite + React + React Flow + Tailwind + shadcn/ui | -| Queue | Redis + BullMQ | -| Auth | GitHub OAuth (arctic), opaque sessions in Redis | -| Monorepo | pnpm workspaces + Turborepo | +| **Resolved across files** — imports are followed, so a call reaches a definition in another file | TypeScript, TSX, JavaScript, JSX | +| **Extracted, resolved within a file** — every function and call site is charted, cross-file resolution is not built | Go, Rust, Python, Java | -Reasoning for each pick is in [`docs/TECH_STACK.md`](docs/TECH_STACK.md). +Per-language limits are pinned by assertions rather than described, because a parser that quietly +produces *less* reads as one that worked. See [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md). -## Layout +## Running it + +### Prerequisites + +- **Node 20+** and **pnpm** (`npm i -g pnpm`) +- **Go 1.24+** with a C toolchain (`gcc`) — tree-sitter uses cgo +- **Docker** and **Docker Compose** — for Postgres and Redis +- **golang-migrate** CLI — or the `migrate/migrate` Docker image, as CI does +- A **GitHub OAuth App** — the app has no other way to know who you are +> `docker compose up` does **not** work yet and is the next piece of work: `apps/web` has no +> Dockerfile, there is no worker service, and the API image cannot start. Until then the four +> prerequisites above are all required. See NFR-4 in [`PRD.md`](PRD.md). + +### Register a GitHub OAuth App + + → **New OAuth App**. The exact values: + +| Field | Value | +|---|---| +| Application name | anything — `funcatlas (local)` | +| Homepage URL | `http://localhost:5173` | +| Authorization callback URL | `http://localhost:3000/auth/callback` | + +Generate a client secret and keep both values for the next step. The scope requested is +`read:user` and nothing more — funcatlas never writes to your account, and the token is read once +for your username and then never used again. It clones over public HTTPS, which is why only public +repositories work: the only scope that reads private ones is `repo`, and that also grants **write** +access to every private repository you can reach. + +### Set up and start + +```bash +git clone https://github.com/ARCoder181105/funcatlas.git +cd funcatlas +pnpm install +cp .env.example .env ``` -/packages/shared Drizzle schema + Zod schemas, shared by api and web -/apps/api Fastify — auth, repo registration, graph endpoints -/apps/web Vite + React — the canvas -/services/parser Go — clone, parse, resolve, write to Postgres -/docs architecture, data model, security, risks + +Then edit `.env` and fill in four values: + +```bash +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32) # unused locally, but required +SESSION_SECRET=$(openssl rand -hex 32) ``` -## Running it +Create the test database once, or `make test` truncates the one you develop against: ```bash -pnpm install -cp .env.example .env # fill in DATABASE_URL and REDIS_URL docker compose up -d postgres redis -migrate -path services/parser/migrations -database "$DATABASE_URL" up -make go-build-bin # the binary the API spawns +docker compose exec postgres psql -U funcatlas -d postgres \ + -c "CREATE DATABASE funcatlas_test OWNER funcatlas;" ``` -Parse a fixture without touching the API: +Then bring the whole thing up — infra, migrations, the parser binary, the API, the web app and the +parse worker: ```bash -make go-run REPO=./services/parser/testdata/sample # emits out.json +make start # then open http://localhost:5173 ``` -Or bring the whole thing up and use it — infra, migrations, parser binary, API and web in one: +Sign in with GitHub, paste a public repository URL, and explore it. `⌘K` finds any function by +name. + +### Without the app + +Parse a repository straight to stdout, no database and no API: ```bash -make start # then open http://localhost:5173 +make go-run REPO=./services/parser/testdata/polyglot +cd services/parser && go run ./cmd/parser --repo ./testdata/resolve --format summary ``` -Sign in with **Continue as a local dev user**, chart a public repository, and explore it. +## How it works + +1. **Clone** the repository, depth one, over public HTTPS. Nothing in it is ever executed — no + install, build or test scripts — because parsing only reads text. Symlinks fail the run rather + than being followed, files over 1 MB are skipped, and file count and depth are capped. +2. **Extract** every function declaration and call site with tree-sitter, into a Go-native + intermediate representation. One pinned grammar per extension, never shared: a mismatched + grammar fails *silently* and drops every call in the file. +3. **Resolve** each call against a symbol table partitioned by language, so no edge can cross a + language boundary, and tag it `exact`, `name_match` or `unresolved`. +4. **Store** functions and edges in Postgres, keyed so an incremental re-parse never orphans an + edge. +5. **Serve** the graph behind a GitHub login. Every `/api` route is session-gated, and sessions are + opaque ids in Redis rather than anything the browser can read. +6. **Explore** it on a React Flow canvas, where the edge style is the resolution confidence. + +## What it does not do -Or drive it over HTTP instead. `/auth/dev-login` exists outside production so this works -without a GitHub OAuth app: +Stated here rather than discovered later: + +- **Public repositories only.** See the OAuth section above for why. +- **No hosted instance.** You run it. +- **An incremental re-parse still re-parses everything.** The *write* is scoped to changed files; + the clone, extract and resolve are not. Resolution is whole-repo, and a partial symbol table + would emit a confident edge where the whole repository would correctly say ambiguous + ([`docs/RISKS.md`](docs/RISKS.md) R35). +- **The parser sandbox is a harness, not the running path.** `--network none`, a read-only rootfs + and dropped capabilities apply under `make parser-isolated`; the queue worker spawns the binary + as a plain subprocess. The input hardening in step 1 above is enforced everywhere. R38 and + [`docs/SECURITY.md`](docs/SECURITY.md). +- **One file card at a time**, with as many function branches off it as you open. +- **Barrel re-export chains, default imports and `tsconfig` path aliases** all resolve to + `unresolved`. Deliberately. + +## Working on it ```bash -cd apps/api && pnpm dev - -curl -c jar -X POST localhost:3000/auth/dev-login -curl -b jar -X POST localhost:3000/api/repos \ - -H 'content-type: application/json' \ - -d '{"githubUrl":"https://github.com/ARCoder181105/funcatlas"}' -curl -b jar localhost:3000/api/repos/1/tree -curl -b jar 'localhost:3000/api/repos/1/search?query=parse' +make start # everything, then open :5173 +make test # TypeScript AND Go — `pnpm -r test` silently skips the parser +make lint && make typecheck +make go-vet # not part of `make test` +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. + +[`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. + +## Layout + +``` +/apps/api Fastify — auth, repo registration, graph endpoints, the parse worker +/apps/web Vite + React — the landing page and the canvas +/packages/shared Drizzle schema + Zod schemas, shared by api and web +/services/parser Go — clone, extract, resolve, write to Postgres +/docs architecture, data model, parsing, security, risks, UI ``` -Every `/api` route answers 401 without that cookie. Full setup, prerequisites, and the daily loop -are in [`DEVELOPMENT.md`](DEVELOPMENT.md). +## Stack + +| Layer | Choice | +|---|---| +| Parser | Go + tree-sitter, pgx with explicit SQL, zap | +| Database | Postgres — edge tables and recursive CTEs | +| API | Fastify + Drizzle + postgres.js + Zod | +| Frontend | Vite + React 19 + React Flow + Tailwind v4 + shadcn/ui on Base UI | +| Queue | Redis + BullMQ, consumed by a Node worker that spawns the Go binary | +| Auth | GitHub OAuth (arctic/oslo), opaque sessions in Redis | +| Monorepo | pnpm workspaces + Turborepo | + +Reasoning for each pick, and the rejected alternatives, is in +[`docs/TECH_STACK.md`](docs/TECH_STACK.md). ## Documentation | Document | Owns | |---|---| -| [`PRD.md`](PRD.md) | What we're building and why — the product contract | -| [`PLAN.md`](PLAN.md) | The 4-phase execution plan | -| [`TASKLIST.md`](TASKLIST.md) | The live task list for the current phase | -| [`DEVELOPMENT.md`](DEVELOPMENT.md) | Setup, dev loop, conventions | +| [`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 | +| [`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/TECH_STACK.md`](docs/TECH_STACK.md) | Stack decisions and rejected alternatives | | [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | Postgres schema | -| [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) | Extraction and call resolution, with limits | -| [`docs/SECURITY.md`](docs/SECURITY.md) | Parsing untrusted repos safely | +| [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) | Extraction, resolution, per-language limits | +| [`docs/SECURITY.md`](docs/SECURITY.md) | Parsing untrusted repositories, and where the isolation actually applies | | [`docs/RISKS.md`](docs/RISKS.md) | Open decisions and tracked risks | -| [`docs/UI_GUIDE.md`](docs/UI_GUIDE.md) | Visual direction for the canvas | - -## Scope - -**In:** TypeScript for the full pipeline. Public repositories via GitHub OAuth — the scope is -`read:user`, since GitHub offers no read-only repository scope and `repo` would grant write access -to every private repository you can reach. Name/scope call resolution with confidence tags. -Webhook-driven incremental updates. Function-name search. - -**Planned:** Go, Rust and Python in Phase 5, extraction only — per-language call resolution stays -cut. - -**Out (post-MVP):** LSP-based resolution, freehand annotation layer, Neo4j, saved canvas layouts, -multi-tenancy, private repositories. +| [`docs/UI_GUIDE.md`](docs/UI_GUIDE.md) | Visual direction, and what this must not look like | +| [`docs/CANVAS_DECISIONS.md`](docs/CANVAS_DECISIONS.md) | Why the canvas behaves the way it does | ## License -MIT — see [`LICENSE`](LICENSE). +MIT. See [`LICENSE`](LICENSE). diff --git a/apps/web/index.html b/apps/web/index.html index 92a6f94..03c58d5 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,13 @@ funcatlas + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 4178531..2010b45 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +// `configure` comes from here rather than @testing-library/dom, which is only +// a transitive dependency and does not resolve from this package. +import { configure, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { APP_ROUTE } from "@funcatlas/shared"; import App from "./App"; import { ApiError, api } from "./lib/api"; +// `/app` is behind `React.lazy`, and resolving that chunk really does pull +// React Flow and the rest of the canvas. Alone it lands well inside the 1s +// default; in a full run, with every other file competing for the same +// threads, it does not, and this file failed intermittently on the route test. +// +// A longer ceiling rather than a mock: the wait is a real property of the +// route, and the assertions still fail if it never renders at all. +configure({ asyncUtilTimeout: 5000 }); + // The real module is kept for ApiError -- session.ts branches on // `instanceof`, so a stubbed class would make the 401 path silently dead. vi.mock("./lib/api", async (importOriginal) => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 258ed6f..a98cb68 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,205 +1,41 @@ -import { useRef, useState } from "react"; -import { APP_ROUTE, type SessionUser } from "@funcatlas/shared"; -import type { PanelImperativeHandle } from "react-resizable-panels"; -import { AppHeader } from "./components/AppHeader"; -import { Canvas } from "./components/Canvas"; -import { CommandPalette } from "./components/CommandPalette"; +import { Suspense, lazy } from "react"; +import { APP_ROUTE } from "@funcatlas/shared"; import { Landing } from "./components/landing/Landing"; -import { LoginScreen } from "./components/LoginScreen"; -import { Sidebar } from "./components/Sidebar"; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable"; -import { SidebarProvider } from "./components/ui/sidebar"; -import { Skeleton } from "./components/ui/skeleton"; -import { TooltipProvider } from "./components/ui/tooltip"; -import { Link, usePath } from "./lib/router"; -import { useSession } from "./lib/session"; -import { - CANVAS_DEFAULT, - GITHUB_REPO_URL, - SIDEBAR_DEFAULT, - SIDEBAR_MAX, - SIDEBAR_MIN, -} from "./lib/constants"; +import { ResolvingSession } from "./components/ResolvingSession"; +import { usePath } from "./lib/router"; /** - * Percentages of the group. Strings with an explicit unit, because this library - * reads a bare number as pixels and the difference is invisible until it is - * wrong. + * The canvas and everything under it -- React Flow, the resizable panels, the + * command palette, Shiki -- fetched only when someone actually goes to `/app`. * - * Both panels declare a size: given only one, the library ignores it and falls - * back to an even split. + * Statically imported, all of that landed in the main bundle and every visitor + * to the landing page downloaded a canvas they had not asked for. On a showcase + * build it is worse than waste: there is no API behind `/app`, so the chunk can + * never be used at all. + * + * `Landing` stays static. It is what `/` renders and what most visitors will + * only ever see, so making it wait on a second round trip would be paying the + * cost in the one place it is least worth paying. */ +const AppRoute = lazy(() => import("./AppRoute")); /** * Two routes. Anything that is not the canvas is the landing page -- there is * no 404, because there is nothing else to be. * * The split matters beyond tidiness: `useSession` lives inside `AppRoute`, so - * the landing page issues no request at all and renders with the API down. - * A marketing page that needs a backend to say what the product is is not a + * the landing page issues no request at all and renders with the API down. A + * marketing page that needs a backend to say what the product is is not a * marketing page. */ export default function App() { - return usePath() === APP_ROUTE ? : ; -} - -/** - * Three states, and the server decides which: resolving, signed out, signed in. - * - * There is no local "is logged in" flag to drift out of sync -- the cookie is - * HttpOnly, so `useSession` asking the API is the only honest answer. - */ -function AppRoute() { - const session = useSession(); - - if (session.isPending) { - return ; - } - - if (session.isError) { - return ; - } - - if (session.data === null) { - return ; + if (usePath() !== APP_ROUTE) { + return ; } return ( - - {/* The provider exists because the sidebar's menu components read it; - width and collapse are owned by the resizable panel below, which is - the thing the reader actually drags. - - Its wrapper ships as `min-h-svh`, which grows past the viewport - instead of clipping -- the document then scrolls, and the tree and - the canvas move together as one page. Pinned to the viewport here so - each pane owns its own overflow. */} - - - - - ); -} - -function Explorer({ user }: { user: SessionUser }) { - const panel = useRef(null); - const [collapsed, setCollapsed] = useState(false); - - /** - * One rule, everywhere: collapsed is whatever the panel says it is. - * - * Dragging the separator past `minSize` collapses the panel too, so the - * button cannot own this state -- it would keep offering to hide a tree that - * is already hidden. Called from both the group's layout callback and the - * toggle, because neither fires for the other's path. - */ - const sync = () => setCollapsed(panel.current?.isCollapsed() ?? false); - - const toggle = () => { - const handle = panel.current; - if (handle === null) return; - - if (handle.isCollapsed()) { - handle.expand(); - } else { - handle.collapse(); - } - sync(); - }; - - return ( -
- - - {/* Mounted with the explorer rather than inside the sidebar: ⌘K is a - window-level shortcut, and a palette that only exists while the tree - is open would stop answering when the tree is collapsed. */} - - - - - - - - - - -
- -
-
-
-
- ); -} - -/** The shape of the app, not a spinner (UI_GUIDE §3.3). */ -function ResolvingSession() { - return ( -
-
- - -
-
-
- - - -
-
-
-
- ); -} - -/** - * The API could not be reached at all -- which is different from being signed - * out, and showing a sign-in button here would send the user to a dead link. - * - * Written for two readers, because both really arrive here. One is running the - * stack and has forgotten a process. The other followed a link to a deploy - * where only the web app is up, and "run `pnpm dev`" means nothing to them -- - * they need to know the page is not broken and where the source is. - */ -function SessionUnavailable() { - return ( -
-
-

The canvas cannot reach its API

- -

- Charting a repository needs the API, the parse worker, Postgres and Redis. None of them - answered, so there is nothing to draw. -

- -

- Running it locally? Start them with make start{" "} - and reload. Otherwise the{" "} - - source - {" "} - has the setup, and the{" "} - - overview - {" "} - explains what it does. -

-
-
+ }> + + ); } diff --git a/apps/web/src/AppRoute.tsx b/apps/web/src/AppRoute.tsx new file mode 100644 index 0000000..744add6 --- /dev/null +++ b/apps/web/src/AppRoute.tsx @@ -0,0 +1,176 @@ +import { useRef, useState } from "react"; +import type { SessionUser } from "@funcatlas/shared"; +import type { PanelImperativeHandle } from "react-resizable-panels"; +import { AppHeader } from "./components/AppHeader"; +import { Canvas } from "./components/Canvas"; +import { CommandPalette } from "./components/CommandPalette"; +import { LoginScreen } from "./components/LoginScreen"; +import { ResolvingSession } from "./components/ResolvingSession"; +import { Sidebar } from "./components/Sidebar"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable"; +import { SidebarProvider } from "./components/ui/sidebar"; +import { TooltipProvider } from "./components/ui/tooltip"; +import { Link } from "./lib/router"; +import { useSession } from "./lib/session"; +import { + CANVAS_DEFAULT, + GITHUB_REPO_URL, + SIDEBAR_DEFAULT, + SIDEBAR_MAX, + SIDEBAR_MIN, +} from "./lib/constants"; + +/** + * Percentages of the group. Strings with an explicit unit, because this library + * reads a bare number as pixels and the difference is invisible until it is + * wrong. + * + * Both panels declare a size: given only one, the library ignores it and falls + * back to an even split. + */ + +/** + * The canvas route, and everything only it needs. + * + * Its own module so `App.tsx` can load it lazily. React Flow alone is most of + * the main bundle, and the landing page cannot use any of it -- on a showcase + * build nobody ever reaches this route at all, so none of this is fetched. + * + * Three states, and the server decides which: resolving, signed out, signed in. + * There is no local "is logged in" flag to drift out of sync -- the cookie is + * HttpOnly, so `useSession` asking the API is the only honest answer. + */ +export default function AppRoute() { + const session = useSession(); + + if (session.isPending) { + return ; + } + + if (session.isError) { + return ; + } + + if (session.data === null) { + return ; + } + + return ( + + {/* The provider exists because the sidebar's menu components read it; + width and collapse are owned by the resizable panel below, which is + the thing the reader actually drags. + + Its wrapper ships as `min-h-svh`, which grows past the viewport + instead of clipping -- the document then scrolls, and the tree and + the canvas move together as one page. Pinned to the viewport here so + each pane owns its own overflow. */} + + + + + ); +} + +function Explorer({ user }: { user: SessionUser }) { + const panel = useRef(null); + const [collapsed, setCollapsed] = useState(false); + + /** + * One rule, everywhere: collapsed is whatever the panel says it is. + * + * Dragging the separator past `minSize` collapses the panel too, so the + * button cannot own this state -- it would keep offering to hide a tree that + * is already hidden. Called from both the group's layout callback and the + * toggle, because neither fires for the other's path. + */ + const sync = () => setCollapsed(panel.current?.isCollapsed() ?? false); + + const toggle = () => { + const handle = panel.current; + if (handle === null) return; + + if (handle.isCollapsed()) { + handle.expand(); + } else { + handle.collapse(); + } + sync(); + }; + + return ( +
+ + + {/* Mounted with the explorer rather than inside the sidebar: ⌘K is a + window-level shortcut, and a palette that only exists while the tree + is open would stop answering when the tree is collapsed. */} + + + + + + + + + + +
+ +
+
+
+
+ ); +} + +/** + * The API could not be reached at all -- which is different from being signed + * out, and showing a sign-in button here would send the user to a dead link. + * + * Written for two readers, because both really arrive here. One is running the + * stack and has forgotten a process. The other followed a link to a deploy + * where only the web app is up, and "run `pnpm dev`" means nothing to them -- + * they need to know the page is not broken and where the source is. + */ +function SessionUnavailable() { + return ( +
+
+

The canvas cannot reach its API

+ +

+ Charting a repository needs the API, the parse worker, Postgres and Redis. None of them + answered, so there is nothing to draw. +

+ +

+ Running it locally? Start them with make start{" "} + and reload. Otherwise the{" "} + + source + {" "} + has the setup, and the{" "} + + overview + {" "} + explains what it does. +

+
+
+ ); +} diff --git a/apps/web/src/components/FileCard.tsx b/apps/web/src/components/FileCard.tsx index e49ffc1..b7796ad 100644 --- a/apps/web/src/components/FileCard.tsx +++ b/apps/web/src/components/FileCard.tsx @@ -237,9 +237,18 @@ function FunctionList({ // and made the row taller than the card was measured for. // The card is sized to the longest name instead. "nodrag cursor-pointer flex-nowrap text-left", + // The same treatment ⌘K gives a result: accent ground and a + // ring in the hue that means "known". Both surfaces are + // offering the same thing -- a function you can pick -- so + // picking one should look the same in either. + // + // box-shadow is in the transition because that is what a + // ring is; `transition-colors` alone would ease the fill and + // snap the outline. + "motion-safe:transition-[background-color,box-shadow] motion-safe:duration-micro", active - ? "bg-accent text-accent-foreground" - : "hover:bg-muted", + ? "bg-accent text-accent-foreground ring-1 ring-primary/40" + : "hover:bg-muted hover:ring-1 hover:ring-primary/20", )} render={ + {/* The outline badge is bordered with --border, and --accent + is the same token, so on the selected row the pill's edge + was drawn in the colour of the row underneath it and + vanished. It keeps its own ground there instead. */} {fn.startLine} diff --git a/apps/web/src/components/ResolvingSession.tsx b/apps/web/src/components/ResolvingSession.tsx new file mode 100644 index 0000000..6ea82d4 --- /dev/null +++ b/apps/web/src/components/ResolvingSession.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "./ui/skeleton"; + +/** + * The shape of the app, not a spinner (UI_GUIDE §3.3). + * + * Its own module because two things need it and they must not import each + * other: `AppRoute` shows it while the session resolves, and `App` uses it as + * the Suspense fallback while the route's chunk downloads. Importing it from + * `AppRoute` would pull that whole chunk -- React Flow included -- back into + * the main bundle and undo the split. + * + * The same skeleton for both is not a shortcut. From the reader's side the two + * waits are one wait, and swapping one placeholder for another mid-load would + * show them a seam that means nothing to them. + */ +export function ResolvingSession() { + return ( +
+
+ + +
+
+
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/landing/ClosingCta.tsx b/apps/web/src/components/landing/ClosingCta.tsx index c270fa4..fda45d4 100644 --- a/apps/web/src/components/landing/ClosingCta.tsx +++ b/apps/web/src/components/landing/ClosingCta.tsx @@ -4,6 +4,7 @@ import { Section } from "./Section"; /** The limits, under a dotted rule, because that is what a dotted line means * everywhere else in this product. */ const LIMITS = [ + "There is no hosted instance. You clone the repository and run it, so the graph and the database are yours and stay on your machine.", "Public repositories only. The OAuth scope is read:user, and the parser clones over public HTTPS.", "Nothing is written to your GitHub account: no commits, no issues, no status checks.", "A push updates the graph through a webhook, so what you are looking at is the current commit.", @@ -15,7 +16,7 @@ export function ClosingCta() { tier="unresolved" eyebrow="Before you start" title="Chart a repository." - lede="Sign in with GitHub, paste a repository URL, and walk the graph it produces." + lede="Clone it, bring it up, sign in with GitHub, and paste a repository URL. The README has the setup, and it is one command once the prerequisites are there." >
    diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx index 752526a..0c162d1 100644 --- a/apps/web/src/components/landing/Hero.tsx +++ b/apps/web/src/components/landing/Hero.tsx @@ -50,7 +50,7 @@ export function Hero() {

- Public repositories. GitHub sign-in at{" "} + A tool you run yourself, on public repositories. GitHub sign-in at{" "} read:user, and nothing is written to your account.

diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx index c121d5c..0d1dc97 100644 --- a/apps/web/src/components/landing/HowItWorks.tsx +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -7,8 +7,8 @@ import { Section } from "./Section"; */ const STEPS = [ { - title: "Clone, in a box", - body: "The repository is cloned into a sandbox with no network, a read-only root filesystem, no capabilities and a non-root user. Symlinks fail the run rather than being followed; files over 1 MB are skipped.", + title: "Clone, without running anything", + body: "Nothing from the repository is executed: no install, no build, no test scripts. Parsing only reads text. The clone is depth-one over public HTTPS, symlinks fail the run rather than being followed, files over 1 MB are skipped, and the checkout is deleted afterwards, including when the parse fails.", }, { title: "Extract", diff --git a/apps/web/src/components/landing/LanguageMarquee.tsx b/apps/web/src/components/landing/LanguageMarquee.tsx new file mode 100644 index 0000000..ad4c46b --- /dev/null +++ b/apps/web/src/components/landing/LanguageMarquee.tsx @@ -0,0 +1,99 @@ +import { cn } from "../../lib/cn"; +import { LANGUAGE_MARK, type LanguageMark } from "../../lib/language-marks"; +import { useMotionEnabled } from "../../lib/motion"; + +/** + * The eight languages, as a strip that drifts. + * + * The two groups below this say which of them resolve across files; this is + * only the roll call, so it can be one continuous line. Motion earns its place + * by doing something static markup cannot: a strip that moves reads as a list + * with no end, which is the impression "eight and counting" wants. + * + * A CSS keyframe over `transform`, not a scroll library. The track holds the + * set twice and translates by exactly half its width, so the seam lands where + * the first copy ends and the loop is invisible. `motion-safe:` means reduced + * motion gets the same strip standing still, not an empty one -- the content is + * the point and the drift is decoration. + */ +const LANGUAGES: { name: string; mark: LanguageMark }[] = [ + { name: "TypeScript", mark: LANGUAGE_MARK.typescript }, + { name: "TSX", mark: LANGUAGE_MARK.react }, + { name: "JavaScript", mark: LANGUAGE_MARK.javascript }, + { name: "JSX", mark: LANGUAGE_MARK.react }, + { name: "Go", mark: LANGUAGE_MARK.go }, + { name: "Rust", mark: LANGUAGE_MARK.rust }, + { name: "Python", mark: LANGUAGE_MARK.python }, + { name: "Java", mark: LANGUAGE_MARK.java }, +]; + +export function LanguageMarquee() { + const animate = useMotionEnabled(); + + return ( + // The mask fades both ends into the ground, so items enter and leave + // rather than being clipped mid-glyph against a hard edge. +
+ {/* The spacing is per-item padding, not `gap`, and that is the whole + trick. `gap` sits *between* items, so a 16-item track has 15 of them: + half the width is one set plus seven and a half gaps, while a + seamless loop needs one set plus a whole one. Translating -50% then + lands half a gap short and the strip visibly jumps every cycle. With + each item carrying its own trailing space the two halves are exactly + equal and -50% is exactly one set. */} +
+ {(animate ? [...LANGUAGES, ...LANGUAGES] : LANGUAGES).map((language, index) => ( +
+
+ ); +} + +function Mark({ + name, + mark, + duplicate, + spaced, +}: { + name: string; + mark: LanguageMark; + duplicate: boolean; + /** Trailing space carried by the item rather than by a `gap`, so the two + * halves of the looping track measure identically. */ + spaced: boolean; +}) { + return ( +
+ + + + {name} +
+ ); +} diff --git a/apps/web/src/components/landing/Languages.tsx b/apps/web/src/components/landing/Languages.tsx index f7a2438..6423867 100644 --- a/apps/web/src/components/landing/Languages.tsx +++ b/apps/web/src/components/landing/Languages.tsx @@ -1,4 +1,6 @@ +import { cn } from "../../lib/cn"; import { Bezel } from "./Bezel"; +import { LanguageMarquee } from "./LanguageMarquee"; import { Section } from "./Section"; /** @@ -8,19 +10,26 @@ import { Section } from "./Section"; * the ECMAScript family the parser extracts functions and calls and resolves * only within a file. Saying "eight languages" and leaving that in the docs * would be the kind of claim this product exists not to make. + * + * The strip above the cards is the roll call and the cards are the caveat. The + * marks live only in the strip: repeating all eight logos inside the cards + * would say the same thing twice on one screen, and the cards are carrying the + * distinction rather than the names. */ const GROUPS = [ { heading: "Resolved across files", detail: - "Imports are followed, so a call reaches a definition in another file.", - languages: ["TypeScript", "TSX", "JavaScript", "JSX"], + "Imports are followed, so a call in one file reaches a definition in another and the edge is drawn exact.", + languages: "TypeScript · TSX · JavaScript · JSX", + tone: "text-confidence-exact", }, { heading: "Extracted, resolved within a file", detail: - "Every function and call site is charted. Cross-file resolution is not built yet.", - languages: ["Go", "Rust", "Python", "Java"], + "Every function and call site is charted, and a call inside its own file still resolves. Reaching across files is not built yet.", + languages: "Go · Rust · Python · Java", + tone: "text-confidence-name", }, ] as const; @@ -32,26 +41,21 @@ export function Languages() { title="Eight languages, and two different depths." lede="Support is not uniform, so it is not presented as though it were. The rule above this heading is dashed for the same reason the canvas draws a name match dashed: reported, not verified." > -
+ + +
{GROUPS.map((group) => ( - -

+ +

{group.heading}

-

- {group.detail} -

-
    - {group.languages.map((language) => ( -
  • - {language} -
  • - ))} -
+

{group.detail}

+ + {/* Coloured by the tier each group's calls actually produce across + files: exact for the first, name match for the second. The one + place in this section where a colour is a claim. */} +

{group.languages}

))}

diff --git a/apps/web/src/components/landing/OpenAtlas.test.tsx b/apps/web/src/components/landing/OpenAtlas.test.tsx new file mode 100644 index 0000000..d92a81e --- /dev/null +++ b/apps/web/src/components/landing/OpenAtlas.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { APP_ROUTE } from "@funcatlas/shared"; + +/** + * `SHOWCASE` is read from `import.meta.env` once, at module load, because it is + * a build-time fact rather than a runtime one. So each case has to reset the + * module graph and import again -- stubbing the env after the import would + * leave the already-evaluated constant behind and both cases would pass + * against the same build. + */ +async function renderCta(showcase: boolean) { + vi.resetModules(); + vi.stubEnv("VITE_SHOWCASE", showcase ? "true" : "false"); + + const { OpenAtlas } = await import("./OpenAtlas"); + render(); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("OpenAtlas", () => { + it("opens the canvas in a normal build", async () => { + await renderCta(false); + + const cta = screen.getByRole("link", { name: /open the atlas/i }); + expect(cta).toHaveAttribute("href", APP_ROUTE); + }); + + it("sends readers to the repository when there is no API behind it", async () => { + await renderCta(true); + + // `/app` on a web-only deploy reaches nothing, so pointing at it would send + // every visitor to an error screen. + const cta = screen.getByRole("link", { name: /run it yourself/i }); + expect(cta).toHaveAttribute("href", expect.stringContaining("github.com")); + expect(screen.queryByRole("link", { name: /open the atlas/i })).not.toBeInTheDocument(); + }); + + it("changes the label with the destination, not just the href", async () => { + // A button still reading "Open the atlas" that opened GitHub would be the + // same lie in a friendlier voice (UI_GUIDE §3.4: an action keeps its name + // through the flow, so a different flow needs a different name). + await renderCta(true); + + const cta = screen.getByRole("link", { name: /run it yourself/i }); + expect(cta).not.toHaveAttribute("href", APP_ROUTE); + }); +}); diff --git a/apps/web/src/components/landing/OpenAtlas.tsx b/apps/web/src/components/landing/OpenAtlas.tsx index c040553..ec784b1 100644 --- a/apps/web/src/components/landing/OpenAtlas.tsx +++ b/apps/web/src/components/landing/OpenAtlas.tsx @@ -1,18 +1,28 @@ +import type { ReactNode } from "react"; import { APP_ROUTE } from "@funcatlas/shared"; import { ArrowRight } from "lucide-react"; import { cn } from "../../lib/cn"; +import { GITHUB_REPO_URL, SHOWCASE } from "../../lib/constants"; import { Link } from "../../lib/router"; import { buttonVariants } from "../ui/button"; /** * The page's only call to action, and it says the same thing in both places it - * appears — the hero and the closing section. One name for one action, kept + * appears -- the hero and the closing section. One name for one action, kept * through the flow (UI_GUIDE §3.4). * - * It goes to `/app` rather than straight to the OAuth endpoint: the sign-in - * card is the surface that carries "Sign in with GitHub" (§3.1), and sending a - * reader through it is how they learn the confidence legend before the canvas - * uses it. + * Two destinations, because there are two kinds of build. + * + * Normally it goes to `/app` rather than straight to the OAuth endpoint: the + * sign-in card is the surface that carries "Sign in with GitHub" (§3.1), and + * sending a reader through it is how they learn the confidence legend before + * the canvas uses it. + * + * In a showcase build there is no API behind `/app`, so sending anyone there + * would be sending them to an error. It points at the repository instead and + * says so: **the label changes with the destination.** A button that still read + * "Open the atlas" and opened GitHub would be the same lie in a friendlier + * voice. */ export function OpenAtlas({ size = "lg", @@ -21,20 +31,34 @@ export function OpenAtlas({ size?: "sm" | "lg"; className?: string; }) { + const classes = cn(buttonVariants({ size }), "group", className); + + if (SHOWCASE) { + return ( + + Run it yourself + + + ); + } + return ( - + Open the atlas - {/* The arrow rides in its own well, so the control reads as a thing with - a moving part rather than as text with a glyph after it. */} - - - + ); } + +/** The arrow rides in its own well, so the control reads as a thing with a + * moving part rather than as text with a glyph after it. */ +function Well(): ReactNode { + return ( + + + + ); +} diff --git a/apps/web/src/components/landing/Section.tsx b/apps/web/src/components/landing/Section.tsx index 94f8821..6a10750 100644 --- a/apps/web/src/components/landing/Section.tsx +++ b/apps/web/src/components/landing/Section.tsx @@ -47,16 +47,23 @@ export function Section({ -

+ {/* The page's third ink lands here: an eyebrow is a label, not a + claim, so it is the one place colour can be spent without + competing with the three that mean something. */} +

{eyebrow}

-

+ {/* 40-48px against 16px body. The old 30px heading sat too close to + its own lede for the eye to rank them, which is most of why the + page read flat. Bricolage's weight axis carries the rest: 560 + here against 400 in prose. */} +

{title}

{lede === undefined ? null : ( -

+

{lede}

)} diff --git a/apps/web/src/components/landing/Tiers.tsx b/apps/web/src/components/landing/Tiers.tsx index 31650ae..cdbd5a4 100644 --- a/apps/web/src/components/landing/Tiers.tsx +++ b/apps/web/src/components/landing/Tiers.tsx @@ -1,7 +1,9 @@ import type { ResolutionConfidence } from "@funcatlas/shared"; import { cn } from "../../lib/cn"; import { CONFIDENCE, CONFIDENCE_ORDER } from "../../lib/confidence"; +import { useMotionEnabled } from "../../lib/motion"; import { ConfidenceRule } from "../ConfidenceRule"; +import { Fades } from "../animate-ui/primitives/effects/fade"; import { Bezel } from "./Bezel"; import { Section } from "./Section"; @@ -12,8 +14,7 @@ import { Section } from "./Section"; * the product does not draw. */ const CAUSE: Record = { - exact: - "The import was followed to a declaration, and only one function could be the target.", + exact: "The import was followed to a declaration, and only one function could be the target.", name_match: "A function with that name is in scope. Another one elsewhere may be the function actually called.", unresolved: @@ -21,6 +22,26 @@ const CAUSE: Record = { }; export function Tiers() { + const animate = useMotionEnabled(); + + const cards = CONFIDENCE_ORDER.map((tier) => { + const { label, meaning, textClass } = CONFIDENCE[tier]; + + return ( +
  • + + + +

    {label}

    + +

    {meaning}

    + +

    {CAUSE[tier]}

    +
    +
  • + ); + }); + return (
    + {/* Staggered in tier order, so the three arrive most certain first and + the scale is read in the direction it means something. One gesture + for the set rather than three cards each deciding for themselves -- + the same treatment the coverage chips get. */}
      - {CONFIDENCE_ORDER.map((tier) => { - const { label, meaning, textClass } = CONFIDENCE[tier]; - - return ( -
    • - - - -

      - {label} -

      - -

      {meaning}

      - -

      - {CAUSE[tier]} -

      -
      -
    • - ); - })} + {animate ? ( + + {cards} + + ) : ( + cards + )}

    - An unresolved call is an admission, not an error, and it is never - coloured like one. A tool that guesses is worse than a tool that stops, - because a wrong edge is read as fact and costs more than the missing one - it replaced. + An unresolved call is an admission, not an error, and it is never coloured like one. A tool + that guesses is worse than a tool that stops, because a wrong edge is read as fact and costs + more than the missing one it replaced.

    ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 248f386..1b68a04 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -25,6 +25,7 @@ --confidence-name: theme("palette.light.confidence.name"); --confidence-unresolved: theme("palette.light.confidence.unresolved"); --on-accent: theme("palette.light.onAccent"); + --spot: theme("palette.light.spot"); /* A genuinely destructive action is red. Deliberately not the unresolved tier, which is a neutral slate because an unresolved call is an honest @@ -45,6 +46,7 @@ --confidence-name: theme("palette.dark.confidence.name"); --confidence-unresolved: theme("palette.dark.confidence.unresolved"); --on-accent: theme("palette.dark.onAccent"); + --spot: theme("palette.dark.spot"); --destructive: oklch(0.68 0.19 25); } @@ -166,6 +168,19 @@ body, user-select: none; } +/* The landing page's language strip. The track holds the set twice and moves + by exactly half its width, so the loop closes on itself invisibly. Applied + through an arbitrary `animate-[...]` utility, which needs the keyframe to + exist in plain CSS. */ +@keyframes marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 00b0576..af3c861 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -36,6 +36,20 @@ export const UI_STORAGE_KEY = "funcatlas-ui"; export const GITHUB_REPO = "ARCoder181105/funcatlas"; export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`; +/** + * This build has no API behind it. + * + * A deploy of the web app on its own -- the public face of a tool you run + * yourself. `/app` would reach nothing, so the landing page sends readers to + * the repository instead of to an error screen. + * + * Build-time rather than a runtime probe, and deliberately so: the landing page + * makes no request to our API at all, which is what lets it render when the + * backend is absent. Asking whether the API is up would give that away for a + * fact the build already knows. + */ +export const SHOWCASE = import.meta.env.VITE_SHOWCASE === "true"; + // --- Motion --------------------------------------------------------------- /** Milliseconds. Page-level motion in UI_GUIDE §4 is 400-600ms, and this moves diff --git a/apps/web/src/lib/language-marks.ts b/apps/web/src/lib/language-marks.ts new file mode 100644 index 0000000..83da6d4 --- /dev/null +++ b/apps/web/src/lib/language-marks.ts @@ -0,0 +1,56 @@ +/** + * Brand marks for the languages funcatlas reads. + * + * The path data is vendored from simple-icons (CC0-1.0) rather than imported: + * the package has no subpath exports, so using it means pulling a module of + * three thousand icons through the bundler to keep seven. These are static + * strings and they change about once a decade. + * + * Drawn monochrome, inheriting the text colour. Brand colours would break the + * one rule the landing page holds to -- no colour appears that does not carry + * its canvas meaning (UI_GUIDE §1.3) -- and eight logos in eight brand palettes + * beside three confidence tiers is exactly the noise that rule exists to stop. + * The silhouettes stay recognisable without it. + * + * TSX and JSX take React's mark, which is what they are. + * + * Java is drawn here rather than vendored. Oracle's cup is a trademark and + * simple-icons does not ship it; OpenJDK's duke is legally clean but nobody + * reads it as "Java". This is a plain coffee cup -- the thing people actually + * recognise, and not a copy of anyone's logo. + */ +export interface LanguageMark { + title: string; + path: string; +} + +export const LANGUAGE_MARK = { + typescript: { + title: "TypeScript", + path: "M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z", + }, + react: { + title: "React", + path: "M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z", + }, + javascript: { + title: "JavaScript", + path: "M0 0h24v24H0V0zm22.034 18.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84 1.515-.66.39.12.75.42.976.9 1.034-.676 1.034-.676 1.755-1.125-.27-.42-.404-.601-.586-.78-.63-.705-1.469-1.065-2.834-1.034l-.705.089c-.676.165-1.32.525-1.71 1.005-1.14 1.291-.811 3.541.569 4.471 1.365 1.02 3.361 1.244 3.616 2.205.24 1.17-.87 1.545-1.966 1.41-.811-.18-1.26-.586-1.755-1.336l-1.83 1.051c.21.48.45.689.81 1.109 1.74 1.756 6.09 1.666 6.871-1.004.029-.09.24-.705.074-1.65l.046.067zm-8.983-7.245h-2.248c0 1.938-.009 3.864-.009 5.805 0 1.232.063 2.363-.138 2.711-.33.689-1.18.601-1.566.48-.396-.196-.597-.466-.83-.855-.063-.105-.11-.196-.127-.196l-1.825 1.125c.305.63.75 1.172 1.324 1.517.855.51 2.004.675 3.207.405.783-.226 1.458-.691 1.811-1.411.51-.93.402-2.07.397-3.346.012-2.054 0-4.109 0-6.179l.004-.056z", + }, + go: { + title: "Go", + path: "M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z", + }, + rust: { + title: "Rust", + path: "M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z", + }, + python: { + title: "Python", + path: "M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.77l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.17l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05-.05-1.23.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.18l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09zm13.09 3.95l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08z", + }, + java: { + title: "Java", + path: "M8.1 2.2c.75.9.75 1.85 0 2.75-.6.72-.6 1.35 0 2.05H6.6c-.75-.9-.75-1.85 0-2.75.6-.72.6-1.35 0-2.05zm3.9 0c.75.9.75 1.85 0 2.75-.6.72-.6 1.35 0 2.05h-1.5c-.75-.9-.75-1.85 0-2.75.6-.72.6-1.35 0-2.05zm3.9 0c.75.9.75 1.85 0 2.75-.6.72-.6 1.35 0 2.05h-1.5c-.75-.9-.75-1.85 0-2.75.6-.72.6-1.35 0-2.05zM3.6 9.1h13.2v6.4a4.6 4.6 0 0 1-4.6 4.6H8.2a4.6 4.6 0 0 1-4.6-4.6V9.1zM17.9 10.2h.9a3.2 3.2 0 0 1 0 6.4h-1.4a6 6 0 0 0 .38-1.9h1.02a1.3 1.3 0 0 0 0-2.6h-.9v-1.9zM2.4 21.1h15.6v1.8H2.4z", + }, +} as const satisfies Record; diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts index 825f489..f2ae8ab 100644 --- a/apps/web/src/lib/tokens.ts +++ b/apps/web/src/lib/tokens.ts @@ -32,6 +32,20 @@ export interface Palette { }; /** Text drawn on top of `confidence.exact`, which doubles as the accent. */ onAccent: string; + /** + * A third ink, and the only colour in here that carries no canvas meaning. + * + * The two spot inks plus a neutral say everything resolution has to say, but + * on a page three thousand pixels long they say it in two hues and the page + * reads flat. A map is drawn in three inks: land, route, and water. This is + * the water. + * + * **Landing page only.** It appears on eyebrows, section detail and hover + * states, and never on the canvas -- there, a colour that means nothing would + * be competing with three that mean something. It sits at 174°, a clear 53° + * off `exact` and 153° off `name`, so it cannot be misread as either. + */ + spot: string; } /** @@ -75,6 +89,7 @@ const ULTRAMARINE: Palette = { unresolved: "#767c92", }, onAccent: "#080a14", + spot: "#2ad4c4", }; /** @@ -100,6 +115,8 @@ const LETTERPRESS: Palette = { unresolved: "#71768a", }, onAccent: "#ffffff", + /** Darker than the dark theme's, to clear 4.5:1 as text on paper. */ + spot: "#0b7268", }; export const PALETTE: Record = { diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 29c29a1..ddc541a 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -2,6 +2,10 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; + /** "true" in a build with no API behind it. See SHOWCASE in lib/constants.ts. + * A string, not a boolean: Vite substitutes the literal from the + * environment, and `VITE_SHOWCASE=false` would otherwise be truthy. */ + readonly VITE_SHOWCASE?: string; } interface ImportMeta { diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 2c18eb2..36a59a9 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -26,6 +26,9 @@ export default { DEFAULT: "var(--ink)", muted: "var(--ink-muted)", }, + // The landing page's third ink. Not a confidence tier, and deliberately + // named so it cannot be mistaken for one. + spot: "var(--spot)", confidence: { exact: "var(--confidence-exact)", name: "var(--confidence-name)", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4cfce1f..40bc429 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,9 +7,9 @@ GitHub repo URL │ ▼ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Clone step │──▶ │ Parser Worker │──▶ │ Resolver │ -│ (network on, │ │ (Go+tree-sitter, │ │ (name and scope │ -│ isolated) │ │ network none) │ │ matching) │ +│ Clone step │──▶ │ Parser │──▶ │ Resolver │ +│ (depth one, │ │ (Go+tree-sitter, │ │ (name and scope │ +│ no scripts) │ │ read only) │ │ matching) │ └──────────────┘ └──────────────────┘ └──────────────────┘ │ ▼ @@ -85,6 +85,8 @@ Job queue ──▶ diff changed files ──▶ re-parse only those ──▶ r - **The worker is a Node process that spawns the Go binary**, exactly as registration used to. The parser gains no Redis dependency and stays a CLI that takes a path and writes to Postgres — which is also what keeps `make go-run` and the container's `--network none` check working unchanged. + Note what that does *not* say: the worker spawns the binary with `execFile`, so the container's + constraints apply to `make parser-isolated` and not to the running product. R38. An earlier draft of this document said the worker "communicates only through the job queue and Postgres"; it does not, and reimplementing BullMQ's job protocol in Go to make that true would have bought nothing. diff --git a/docs/RISKS.md b/docs/RISKS.md index 6f4a45c..c54ff2e 100644 --- a/docs/RISKS.md +++ b/docs/RISKS.md @@ -62,6 +62,12 @@ Nothing outstanding -- R19 through R22 and R26 through R29 all closed; see Decid | **R36** | **A test can measure the boundary with the function that defines it.** The Phase 5 exit test first compared `ResolutionGroup(caller)` with `ResolutionGroup(callee)` -- and passed with `ResolutionGroup` returning a constant, because both sides moved together. | Found by breaking the function on purpose and watching nothing fail. The assertions now compare language names from a literal set written in the test file. The same shape is worth suspecting anywhere a test derives its expectation from the code under test -- and the fixture had a second version of the problem: every file defined `helper`, so ambiguity answered `unresolved` whether the partition worked or not. It now also carries names defined in exactly one language. | | **R37** | **Six languages share one resolver, and only TypeScript's rules are modelled.** Same-file resolution is language-agnostic and stays `exact`; everything else outside the ECMAScript family is `name_match` or `unresolved`. | Deliberate, and the reason Phase 5 was scoped to extraction (`PLAN.md`). The risk is drift: a later change that widens a rule for one language widens it for all six, because there is one `resolve`. The guard is `TestPolyglot_CrossFileIsNeverExactOutsideECMAScript`, which fails the moment any non-ECMAScript language starts answering confidently across files. | +### Found while planning NFR-4 + +| | Risk | Notes | +|---|---|---| +| **R38** | **The parser sandbox is real, tested, and not used by the product.** `docs/SECURITY.md` ticked "clone/parse runs in an isolated container" and "parser has no outbound network access" from Phase 1 onward, `CLAUDE.md` said isolation "was built in Phase 1, not deferred", and the landing page told readers a repository is cloned into a sandbox with no network and no capabilities. All true of `make parser-isolated` and of nothing else: `repos/register.ts` runs the parser with `execFile(env.PARSER_BIN, ...)` from the queue worker, so on `make start` and in any composed stack it is a plain child process carrying the worker's network, filesystem and user. | Found by reading the spawn rather than the checklist. **The claim is corrected rather than the code**, because both fixes cost more than the gap: shelling out to `docker run` needs the Docker socket mounted into the worker, which grants root-equivalent host control — a worse property than the one it buys — and namespaces/seccomp/bubblewrap avoids that but is Linux-only and a project of its own. What survives is enforced everywhere, because it lives *in* the parser rather than around it: symlinks hard-fail, size/count/depth caps, a `--depth 1` clone with credential prompts disabled, no repo scripts executed, and the clone removed on the failure path too. Two lessons worth more than the fix. A checkbox ticked against a harness says nothing about the product — the harness passes `--no-deps`, which is also why nobody hit the `parser` service's `network_mode: none` / `depends_on` contradiction. And the deferral in the TOCTOU checklist item had quietly leaned on this claim inside its own justification, so one false statement had already propagated into a second decision. | + --- ## Deferred until after the MVP diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d9d9ba5..b6b7af5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -7,10 +7,44 @@ This project clones and reads arbitrary user-supplied repositories. That's a rea - **Parsing never requires execution.** Tree-sitter reads file contents and produces a syntax tree — it does not need `npm install`, `pip install`, build scripts, or any other code from the target repo to run. The clone/parse pipeline should never invoke the repo's own install/build/test scripts. - **Isolate the clone + parse step.** Even without deliberately executing the repo's code, git hooks and certain tooling can trigger unexpected behavior on clone/checkout. Run cloning and parsing inside a container at minimum; a microVM (e.g. Firecracker-style isolation) is a stronger option if handling many untrusted third-party repos at scale. - **Mount only what's needed.** The parser process should only have access to the cloned repo's directory — never the host filesystem, credentials, or other users' cloned repos. -- **No network access from the parse step.** The parser doesn't need outbound network access to do its job; blocking it removes a whole class of potential exfiltration or supply-chain risk from a malicious repo. This is enforced as a **hard runtime constraint** (`docker run --network none`, read-only mount, dropped capabilities) — not just a note. +- **No network access from the parse step.** The parser doesn't need outbound network access once the clone is done; blocking it removes a whole class of potential exfiltration or supply-chain risk from a malicious repo. **This is a goal, not something the running product currently enforces** — see "Where the isolation actually applies" below before relying on it. - **Auth.** GitHub OAuth for user login. The scope is `read:user`, not `repo` -- GitHub OAuth apps have no read-only repository scope, and `repo` grants write access to every private repository the user can reach (R26). Private repositories are therefore out of scope until a phase needs them. Webhook payloads must be signature-verified (GitHub's HMAC signature) before being trusted and enqueued. - **Tenant isolation.** If this ever serves more than one user, cloned repos and parse jobs must not share state, disk, or memory across users/repos — treat each clone as its own isolated workspace, cleaned up after use. +## Where the isolation actually applies + +Read this before quoting anything above as a guarantee. **The container constraints are real and +tested, and the running product does not use them.** + +There are two ways the parser runs, and only one is sandboxed: + +| Path | How it runs | Sandboxed? | +|---|---|---| +| `make parser-isolated` | `docker compose run --rm --no-deps parser` against the `parser` service, which sets `network_mode: "none"`, `read_only: true`, `cap_drop: [ALL]` and a non-root user | **Yes.** This is the harness the constraints were written for. | +| The product, on every path | `apps/api/src/repos/register.ts` calls `execFile(env.PARSER_BIN, [...])` from the queue worker | **No.** A plain child process of the worker, with the worker's network, filesystem and user. | + +So `--network none`, the read-only rootfs and the dropped capabilities apply when you deliberately +invoke the harness, and at no other time. `make start` does not use them, and neither would a +composed stack — `docker-compose.yml`'s `parser` service is the harness, which is why it can carry +`network_mode: "none"` and a `depends_on` on Postgres without anyone noticing the contradiction. + +**What is genuinely enforced on every path**, because it lives in the parser rather than around it +(`services/parser/internal/security`, covered by `security` package tests): + +- Symlinks hard-fail the run; every path is bounded to the clone root. +- Files over `PARSER_MAX_FILE_BYTES` (1 MB) are skipped, and `PARSER_MAX_FILES` / + `PARSER_MAX_DEPTH` / `PARSER_SKIP_PATHS` cap the walk. +- The clone is `git clone --depth 1` with `GIT_TERMINAL_PROMPT=0` and empty `GIT_ASKPASS` / + `SSH_ASKPASS`, so a private or missing repository fails immediately instead of blocking on a + credential prompt (R32). +- The repo's own install, build and test scripts are never invoked. Parsing does not execute code. +- The clone is removed afterwards, on the failure path too. + +Closing the gap is **R38**. The two routes both cost something: having the worker shell out to +`docker run` means mounting the Docker socket into it, which grants root-equivalent control of the +host and trades one security property for a worse one; doing it with namespaces, seccomp or +bubblewrap avoids that but is Linux-only and a project in its own right. + ## Concrete input hardening (enforced in the clone/parse step) - **Symlink / path-traversal guard.** Never follow symlinks. Resolve every file path and reject it unless it is strictly inside the clone root (rejects `../../etc/passwd` and symlinks pointing outside the repo). This applies to both parsing and to serving raw source. @@ -26,10 +60,10 @@ This project clones and reads arbitrary user-supplied repositories. That's a rea ## Checklist before handling real users' private repos -- [x] Clone/parse runs in an isolated container, not the host running the API -- [x] No install/build scripts from the target repo are ever invoked -- [x] Parser process has no outbound network access (`--network none`, read-only mount, dropped caps) -- [ ] Symlink / path-traversal escapes are checked — **path validation done; descriptor-based TOCTOU protection deferred again in Phase 4.** `Walk` hard-fails on any symlink and `ContainsRoot` bounds every path, so an escape needs a directory swapped between the check and the open. That is a real race and a narrow one: the parser reads a checkout it made itself, seconds earlier, in a container with no network and no capabilities. Revisit when it parses a tree someone else can write to. +- [ ] Clone/parse runs in an isolated container, not the host running the API — **the container exists and is tested; the product does not use it.** `make parser-isolated` runs the parser under `network_mode: "none"`, a read-only rootfs, `cap_drop: ALL` and a non-root user. The queue worker runs the same binary through `execFile`, in its own process space. Ticked from Phase 1 until the landing-page branch, on the strength of the harness rather than of the running path. R38. +- [x] No install/build scripts from the target repo are ever invoked — parsing reads file contents into a syntax tree and nothing else. This one holds on every path, container or not, because it is a property of tree-sitter rather than of the sandbox. +- [ ] Parser process has no outbound network access (`--network none`, read-only mount, dropped caps) — **only under `make parser-isolated`.** Same gap as the box above, and note the parser needs network for `git clone` in the first place, so a real sandbox has to admit a clone stage before it drops the interface. +- [ ] Symlink / path-traversal escapes are checked — **path validation done; descriptor-based TOCTOU protection deferred again in Phase 4.** `Walk` hard-fails on any symlink and `ContainsRoot` bounds every path, so an escape needs a directory swapped between the check and the open. That is a real race and a narrow one: the parser reads a checkout it made itself, seconds earlier, from a `--depth 1` clone of a public repository. **The original wording justified this partly by "in a container with no network and no capabilities", which R38 shows is not true of the running path** — the deferral still stands on the rest, but it stands on less than it appeared to. Revisit when it parses a tree someone else can write to. - [x] File-count, per-file size (>1MB), and depth caps are enforced; binary/`node_modules`/`.git` skipped - [x] Webhook signatures are verified, replay-protected, and per-repo throttled — HMAC-SHA256 over the **raw bytes** (`routes/webhook.ts`), delivery ids held in Redis with `SET NX`, and a per-hook rate limit. Replay protection is by delivery id rather than a timestamp window: GitHub sends no timestamp, and the id is what a retry repeats. - [x] All graph endpoints are session-gated — one `preHandler` on one encapsulated scope (`routes/index.ts`), asserted route by route in `routes/graph.test.ts`. Shipped in Phase 3a; the box was never ticked. The webhook is deliberately outside that gate and is authenticated by its signature instead. diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index 765b961..7c81bd8 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -65,6 +65,13 @@ what a press would do on white stock. | `name_match` | `confidence.name` | `#a55424` | Fired clay, darkened the same way. | | `unresolved` | `confidence.unresolved` | `#71768a` | Cool ash, dark enough to hold as a hairline on paper. | +**A third ink, `spot`, for the landing page only** — `#2ad4c4` dark, `#0b7268` light. It carries no +canvas meaning, which is why it is named `spot` and not a tier: on a page three thousand pixels long +two hues read flat, and a map is drawn in three inks rather than two. It sits at 174°, a clear 53° +off `exact` and 153° off `name`, so it cannot be misread as either, and it never appears on the +canvas — there a colour that means nothing would compete with three that mean something. Used on +eyebrows, section detail and hover states. + Two rules bind both palettes, and `confidence.test.ts` enforces them: 1. **`unresolved` is never a chromatic red.** Colouring an honest admission as a failure tells the diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f1ff046 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm --filter web build", + "installCommand": "pnpm install --frozen-lockfile", + "outputDirectory": "apps/web/dist", + "framework": null, + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +}