286344c254
Pulled from a retrospective on a feature PR that exercised every rule already in the doc, and surfaced eight gaps where the doc was silent. - Protocol-introducing changes (new section): require an explicit cross-client compatibility section, on-relay wire-format verification via nak, and a NIP spec citation when claiming "NIP-X allows this". These three together prevent the "syncs across devices" handwave from masking real interop questions. - Release-minified build subsection in "Build and install both flavours": call out the existing benchmark build type (com.vitorpamplona.amethyst.benchmark, profileable=true, R8-active) as the cheap rig for catching ViewModel-factory / expect-actual / serialization R8-stripping bugs before the reviewer does. - Strip diagnostic Log.d before commit: explicit policy. Lambda Log is required for committed logs; temporary debug Log.d gets removed. - Regression-test-plan worked example: shows the required ### Feature test plan / ### Regression test plan structure with a real-shaped example, so contributors don't ship the wrong subheadings. - Multi-device sync touch-point: added to the regression sweep checklist for features that publish per-account state to relays. - R8-minified-build touch-point: added to the same checklist for reflection-heavy code paths. - "When on-device QA finds bugs in your own diff" subsection in "Code review pass": promote the pattern of landing manual-QA fixes as a separate commit with root-cause prose, not folding silently into the feature commit. - Working-notes convention in "Everything else": clarify docs/plans/ is tracked, docs/brainstorms/ + docs/superpowers/ are gitignored. Saves AI tools from trying to commit scratch work.
401 lines
17 KiB
Markdown
401 lines
17 KiB
Markdown
# Contributing to Amethyst with AI Assistance
|
|
|
|
This document is a companion to [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
|
Everything in `CONTRIBUTING.md` applies to every contribution. This doc
|
|
adds gates specific to pull requests whose diff was substantially
|
|
authored by an AI coding assistant (Claude Code, Copilot, Cursor,
|
|
Codex, etc.). Where this doc and `CONTRIBUTING.md` differ, the
|
|
stricter rule wins.
|
|
|
|
If you are not using an AI assistant, you can stop reading here.
|
|
|
|
- [Research before code](#research-before-code)
|
|
- [Build and install both flavours](#build-and-install-both-flavours)
|
|
- [Protocol-introducing changes](#protocol-introducing-changes)
|
|
- [Performance and resource hygiene](#performance-and-resource-hygiene)
|
|
- [Automated tests for new logic](#automated-tests-for-new-logic)
|
|
- [Regression test plan](#regression-test-plan)
|
|
- [Code review pass before opening the PR](#code-review-pass-before-opening-the-pr)
|
|
- [Don't touch without an issue first](#dont-touch-without-an-issue-first)
|
|
- [Everything else](#everything-else)
|
|
|
|
---
|
|
|
|
## Research before code
|
|
|
|
AI agents are good at writing plausible-looking code for issues that
|
|
no longer make sense. Before you (or your assistant) write a line of
|
|
code, confirm the issue still wants to be implemented.
|
|
|
|
- **Issue still valid.** The issue is open, not superseded by a merged
|
|
PR, not blocked by a NIP change, and not declared out of scope. Old
|
|
bountied issues fail these checks routinely.
|
|
- **Post a research summary on the issue first.** A one-paragraph
|
|
comment stating your read of the problem, the approach you intend
|
|
to take, and the modules you expect to touch. Give maintainers a
|
|
chance to flag it as stale before you invest in a diff.
|
|
- **No duplicate PR.** Search open and recently-closed PRs for the
|
|
same feature. If a prior attempt exists, link to it and explain
|
|
what you do differently.
|
|
- **Fits Amethyst's nature.** The feature must work in a decentralised
|
|
client: no central server, no maintainer-controlled state, no
|
|
required third-party account. If the proposal assumes any of these,
|
|
the feature doesn't belong in Amethyst, regardless of the bounty.
|
|
- **NIPs still current.** If the issue references a specific NIP,
|
|
check it hasn't been deprecated or superseded.
|
|
|
|
## Build and install both flavours
|
|
|
|
Any change that could differ between flavours — UI, services,
|
|
dependencies, `AndroidManifest.xml`, ProGuard rules — must build and
|
|
install on both Play and F-Droid:
|
|
|
|
```bash
|
|
./gradlew installPlayDebug
|
|
./gradlew installFdroidDebug
|
|
```
|
|
|
|
Paste the `BUILD SUCCESSFUL` tail of both into the PR description.
|
|
|
|
**Why both.** F-Droid drops Google-proprietary dependencies (Play
|
|
Services, Firebase, Cast SDK, etc.). Code that compiles only on Play
|
|
is rejected. The canonical recent example is the Chromecast feature:
|
|
the Google Cast SDK is a Play-only dependency, so F-Droid required a
|
|
separate stub implementation under `amethyst/src/fdroid/`. Agents
|
|
routinely add Play-only imports without realising the F-Droid build
|
|
breaks.
|
|
|
|
If a change is conclusively flavour-irrelevant (a pure `quartz/`
|
|
protocol fix, a docs change, a translation), one flavour is enough —
|
|
say which and why in the PR description.
|
|
|
|
### Test the release-minified build, not just `*-debug`
|
|
|
|
Reflection-touched code paths — `ViewModelProvider.Factory`,
|
|
`expect`/`actual` boundaries, serialization, custom Compose runtime
|
|
machinery — silently break under R8 / ProGuard while compiling fine
|
|
in `*-debug` builds. The cheapest way to catch this on this codebase
|
|
is the `benchmark` build type:
|
|
|
|
```bash
|
|
./gradlew :amethyst:installPlayBenchmark
|
|
```
|
|
|
|
That installs `com.vitorpamplona.amethyst.benchmark` ("Amy Benchmark"
|
|
on the home screen) — R8-minified, release-flavoured, `profileable =
|
|
true`, side-by-side with your normal Amethyst install via the
|
|
`.benchmark` applicationId suffix. Sign in to a test npub and exercise
|
|
the new flow there before opening the PR. A feature that crashes or
|
|
silently no-ops in the benchmark build but works in `*-debug` is a
|
|
missing keep rule — file it before reviewers find it.
|
|
|
|
## Protocol-introducing changes
|
|
|
|
If your PR publishes a new event kind, a new tag form, a new marker,
|
|
or a new interpretation of an existing NIP clause, the diff must also
|
|
include:
|
|
|
|
- **Cross-client compatibility section in the PR description.** State
|
|
explicitly: which earlier Amethyst versions honour the new payload
|
|
(forward/backward compat), whether other major clients (Damus,
|
|
Primal, Iris, Coracle, etc.) implement the same NIP clause today,
|
|
and what user-visible behaviour differs for users still on older
|
|
builds or other clients. Don't paper over this with "syncs across
|
|
devices" — say which devices, which clients, and which versions.
|
|
- **Wire-format verification.** Before opening the PR, fetch the
|
|
published event from a relay and confirm its tags, content, and
|
|
encryption envelope match what you intended. `nak req -i <event-id>
|
|
wss://<relay>` is the standard tool. Paste the relevant tag line
|
|
(redact private content) into the PR description. This catches bugs
|
|
that don't show locally — wrong markers, missing relay hints,
|
|
malformed private-tag JSON.
|
|
- **NIP citation.** If you claim "NIP-X allows this", quote the exact
|
|
spec line in the PR. A reviewer should not have to re-derive the
|
|
authority for your tag form.
|
|
|
|
## Performance and resource hygiene
|
|
|
|
Code that looks fine in review can wreck the app at runtime. The
|
|
following are common AI-agent footguns in this codebase. None of them
|
|
trip CI — they only surface in careful manual review or on a real
|
|
device. PRs that introduce any of them will be sent back.
|
|
|
|
### UI thread and recomposition
|
|
|
|
- **No main-thread blocking.** Network, JSON parsing, regex, crypto,
|
|
file I/O, and DB queries belong on `Dispatchers.IO` or
|
|
`Dispatchers.Default`. Never `runBlocking { ... }` from a Composable,
|
|
click handler, or `LaunchedEffect`.
|
|
- **Hoist work out of `@Composable` bodies and `LazyColumn` item
|
|
content.** Parsing, list filtering, building maps, allocating data
|
|
classes all belong in `remember`, `derivedStateOf`, or the
|
|
ViewModel, not in the render path. New lambdas allocated per render
|
|
also defeat `@Stable` and cause unnecessary recomposition of
|
|
children.
|
|
- **Use `collectAsStateWithLifecycle()`** for Flow → Compose, not
|
|
`collectAsState()`, so collection pauses when the screen is
|
|
off-screen.
|
|
|
|
### Coroutines and scoping
|
|
|
|
- **No `GlobalScope.launch` and no ad-hoc `CoroutineScope(Job())`.**
|
|
Use `viewModelScope`, a lifecycle scope, or a passed-in
|
|
`CoroutineScope`, so cancellation propagates on logout, navigation,
|
|
or process death.
|
|
- **Don't put suspend work in `init {}`** of ViewModels. It runs on
|
|
whatever thread constructed the VM and can't be cancelled. Use a
|
|
`MutableStateFlow` + `viewModelScope.launch` pattern.
|
|
|
|
### Memory and caching
|
|
|
|
- **Don't build a parallel cache of Notes, Users, or Events.**
|
|
Amethyst stores them once in `LocalCache` (backed by `LargeCache`),
|
|
keyed by id or pubkey, mutable in place. A new
|
|
`mutableMapOf<HexKey, Note>()` in your feature doubles the working
|
|
set and gets stale.
|
|
- **Don't roll your own image cache.** Coil is wired up with
|
|
size-aware loaders. Decoding a full-resolution image yourself will
|
|
OOM mid-scroll.
|
|
- **Bound your collections.** Unbounded `mutableMapOf` or
|
|
`mutableListOf` that accrue per-event entries are memory leaks. If
|
|
you mean "the last N", use a size-bounded structure.
|
|
|
|
### Relay traffic and mobile network
|
|
|
|
- **Use the existing subscription layer.**
|
|
`ComposeSubscriptionManager`, `Subscribable`, and the filter
|
|
assemblers under `commons/.../relayClient/` are lifecycle-aware,
|
|
deduped, and EOSE-closed. Don't open ad-hoc WebSockets and don't
|
|
issue raw `REQ` filters from a Composable.
|
|
- **Don't re-fetch what's already in `LocalCache`.** Check the cache
|
|
first; only subscribe for what's missing.
|
|
- **Respect data-saver and connectivity context.** Auto-fetching
|
|
full-resolution video on cellular is a regression even if the code
|
|
technically works.
|
|
|
|
### KMP source-set discipline
|
|
|
|
- **Android-only imports don't belong in `commons/commonMain` or
|
|
`quartz/commonMain`.** Use `expect`/`actual` for platform-specific
|
|
bits, or move the Android-specific code to `androidMain`.
|
|
|
|
### Logging
|
|
|
|
- **Use the Quartz lambda Log.**
|
|
`com.vitorpamplona.quartz.utils.Log.d { "msg $x" }` — the lambda
|
|
body only runs when the log level is enabled. Plain
|
|
`Log.d("msg $x")` allocates the formatted string on every call,
|
|
including in feed and scroll hot paths.
|
|
- **Strip diagnostic `Log.d` calls before commit.** Logs added
|
|
during on-device debugging — even lambda-form ones — must be
|
|
removed from the production diff. They survive R8 stripping only
|
|
in debug builds, so committed `Log.d` doesn't directly hurt
|
|
release performance, but it bloats the diff, scatters noise across
|
|
logcat for the next developer, and silently grows over time. If a
|
|
log line is genuinely load-bearing for future
|
|
incident-response, promote it to `Log.i`/`w` with explicit
|
|
justification in the commit message.
|
|
|
|
Relevant skills under `.claude/skills/`: `account-state`,
|
|
`relay-client`, `kotlin-coroutines`, `kotlin-multiplatform`,
|
|
`find-non-lambda-logs`.
|
|
|
|
## Automated tests for new logic
|
|
|
|
For any change beyond pure UI tweaks or docs, add automated tests.
|
|
"Tested manually" alone is not enough; it doesn't survive the next
|
|
refactor, and reviewers can't re-verify it.
|
|
|
|
Minimum bar:
|
|
|
|
- **New logic in `quartz/`** (event types, NIPs, parsing, crypto,
|
|
Bech32) — must have unit tests in the matching
|
|
`commonTest` / `androidTest` / `jvmTest` source set. Quartz is the
|
|
protocol surface; everything new there gets coverage.
|
|
- **New logic in `commons/`** (ViewModels, filters, formatters,
|
|
non-trivial state transitions) — unit tests for the paths a future
|
|
refactor could break.
|
|
- **Bug fixes** — a regression test that fails before your fix and
|
|
passes after. No exception. If the bug is hard to reproduce in a
|
|
unit test, write the test that reproduces it first.
|
|
- **UI-only changes** in `amethyst/` or `desktopApp/` — automated UI
|
|
tests are not required (per `CONTRIBUTING.md` § Tests). The manual
|
|
on-device test plan and screenshots stay required.
|
|
|
|
If your change touches a domain covered by an interop suite (MLS /
|
|
Marmot, NIP-17 DMs, audio rooms, MoQ-lite, QUIC), run the relevant
|
|
suite locally and paste the result. CI does not run them. See
|
|
[`CONTRIBUTING.md` § *Interoperability tests*](CONTRIBUTING.md#interoperability-tests)
|
|
for the suite list and commands.
|
|
|
|
Commands:
|
|
|
|
```bash
|
|
./gradlew test # unit + KMP common tests, all modules
|
|
./gradlew :quartz:test # one module
|
|
./gradlew connectedAndroidTest # Android instrumented (needs device)
|
|
```
|
|
|
|
Tests pass before you open the PR. "CI will catch it" is not a
|
|
substitute — interop and instrumented suites don't run in CI.
|
|
|
|
## Regression test plan
|
|
|
|
The PR template has a **Test plan** section. For AI-authored PRs that
|
|
aren't pure docs or translations, that section must contain *two*
|
|
parts under these exact subheadings:
|
|
|
|
- `### Feature test plan` — what you did to confirm the new thing works.
|
|
- `### Regression test plan` — what you did to confirm the old things
|
|
still work, and that you actually thought about which ones could
|
|
break.
|
|
|
|
Don't add a new top-level section to the PR description — put both
|
|
subheadings inside the existing **Test plan** section.
|
|
|
|
Worked example of the required structure (real shape from a recent
|
|
feature PR; substitute your own touch points and verifications):
|
|
|
|
````markdown
|
|
## Test plan
|
|
|
|
### Feature test plan
|
|
|
|
- Long-press a reply note → quick-action sheet → "Mute thread" →
|
|
thread disappears from Home immediately.
|
|
- Force-stop the app, relaunch → muted thread still hidden (relay
|
|
round-trip verified).
|
|
- Settings → Security & Filters → Muted threads → tap "Unmute" →
|
|
thread reappears in Home.
|
|
|
|
### Regression test plan
|
|
|
|
Touch points and verification:
|
|
|
|
- Existing user-mute — could regress through the shared
|
|
`Account.isAcceptable` chokepoint — verified: muted a different
|
|
user, posts hidden as before.
|
|
- Hidden words — same chokepoint — verified: added a word, posts
|
|
filtered; removed it, posts back.
|
|
- Multi-account switch — could leak mute list across accounts —
|
|
verified: switched between two npubs, each saw only its own mutes.
|
|
- R8-minified build — could fail on new ViewModel factory — verified
|
|
on `installPlayBenchmark`.
|
|
- Both flavours — built and installed `installPlayDebug`; F-Droid
|
|
build is flavour-irrelevant for this change (no Google-proprietary
|
|
touch points), built only.
|
|
````
|
|
|
|
For the regression test plan, list:
|
|
|
|
1. **Touch points** — screens, flows, ViewModels, shared state, or
|
|
modules your change reads from or modifies. List the ones a
|
|
careful reader would expect to be affected, not the entire app.
|
|
2. **Failure mode** — for each touch point, what would actually go
|
|
wrong if your change is buggy. "Feed wouldn't load." "Metadata
|
|
stale across account switch." "OOM on scroll."
|
|
3. **Verification** — what you did to confirm it still works. Same
|
|
`action → observed result` format as the feature test plan.
|
|
|
|
Worked example, for "add a new field to `Account`":
|
|
|
|
- Account creation — could crash on first launch — verified: created
|
|
a fresh npub, app opened home feed.
|
|
- Account switching — could leak state across users — verified:
|
|
switched twice between two npubs, feeds refreshed.
|
|
- Settings export/import — could corrupt restore — verified:
|
|
exported, wiped data, re-imported, no errors.
|
|
|
|
Common touch-point categories worth scanning every PR for:
|
|
|
|
- Account / login / logout / multi-account switch.
|
|
- **Multi-device sync when the feature publishes per-account state to
|
|
relays.** Sign in to the same npub on a second device and verify
|
|
the new state propagates and is honoured there. State explicitly
|
|
which Amethyst versions both sides need to be running for the sync
|
|
to work (current build only, or older builds too).
|
|
- Both flavours (Play and F-Droid).
|
|
- Feed types: home, profile, hashtag, bookmarks, notifications,
|
|
DMs, communities.
|
|
- Orientation changes.
|
|
- Cold start vs warm start.
|
|
- Background → foreground transitions.
|
|
- Release-minified build (`installPlayBenchmark`) for any code path
|
|
that touches reflection — ViewModel factories, expect/actual,
|
|
custom serialization.
|
|
|
|
If a touch point can't reasonably be verified (it would require a
|
|
relay matrix you don't have, or a device combination you can't
|
|
access), state so and explain why you accept the risk. A reviewer
|
|
can tell you to do it anyway, but silent omission is not an option.
|
|
|
|
## Code review pass before opening the PR
|
|
|
|
Before pushing, run a code-review pass with a *different* agent or
|
|
model than the one that wrote the code. AI agents are bad at finding
|
|
their own bugs; switching agent breaks the same-context blind spots
|
|
that produced the initial diff.
|
|
|
|
Options:
|
|
|
|
- Use a dedicated review skill if your harness has one — `/simplify`,
|
|
`/kotlin-review`, `/security-review`, `/code-review`.
|
|
- Spawn a fresh agent from a different model (Sonnet → Opus, Opus →
|
|
GPT-5, Claude → Codex) and have it review the diff.
|
|
- Run any static-analysis pass available (`./gradlew lint`).
|
|
|
|
After the review, **re-run the tests and the manual on-device test
|
|
plan**. Review feedback routinely surfaces bugs the tests didn't
|
|
catch; the fix introduces its own risk; verify the fix didn't
|
|
regress.
|
|
|
|
If the review flags issues, either address them or document in the
|
|
PR description why you accept the risk. Don't silently discard
|
|
review output.
|
|
|
|
### When on-device QA finds bugs in your own diff
|
|
|
|
On-device QA frequently surfaces defects after the initial
|
|
implementation reads as "done". Don't fold those fixes silently
|
|
into the feature commit and force-push — that erases the signal that
|
|
the defect existed and what it was. Instead:
|
|
|
|
- Land each fix as its own commit, or a final squashed
|
|
`"Manual testing fixes"` commit at the tip of the branch.
|
|
- The commit message body names the root cause, not just the
|
|
symptom. Answer the question "why was this missing from the initial
|
|
diff?" — undocumented event-shape variant, collision with a
|
|
recently-merged upstream change, Compose recomposition assumption,
|
|
R8 keep-rule gap, lifecycle race.
|
|
- A reviewer reading your branch sees: feature → code-review cleanup
|
|
→ on-device manual-QA fixes. That's a healthy development arc, not
|
|
a liability.
|
|
|
|
## Don't touch without an issue first
|
|
|
|
Open an issue and get explicit maintainer alignment before opening a
|
|
PR for:
|
|
|
|
- **Signer and KeyStore surface** — `NostrSigner` and its
|
|
implementations (`NostrSignerInternal`, `NostrSignerRemote`,
|
|
`NostrSignerExternal`), anything touching key storage or the
|
|
signing flow.
|
|
- **Release pipeline** — workflow files under `.github/workflows/`,
|
|
signing config, Gradle plugins, packaging.
|
|
- **NIP direction calls** — anything that changes how Amethyst
|
|
interprets a NIP, or invents a new tag or kind, needs upstream NIP
|
|
discussion (and likely a NIP PR) first.
|
|
|
|
These areas have a high cost when an unaligned PR lands — security
|
|
risk, release breakage, protocol fragmentation. Open the issue first
|
|
regardless of whether you call your change a bug fix, refactor, or
|
|
feature; describe what you observed and what you propose. A maintainer
|
|
will tell you to skip the issue gate if the change is genuinely
|
|
trivial.
|
|
|
|
## Everything else
|
|
|
|
For commit format, dev setup, interop tests, PR structure, translation
|
|
flow, and coding standards — see [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
|
and [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
|