Development Guide

June 14, 2026 · View on GitHub

Setup, linting, and test commands for working on Rubree locally.

Getting started

  1. Clone the repo and open in VS Code with the Dev Containers extension. Select Reopen in Container — Ruby, Node.js, Rust, and gh are set up automatically.

  2. (Optional) Install Claude Code automatically when the container is created:

    touch .install-claude-code   # gitignored, scoped to this clone
    

    setup.sh detects this marker file and runs the installer when the container builds. Or install manually inside the container:

    curl -fsSL https://claude.ai/install.sh | bash
    

Manual setup (without Dev Container)

bin/setup          # installs gems and npm packages, starts the app
open http://localhost:3000

Optional:

brew install lefthook && lefthook install
brew install gitleaks

GitHub CLI

gh is installed in the Dev Container. SSH key auth handles git push/pull automatically, but gh needs a separate token for GitHub API operations.

Authenticate once after the container is created:

gh auth login

When prompted, follow the device flow below (recommended answers shown):

? Where do you use GitHub? GitHub.com
? What is your preferred protocol for Git operations on this host? SSH
? Upload your SSH public key to your GitHub account? /home/vscode/.ssh/id_ed25519.pub
? Title for your SSH key: GitHub CLI
? How would you like to authenticate GitHub CLI? Login with a web browser

! First copy your one-time code: XXXX-XXXX
Press Enter to open https://github.com/login/device in your browser...
(node:XXXXX) [DEP0169] DeprecationWarning: `url.parse()` ...   ← ignore this
✓ Authentication complete.
- gh config set -h github.com git_protocol ssh
✓ Configured git protocol
! Authentication credentials saved in plain text              ← stored in ~/.config/gh/hosts.yml, normal
✓ SSH key already existed on your GitHub account: /home/vscode/.ssh/id_ed25519.pub
✓ Logged in as <your-username>

Open https://github.com/login/device, enter the one-time code, and the terminal will complete automatically. After authentication, verify with gh auth status.

If you prefer not to use browser auth, set GH_TOKEN to a fine-grained PAT in your host shell profile and add "GH_TOKEN": "${localEnv:GH_TOKEN}" to devcontainer.jsonremoteEnv.

Tool priority for Claude Code:

ToolWhen to use
ghAll GitHub operations when authenticated — gh pr create, gh run list, gh api repos/...
WebFetchPublic GitHub content only (raw files, public pages) when gh is not authenticated

Autonomous overnight operation

Claude Code can run autonomously (write code, run tests, commit) using a .steering/ task plan.

What is protected

ProtectionMechanism
Force-push to main/masterPreToolUse hook → deny
Secrets: .env, master.key, .ssh/** via Read tooldeny in settings.json
SSH private key read via Bash (cat ~/.ssh/id_*)deny in settings.json
Pipe-to-shell downloads (curl | sh, wget | bash)deny in settings.json
Netcat/socat exfiltration (nc, ncat, socat)deny in settings.json
--dangerouslySkipPermissions bypassdisableBypassPermissionsMode: "disable"
Publishing packages, state-changing gh commandsask in settings.json

What is NOT sandboxed

CapabilityNotes
git push to any non-main branchScope via .steering/ plan; add to ask for extra caution
Outbound HTTP/HTTPSnpm install, curl downloads, API calls all work
ssh direct connectionsIn ask — requires approval
npm install <new-package>In ask — approve each new package consciously

Known attack vectors

AttackMitigation
Malicious project hooks (CVE-2025-59536)Audit settings.json before running Claude Code in a cloned repo
InversePrompt — prompt injection via files Claude readsScope tasks with .steering/ plan
Supply chain via npm installnpm install <package> is in ask
API key exfiltrationSet a spending cap on ANTHROPIC_API_KEY
Subcommand limit bypassMirror critical deny rules in ~/.claude/settings.json on the host

CVE numbers from researcher reports (2025–2026). Verify against NVD before treating as authoritative.

User-level settings — project-level settings.json can be overridden by a malicious cloned repo. Mirror critical denies in ~/.claude/settings.json on the host machine:

{
  "permissions": {
    "deny": [
      "Bash(curl * | sh*)",
      "Bash(curl * | bash*)",
      "Bash(nc *)",
      "Bash(ncat *)",
      "Bash(socat *)",
      "Bash(cat ~/.ssh/id_*)"
    ]
  }
}

Browser verification

Use /verify-wasm — it automates the full sequence (WASM build → Vite start → Playwright MCP headless golden-path check). See .claude/commands/verify-wasm.md for details.

If Playwright MCP is not listed in the tools panel, it means the MCP server failed to start. @playwright/mcp is declared as a devDependency in package.json and must be installed first:

yarn install   # installs @playwright/mcp into node_modules

After installing, restart Claude Code. The project-level config in .claude/settings.json (enableAllProjectMcpServers: true) will pick it up automatically on the next session start. If it still does not appear, add it at the user scope as a fallback:

claude mcp add -s user playwright -- npx @playwright/mcp@latest --headless

Then restart Claude Code once more.

If Playwright MCP is not connected in the current session, fall back to the manual steps in Test deployment locally (WASM build) and verify in Chrome yourself, or run the headless script directly:

node -e "
  const { chromium } = require('./node_modules/playwright');
  // ... or use docs/screenshots/golden-path/ as visual reference for expected state
"

Running linters

bin/rubocop                   # Ruby
bin/erb_lint --lint-all       # ERB
bin/yarn biome check          # JS/TS
bin/brakeman --no-pager --skip-files app/assets/builds/,build/,node_modules/,pwa/,rubies/  # security

Auto-fix:

bin/rubocop -a
bin/erb_lint --lint-all -a
bin/yarn biome check --write && bin/yarn biome migrate --write

Running tests

Rubree has two distinct test layers that cover different environments.

Layer 1 — RSpec system specsLayer 2 — WASM E2E (planned)
Commandbin/rspecnode bin/qa-wasm
ServerRails dev server (port 3000)PWA dev server (port 5173)
Ruby runtimeNative processWASM inside the browser
What it coversModels, controllers, views, Stimulus JSRegexp::Parser feature coverage, railroad diagrams, UI states
WASM involved?NoYes — production-equivalent environment
SpeedFast (seconds)Slow (~30 s WASM boot per session)
In CI?ci.yml (every PR)🔲 Planned: deploy.yml post-pack, pre-Pages
Local executionbin/rspecManual / Claude-suggested / Claude-delegated

Both layers should pass before shipping any change that could affect the WASM runtime.

Layer 1 — RSpec system specs (Rails dev server)

bin/rspec   # default: headless Playwright/Chromium
What it testsHow it runs
Ruby logic, controllers, views, Stimulus JSPlaywright drives a Rails dev server on port 3000
WASM is not involvedRuby executes natively as a normal Rails process
Fast (seconds)No WASM build required

Override the driver with DRIVER=<name>:

DRIVER=playwright_chromium          bin/rspec   # Chromium with UI
DRIVER=playwright_chromium_headless bin/rspec   # Chromium headless
DRIVER=playwright_firefox           bin/rspec   # Firefox with UI
DRIVER=playwright_firefox_headless  bin/rspec   # Firefox headless
DRIVER=playwright_webkit            bin/rspec   # WebKit with UI
DRIVER=playwright_webkit_headless   bin/rspec   # WebKit headless
DRIVER=selenium_chrome              bin/rspec   # Selenium Chrome with UI
DRIVER=selenium_chrome_headless     bin/rspec   # Selenium Chrome headless
DRIVER=rack_test                    bin/rspec   # Rack Test (no JS)

Layer 2 — WASM E2E test (PWA dev server)

# 1. Ensure a WASM build exists
bin/rails wasmify:build && bin/rails wasmify:pack   # skip if app.wasm is current

# 2. Start the PWA dev server
(cd pwa && npm run dev) &
sleep 5

# 3. Run the comprehensive Playwright script
node bin/qa-wasm
# Results → /tmp/rubree_test_results.json

bin/qa-wasm is not yet implemented. The script is being developed under .steering/20260607-ux-improvements/comprehensive_test.mjs (gitignored) and will be moved here once stabilised. See .steering/20260607-ux-improvements/tasklist.md Phase 3.

What it testsHow it runs
Full Regexp::Parser feature coverage, railroad diagrams, UI statesPlaywright drives the PWA dev server on port 5173
Ruby executes inside the browser via WASMProduction-equivalent environment
Slow (~30 s boot per session)Requires a built app.wasm

When to run: after wasmify:build, after gem updates, or when adding a new regex feature. For a focused golden-path check, use /verify-wasm instead.

Status: local only. This layer is documented here as a manual step and is not yet integrated into GitHub Actions. CI integration is planned as a post-pack step in deploy.yml so WASM regressions are caught before GitHub Pages deployment. See .steering/20260607-ux-improvements/tasklist.md for the integration plan.

Expected impact on deploy.yml duration once integrated:

Cache warmCache cold
Current deploy~3–4 min~18 min
After Layer 2~5–6 min (+2 min)~23 min (+5 min)

Cold builds are dominated by wasmify:build (~15 min), so Layer 2 is not the bottleneck. The +2 min warm cost covers: root npm install, Playwright Chromium, Vite dev server start, WASM boot (~30 s), and ~60 test cases at ~1 s each.

Token-efficient workflow: run the script yourself; bring only the failing cases to Claude. The JSON results file makes it easy to filter: jq -e '.summary.fails == 0' /tmp/rubree_test_results.json || jq '.results[] | select(.status=="fail")' /tmp/rubree_test_results.json


Test deployment locally (WASM build)

RSpec system specs run against the Rails dev server (port 3000) — they do not test the WASM runtime. Use /verify-wasm for automated headless verification, or follow the manual steps below.

1. Build the WASM artefacts

bin/rails wasmify:build   # ~15 min cold, ~1 min with cache
bin/rails wasmify:pack

Clean build — delete intermediate artefacts first if you need a fully fresh build (e.g. after changing gems or suspecting a stale cache):

rm -rf tmp/wasmify/        # intermediate artefacts: ruby.wasm, app-core.wasm, ruby-core.wasm
rm -f pwa/public/app.wasm  # final packed output
bin/rails wasmify:build
bin/rails wasmify:pack

If either command fails, check docs/wasm-build-notes.md before investigating.

2. Start the PWA dev server

cd pwa && npm run dev   # http://localhost:5173

Vite sets the required Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers automatically (pwa/vite.config.js). Without them the WASM runtime silently fails to boot — do not remove them.

Locally the app is served at /; on GitHub Pages at /rubree/. Controlled by GITHUB_ACTIONS env var in vite.config.js.

3. Open in Chrome and verify

Open http://localhost:5173 in Chrome or Edge (not Safari or Firefox — see README).

  1. Accept the Terms of Service modal
  2. Wait for the WASM boot progress bar to complete
  3. Verify: regex match highlighting, railroad diagram, substitution, Ruby code snippet, permalink

To force a cold re-boot: unregister the Service Worker in DevTools → Application → Service Workers, then hard-refresh.

Sequence diagrams

Boot sequence (first visit)

sequenceDiagram
  actor User
  participant Browser
  participant boot.js
  participant TOS as TOS Modal
  participant SW as Service Worker<br/>(rails.sw.js)
  participant VM as Ruby VM (WASM)

  User->>Browser: open http://localhost:5173
  Browser->>boot.js: load & execute
  boot.js->>User: enable Start button
  note over boot.js: Safari/Firefox detected →<br/>show warning banner, stop here

  User->>boot.js: click Start
  boot.js->>TOS: dispatch rubree:show-tos
  TOS->>User: show Terms of Service modal
  User->>TOS: click Agree
  TOS->>boot.js: dispatch rubree:tos-agreed

  boot.js->>Browser: show starting overlay (progress bar)
  boot.js->>SW: navigator.serviceWorker.register(rails.sw.js)
  SW-->>SW: install → skipWaiting()
  SW-->>SW: activate

  boot.js->>SW: postMessage {type: start-rails} + MessagePort
  SW->>SW: initDB() via @sqlite.org/sqlite-wasm
  SW->>VM: initRailsVM("./app.wasm")
  note over SW,VM: loads app.wasm (tens of MB)<br/>stale-while-revalidate on 2nd visit
  VM-->>SW: progress callbacks
  SW-->>boot.js: message {type: progress}
  boot.js-->>User: update progress bar (1 s ticks, ≤10 s)
  VM->>VM: ActiveRecord::Tasks::DatabaseTasks.prepare_all
  VM-->>SW: VM ready
  SW-->>boot.js: MessagePort reply {type: rails-started}

  boot.js->>User: show "🎉 Welcome to Rubree 🎉"
  boot.js->>Browser: window.location.href = "./"
  Browser->>SW: fetch GET /
  SW->>VM: rackHandler.handle(GET /)
  VM-->>SW: HTML (Rails render)
  SW-->>Browser: response
  Browser->>User: app ready

Request flow (after boot)

sequenceDiagram
  actor User
  participant Form as regexp-form<br/>(Stimulus controller)
  participant Turbo
  participant SW as Service Worker<br/>(rails.sw.js)
  participant Rails as Rails<br/>(Ruby VM / WASM)

  User->>Form: type in pattern or test string
  Form->>Form: debounce 200 ms
  Form->>Turbo: requestSubmit()
  Turbo-->>Form: turbo:submit-start
  Form->>Form: setTimeout(showOverlay, 300 ms)
  note over Form: overlay only appears if response<br/>takes > 300 ms (avoids flicker)

  Turbo->>SW: fetch POST /regular_expressions
  SW->>Rails: rackHandler.handle(request)
  Rails->>Rails: RegularExpression.new(params)
  Rails->>Rails: match spans · railroad diagram<br/>substitution · ruby code
  Rails-->>SW: HTML partial
  SW-->>Turbo: response

  Turbo->>Browser: render into turbo-frame#regexp
  Turbo-->>Form: turbo:frame-render (target: regexp)
  Form->>Form: clearTimeout → hide overlay
  Browser->>User: updated results panel

References