Files
OwnCord/docs/contributing.md
T
Claude 474bb2175c test: give the cross-stack contracts a named tier (RL-11)
`Client/tests/unit/admin-static-channel-perms.test.ts` reads and executes
`Server/admin/static/index.html`. Filed under `tests/unit`, nothing about its
location or name said it locks a server-owned artifact, so a Go developer
editing the admin SPA got a red check called "Client Unit Tests" with no clue
why.

The register describes this as one file. It is not, and the measured set does
not match the description in either direction:
- Client -> Server: exactly ONE test crosses by filesystem read, not two.
  `main-page.test.ts` was named in the plan but only carries a prose comment
  citing `Server/admin/update_handlers.go:181` at line 1046 — no read, no
  import, nothing to move.
- Server -> Client: the four tests the plan named do not cross.
  `waf_test.go`/`waf_crs_test.go` set a `User-Agent: OwnCordClient/1.0`
  literal that appears nowhere under `Client/`; `ws_integration_test.go:289`
  and `sanitize_content_fuzz_test.go:46` are comments. The real crossing is
  one the register never named: `Server/updater/updater_test.go:630` does
  `os.ReadFile` on `Client/src-tauri/tauri.conf.json`.

The obvious fixes are both wrong. Moving the invariant "to the owning server
test" cannot work: `Server/go.mod` carries no JavaScript engine (no goja,
otto, v8go, quickjs, rogchap, duktape), so a Go port could only assert at the
text level like `admin/perm_grid_test.go` does — and that is not a
substitute. Flipping the guard at `admin/static/index.html:1182` to
`targetIsTouchedRole=false` reintroduces OC-0154 in full while leaving every
greppable identifier intact, so a text-level test passes on a broken file.
Relocating it to the e2e admin journey is worse: that job is
`continue-on-error: true` and deliberately unpinned ("requiring it is
theatre" — `docs/plans/b0-dev-branch-protection.sh`), so it would convert a
blocking, pinned gate into one that is green regardless. And the journey does
not cover the invariant today: `grep -Eic "perm|access|role|override|matrix"`
over its 142 lines returns 0, so the "if e2e already covers it, delete"
branch never fires.

Done — one tier, applied to the whole set, defined by artifact coupling and
placed by runtime capability:
- New `Client/tests/contract/`, holding
  `server-admin-static-channel-perms.test.ts`. Same directory depth, so
  `../../../Server/...` still resolves; the body is byte-identical apart from
  a header naming the owner and the runner.
- `Server/updater/tauri_key_contract_test.go` splits the one cross-component
  Go test out of `updater_test.go` verbatim, same `package updater`. It stays
  in Go — placement follows capability, and Go parses JSON fine — so only the
  file name has to declare the crossing. Without this the item would have
  been "moved one file and declared the class closed".
- `npm run test:contract`, and the tier, the membership rule and a
  blocking/non-blocking table in `docs/contributing.md#testing`, which
  previously described no tiers at all.
- `Client/CLAUDE.md`'s tier list was missing `tests/e2e/admin` and
  `tests/e2e/native` before this; it now lists all seven and states the rule.
  `Server/CLAUDE.md` records why the SPA's execution-level invariant is
  locked from the client tree, so nobody "fixes" it into a regex.
- Ledger `OC-0154.fix.test` re-pointed and `FINDINGS.md` re-rendered;
  `.claude/workflows/bughunt.js` — the workflow that produced OC-0154 — no
  longer describes the TS test surface as `tests/unit/*.test.ts` only.
- Three stale cross-stack pointers of exactly the class this item is about:
  `tests/e2e/helpers.ts:348,351` and `tests/unit/types.test.ts:13` named
  `docs/brain/06-Specs/PROTOCOL.md`, which does not exist (`docs/brain/` is a
  gitignored path); all now name `docs/protocol.md`.

15 files, 125 insertions, 33 deletions. No CI job, workflow, vitest,
tsconfig, eslint, knip or stryker change, and no new pinned check —
`ci.yml`'s `npx vitest run --coverage` has no path filter and
`vitest.config.ts` includes `tests/**/*.test.ts`, so enforcement after the
move is bit-identical to enforcement before it. That is deliberate: `dev`
pins 11 contexts and a 12th is a branch-protection API write, not something a
PR can do, so any new job would be advisory until someone separately changed
repository settings — strictly less protection than today.

Verified: both directions, and the assertion was not weakened. Flipping
`admin/static/index.html:1182` to `const targetIsTouchedRole=false;` makes
the moved test fail (`AssertionError: expected 'DELETE' not to be 'DELETE'`);
`git checkout` of that file makes it pass again — so the invariant survived
the move intact rather than becoming a test that passes anywhere. The split
Go test's cross-boundary read is live too: with
`Client/src-tauri/tauri.conf.json` moved away, `go test ./updater/` fails
with `ReadFile(../../Client/src-tauri/tauri.conf.json): no such file or
directory` from `tauri_key_contract_test.go:20`, and passes once restored.
The full client suite is 192 files / 5257 tests passing, identical to the
count before the move; `npm run typecheck` passes, which proves
`tests/contract/` is inside the tsconfig graph and that `tests/types/jsdom.d.ts`
still resolves the moved test's `import { JSDOM }`. `npm run lint`,
`npx prettier --check .`, `go vet ./...` and `go test ./updater/` all pass.
`git grep "tests/unit/admin-static-channel-perms"` finds no survivor outside
the B1 plan itself.

Not included: nothing was deleted, because no e2e sibling covers OC-0154.
`Client/tests/types/jsdom.d.ts` was neither moved nor deleted — it is still
the only type source for the moved test's `jsdom` import. `capabilities-scope.test.ts`
and `tauri-conf-webview2-args.test.ts` read `src-tauri/` and stay in
`tests/unit`: `src-tauri` is inside the `Client` component, so they are not
contract tests, and the rule earns that rather than hand-waving it — moving
them would have forced repoints of ledger entry OC-0089 and
`docs/security.md:64` for no gain. Each gained a one-line header saying why.
`Server/admin/perm_grid_test.go` and `emoji_section_test.go` read their own
package's embedded asset and are unchanged; they are the text-level
complement to the execution-level test, not duplicates. No JS engine was
added to `go.mod`, no npm root was created under `Server/`, and no root-level
`tests/` tier was created — there is no runner for one and no way to make it
blocking from a PR. Separately noticed and NOT fixed here:
`docs/contributing.md:221` still says "All ten required checks" while
`docs/plans/b0-dev-branch-protection.sh` pins eleven since B1-3 added
`Repository Hygiene`, and `docs/plans/hp-0-scorecard-2026-08-25.md:109` is
stale the same way — that is the branch-protection item's to fix, not this
one's, and one register item per commit.

Refs RL-11, L-11
2026-08-26 20:13:26 +00:00

341 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Contributing
How to set up the development environment and contribute to OwnCord.
## Development Setup
### Prerequisites
| Platform | Server | Client |
| --------------- | ------ | ------------ |
| Windows 10+ x64 | ✅ | ✅ |
| Linux x64 | ✅ | ✅ |
| Linux ARM64 | ✅ | ✅ (CI only) |
- **Go 1.26+** (server)
- **Node.js 24+** (client) — pinned in `Client/.nvmrc`; `engine-strict` makes a
wrong major a hard failure, not a warning
- **Rust / Cargo** (Tauri client — not needed for server-only work)
- **Docker + Compose v2** (optional — alternative to building the server locally)
### Available Commands
#### Root facade — one entry point
From the repository root. These orchestrate the per-stack commands below; they
are a convenience, not a replacement. Nothing here needs `make`, and everything
works the same on Windows, macOS and Linux.
| Command | Description |
| ----------------------------- | ------------------------------------------------------------------------------- |
| `npm run bootstrap` | `npm ci` in all three package roots |
| `npm run check` | Everything CI gates on: server, client, Rust |
| `npm run check:server` | Server only — build variants, vet, race, deadlock, lint, generated-output drift |
| `npm run check:client` | Client only — typecheck, lint, format, unit + integration tests |
| `npm run check:rust` | Tauri backend — `cargo test --lib` and clippy |
| `npm run check:docs` | Fail if a watched document states a finding count the ledger contradicts |
| `npm run format` | Prettier over the client, `gofmt -w` over the server |
| `npm run generate` | Regenerate protocol constants and the sqlc query layer |
| `npm run release:preflight` | `check` plus a client production build |
| `node scripts/run.mjs --list` | Print the exact command every task runs, and where |
Tools CI installs but you may not have — `golangci-lint`, `sqlc` — are skipped
with a printed reason rather than failing the run.
**Working on the server only? You never need Node.** The facade prints each
command it runs and the directory it runs it in; those are the commands in the
next section, and using them directly is equally correct.
#### Server (Go)
| Command | Description |
| --------------------------------------------------------- | ------------------------------------------------------------------------ |
| `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary (Windows) |
| `CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" .` | Build server binary (Linux) |
| `go build -tags otel .` | Build with OpenTelemetry SDK (requires `go get` first — see Phase B) |
| `go build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) |
| `go test ./...` | Run all server tests |
| `go test ./... -cover` | Run server tests with coverage |
| `go test -race ./...` | Run server tests with race detection |
**Make targets** (run from `Server/`):
| Command | Description |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) |
| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) |
| `make cover` | Per-package coverage (what CI uploads) + a function summary |
| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) |
| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` |
| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) |
| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) |
| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `protocol/schema.json` |
| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) |
| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
| `make otel-down` | Stop and remove the OTel dev containers |
#### Client (Tauri v2)
**Build & dev**
| Command | Description |
| --------------------- | ---------------------------------------------------------------- |
| `npm run dev` | Start Vite dev server with hot reload |
| `npm run build` | TypeScript check + Vite production build |
| `npm run tauri dev` | Launch Tauri app in dev mode |
| `npm run tauri build` | Build release installer (NSIS on Windows, AppImage+deb on Linux) |
**Tests**
| Command | Description |
| -------------------------- | -------------------------------------- |
| `npm test` | Run all tests (vitest) |
| `npm run test:unit` | Unit tests only |
| `npm run test:integration` | Integration tests only |
| `npm run test:contract` | Cross-component contract tests only |
| `npm run test:e2e` | Playwright E2E (mocked Tauri) |
| `npm run test:e2e:native` | Playwright E2E (real Tauri exe + CDP) |
| `npm run test:e2e:admin` | Playwright E2E (real Go server + SPA) |
| `npm run test:e2e:prod` | Playwright E2E (prod build) |
| `npm run test:e2e:ui` | Playwright UI mode |
| `npm run test:watch` | Vitest watch mode |
| `npm run test:coverage` | Coverage report |
| `npm run test:mutate` | Stryker mutation testing |
| `npm run test:mutate:dry` | Stryker dry-run (no mutations applied) |
| `npm run test:browser` | Vitest browser-mode tests |
**Type checking, linting & formatting**
| Command | Description |
| ------------------------- | ------------------------------------- |
| `npm run typecheck` | Full typecheck (all sources) |
| `npm run typecheck:build` | Typecheck build config only |
| `npm run lint` | oxlint + ESLint check (src/) |
| `npm run lint:fix` | ESLint auto-fix |
| `npm run lint:ox` | oxlint only (fast correctness checks) |
| `npm run format` | Prettier format (src/ + tests/) |
| `npm run format:check` | Prettier check only (no writes) |
| `npm run knip` | Dead code and unused export detection |
### Git hooks (recommended)
Committed hooks in `.githooks/` catch the most common CI failures locally. Enable once per clone (from the repo root):
```bash
npm run hooks:install # = git config core.hooksPath .githooks
```
| Hook | What it runs |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pre-commit` | gofmt + `go vet` (when Go files staged), oxlint + prettier + `tsc --noEmit` (when client TS staged), `sqlc-verify` / `protocol-verify` (when their inputs staged) |
| `pre-push` | Server build in all build-tag variants, client typecheck + type-aware ESLint. Set `OWNCORD_PREPUSH_TESTS=1` to also run `go test -race ./...` |
Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` when needed — CI still enforces everything.
Neither hook needs `make`, and neither needs Node for the Go checks.
**`core.hooksPath` is exclusive.** Once set, Git resolves every hook against
`.githooks/` and never looks in `.git/hooks/` again. `.githooks/` holds only
`pre-commit` and `pre-push`, so `hooks:install` silently disables any other
hook you installed there (`post-commit`, `post-checkout`, ...). Nothing warns
you. Put it under `.githooks/` instead (untracked, so it stays yours), or skip
`hooks:install` and use `npm run check` before pushing.
## Plugin Development
Plugins are WASM modules loaded at runtime when the server is built with `-tags wazero`.
See `Server/plugin/examples/hello/README.md` for the full plugin ABI and build instructions.
**Toolchain requirements for building `.wasm` plugins with TinyGo:**
| Tool | Version | Notes |
| -------- | ------------ | --------------------------------------------------------------------------------------------------- |
| TinyGo | 0.40.1 | Supports Go 1.191.25 only |
| Go SDK | 1.25.x | Install alongside the system Go via `go install golang.org/dl/go1.25.3@latest && go1.25.3 download` |
| wasm-opt | Binaryen 129 | Required by TinyGo for the `wasi` target; download from Binaryen GitHub releases |
Any WASM toolchain (Rust/`wasm32-wasi`, AssemblyScript, etc.) that exports the five ABI
functions is equally valid — TinyGo is just the example toolchain used by `examples/hello/`.
---
## Branch and PR model
This section is the single source of truth for the branch model. Everywhere
else -- the root `README.md`, `CLAUDE.md`, the PR template -- summarises it and
links here rather than restating it.
- `dev` -- the integration branch. **All contributions target `dev`.**
- `main` -- releases only. `dev` is merged to `main` for a release, and release
tags are cut from `main`.
`dev` is protected and PR-only: direct pushes are rejected, ten status checks
are required, `required_approving_review_count` is 0, and the rule is enforced
on admins. So a PR is self-mergeable once CI is green, but no commit reaches
`dev` without CI having run on it. Settings and rationale live in
[`docs/plans/b0-dev-branch-protection.sh`](plans/b0-dev-branch-protection.sh).
Two consequences worth knowing before you open a PR:
- The Docker and Tauri Full Build jobs are gated on `main` and report as
_skipped_ on a PR into `dev`. That is expected, not a failure.
- Squash merge, and a conventional commit subject on the squashed commit.
## Branch Naming
- `feature/<name>` -- new features
- `fix/<name>` -- bug fixes
- `docs/<name>` -- documentation changes
## Commit Format
Use conventional commits:
```text
feat: add thread support to channels
fix: prevent duplicate WebSocket connections
refactor: extract permission checks into middleware
docs: update quick-start guide
test: add integration tests for invite flow
chore: bump Go dependencies
perf: cache role permissions in memory
ci: add lint step to GitHub Actions
```
For anything non-trivial the body carries the reasoning, not a restatement of
the diff: what was wrong, why the obvious fix is wrong, what was done, concrete
numbers, and a `Verified:` paragraph proving both directions — that the defect
was present before and is absent after.
End with an explicit **`Not included:`** line naming adjacent scope you
deliberately left out, and why. A written deferral is a deliverable: it is what
separates considered-and-declined from silently-missed, and it means adjacent
work you spotted mid-change does not have to become either scope creep or a
blocking question. Put it in the commit that noticed it.
## Pull Request Process
See [Branch and PR model](#branch-and-pr-model) above for what to branch from
and target.
1. Branch from `dev`
2. Open the PR against `dev`
3. All ten required checks must pass -- `dev` is protected, so a red PR cannot
merge
4. Request code review
5. Squash merge, conventional commit subject
## Testing
The client suite enforces **70% coverage thresholds** in `vitest.config.ts`;
the Go suite has deliberately no floor (T-2026-07-25-19) — use `make cover-all`
to see the honest cross-package number. Follow a test-driven workflow and never
lower a threshold to make a change fit.
### Tiers
| Tier | Command | CI job | Blocking |
| -------------------------- | -------------------------- | ------------------------------------------- | -------- |
| `Client/tests/unit` | `npm run test:unit` | Client Unit Tests | yes |
| `Client/tests/integration` | `npm run test:integration` | Client Unit Tests | yes |
| `Client/tests/contract` | `npm run test:contract` | Client Unit Tests | yes |
| `Client/tests/browser` | `npm run test:browser` | — | no |
| `Client/tests/e2e` | `npm run test:e2e` | Client E2E (Playwright) | yes |
| `Client/tests/e2e` @parity | — | Client E2E (parity subset, blocking) | yes |
| `Client/tests/e2e/native` | `npm run test:e2e:native` | — | no |
| `Client/tests/e2e/admin` | `npm run test:e2e:admin` | Admin Panel E2E (real server, non-blocking) | **no** |
| `Server/**/*_test.go` | `make test` | Server Build & Test | yes |
| `Client/src-tauri` | `cargo test --lib` | Rust Unit Tests | yes |
`npm test` — not `npm run test:unit` — is what CI runs and what
`npm run check:client` invokes, so it is the command that covers
`tests/contract`.
### What belongs in `tests/contract`
A test is a **contract test** when its assertions read, import or execute an
artifact owned by a _different top-level component_ (`Server/`, `Client/`, root
`protocol/`) than the one its runner lives in. A comment referencing the other
side does not count.
1. **Placement follows capability, not ownership.** A contract test lives in the
tier whose runtime can execute or parse the artifact. If the owning component
can execute it, it stays in that component's own suite —
`Server/updater/tauri_key_contract_test.go` reads
`Client/src-tauri/tauri.conf.json` and stays in Go, because Go parses JSON
fine and the assertion is about a server constant.
2. **Ownership is declared in the name, never in the directory.** The file name
and the top-level `describe`/`Test` name must name the owned artifact's path.
3. **A contract test may only live in a blocking tier.** A non-blocking job is
not coverage. `Admin Panel E2E` is `continue-on-error: true`
(`.github/workflows/ci.yml`), so it is ineligible however well it fits
topically — until it graduates.
4. `Client/` is one component: its TypeScript frontend and its thin Rust backend
in `src-tauri/` are the same side of the boundary, so a `tests/unit` test that
reads `src-tauri/tauri.conf.json` is an ordinary unit test. The same goes for a
Go test reading its own package's embedded assets
(`Server/admin/perm_grid_test.go`).
E2E is _runtime_ coupling rather than artifact coupling; it stays in `tests/e2e`.
If a tier ever gains a runner of its own, model its anti-vacuity guard on
`Server/invariants/invariants_test.go` — it fails loudly when a configured scope
resolves to nothing, rather than passing on an empty set.
## Code Style
- **TypeScript**: See [Client Architecture](architecture/client.md)
- **Go**: `gofmt` + `golangci-lint`, standard library preferred
- **Rust**: `cargo fmt` + `cargo clippy`, minimal code (native APIs only)
## Dependency Policy
The policy behind what the lockfiles already enforce (decided 2026-08-05,
closing audit findings 2026-04-07 #8 / DC-11):
- **Lockfiles are authoritative.** `package-lock.json`, `go.sum` and
`Cargo.lock` pin every transitive dependency; CI installs only from them
(`npm ci`, module/registry verification — never a bare `npm install` in CI
or hooks). `package.json` keeps ordinary caret ranges: exact-pinning it
would duplicate what the lockfile does while making every security patch a
manual edit.
- **Upgrades arrive as reviewed PRs, not ambient drift.** Dependabot runs
weekly per ecosystem (`.github/dependabot.yml`) with semver-major updates
ignored across the board — majors are adopted deliberately, by a human,
reading the changelog. Peer-coupled groups (`vitest`/`@vitest/*`,
`@stryker-mutator/*`) update as one PR so exact peer pins cannot wedge.
- **Security gates run on every PR:** `npm audit --omit=dev
--audit-level=high` (shipped deps only — dev-tooling advisories are
triaged in the workflow comment instead of blocking on unfixable pins),
`govulncheck` for Go, `cargo audit` for Rust, and `knip` refuses unused
client dependencies outright.
- **Version skew is pinned at the toolchain level** too: `Client/.nvmrc`, every
`actions/setup-node` in CI, and an `engines` block in all three
`package.json` files say Node 24 — with `engine-strict=true` in each
package's `.npmrc`, so a wrong major fails the install instead of warning.
`Server/sqlc.version` pins sqlc, Go pins via `go.mod` (`GOTOOLCHAIN=auto`),
and GitHub Actions are SHA-pinned with Dependabot bumping the pins.
- **Three package roots, not an npm workspace** — measured 2026-08-26 (npm
11.17, Node 26), not decided on principle. Making `/`, `/Client` and
`/tools/mcp-introspect` npm workspaces buys one 298 KB lockfile instead of
three (17 KB / 253 KB / 42 KB) and dedupes 614 resolved packages to 582 — 32
packages, 5.2%. Client install time is unchanged: 5642 ms against 5667 ms.
The things you would expect to break do not: `npm ci` inside `Client/` still
exits 0, `npm run <script>` still resolves the hoisted binaries (npm prepends
every ancestor `node_modules/.bin` to `PATH`), and `engine-strict` still
fails the install on a wrong Node major. The costs that are real:
- Ten CI steps key on `cache-dependency-path: Client/package-lock.json` —
six in `ci.yml`, four in the tag-only, CI-ungated `release.yml`. That file
stops existing, and four of the ten have no gate that would catch it.
- Repository Hygiene installs root-only on purpose (prettier is all it
needs). Under workspaces that grows 970 ms → 6172 ms and 39 → 318
packages, unless every call site gains `--workspaces=false` — the
mitigation works (1112 ms, 38 packages) but has to be remembered forever.
- One lockfile puts all three npm Dependabot groups back into the same file.
They rewrite three disjoint files today; undoing that reinstates the
merge-then-rebase-then-re-run-CI storm the grouping comment at the top of
`.github/dependabot.yml` exists to prevent.
Thirty-two deduped packages does not pay for that. The roots stay separate,
`npm run bootstrap` installs all three, and Dependabot covers all three.