loosecanvas / CONTRIBUTING.md
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
|
Raw
History Blame Contribute Delete
9.71 kB

Contributing to loosecanvas

This file is the single-source operational reference for anyone working in this repo β€” human, Claude Code, or Copilot agent. CLAUDE.md and AGENTS.md add agent-specific framing; they do NOT duplicate the content here.

Commands

All from the repo root unless noted. Call the venv by absolute path; do not cd or mutate PATH (frontend builds pollute the shared terminal). If .venv is missing, bootstrap with uv sync (uv.lock is committed β€” reproducible install); the runtime always calls .\.venv\Scripts\python.exe by absolute path.

& .\.venv\Scripts\python.exe -m pytest tests/ -v                      # all tests (PYTHONPATH=src via pyproject)
& .\.venv\Scripts\python.exe -m pytest tests/test_reducer.py          # one file
& .\.venv\Scripts\python.exe -m pytest tests/test_reducer.py::test_x  # one test (use --lf to stay on failures)
& .\.venv\Scripts\ruff.exe check src tests --fix                      # lint + autofix
& .\.venv\Scripts\python.exe -m mypy src                              # type-check (strict)
docker compose up                                                      # llama.cpp server (needs .env) + Prometheus :9090 + Grafana :3000
$env:PYTHONPATH = "src"; & .\.venv\Scripts\python.exe -m uvicorn loosecanvas.main:app --port 8000   # THE APP

Frontend (run from cytoscapecanvas/):

& ..\.venv\Scripts\gradio.exe cc dev   --python-path ..\.venv\Scripts\python.exe   # component-only demo (7860/7861), NOT the app
& ..\.venv\Scripts\gradio.exe cc build --python-path ..\.venv\Scripts\python.exe   # build wheel into backend/.../templates/
npm run typecheck    # tsc --noEmit   (from cytoscapecanvas/frontend/)
npm run lint         # eslint .       (from cytoscapecanvas/frontend/)
npm test             # vitest         (from cytoscapecanvas/frontend/)

Prefer the VS Code tasks (exact ids): fastapi: dev server, frontend: gradio cc dev, frontend: gradio cc build, pytest: run all, ruff: check + fix, docker: llama-server up / down, dev: start all (watch + server).

Two different servers β€” do NOT confuse them

  • The app (graph + chat + LLM turn loop) = uvicorn on :8000. If the user is testing the app, this is the only correct server.
  • gradio cc dev on :7860/7861 is a bare component demo (cytoscapecanvas/demo/app.py) β€” no LLM, no backend. Use it ONLY when hacking on cytoscapecanvas/frontend/*.svelte|*.ts. Stray demo processes pile up; kill them via Get-CimInstance Win32_Process -Filter "Name='python.exe'" β†’ Stop-Process.
  • uvicorn --reload cannot be trusted here: reload is blocked while a Gradio browser tab holds the SSE connection open, so backend edits silently don't apply mid-session (the fastapi: dev server task does pass --reload, but this still bites). To apply backend changes, kill the uvicorn terminal and restart it.

Quality gate = definition of done

Backend: pytest green + ruff clean + mypy --strict clean; coverage floor is 70% enforced via pytest --cov-fail-under=70.

Frontend items additionally: npm run typecheck + npm run lint + npm test + gradio cc build succeeding.

Enforced by pre-commit (.pre-commit-config.yaml): ruff, black, mypy, prettier, eslint, actionlint, gitleaks, bandit, typos.

Pre-commit gotchas:

  • The mypy hook is a repo: local hook running the project .venv (must be active; do not reintroduce mirrors-mypy β€” it resolves pydantic to Any and floods false errors).
  • Black wraps at 88 while ruff allows 120, so inline # noqa/# nosec can get wrapped off its line β€” prefer pyproject per-file-ignores.
  • A module-level attribute docstring (triple-quoted string after a top-level assignment) trips check-docstring-first once black wraps the assignment β€” use a # comment instead.

Conventions

  • Imports use the package root: from loosecanvas.contracts import Node β€” never from contracts import Node (src layout; pythonpath = ["src"]).
  • Pytest asyncio_mode = "auto" β€” async def test_* needs no marker.
  • Ruff line-length 120; selects security (S), naming (N), datetimez (DTZ), no-print (T20), no-commented-code (ERA). yaml.safe_load only.
  • Config is env-driven via .env (see .env.example): LLAMA_CPP_IMAGE/PORT/CTX, GEMMA_MODEL_FILENAME, APP_PORT β€” plus LOOSECANVAS_ALLOWED_BASE (note: .env.example's previous ALLOWED_BASE_DIR= line is drift; the code never reads that name). (TRACE_LOGGING was removed as PoC legacy.)
  • Delegate recon to read-only subagents to keep the main context lean. Parallelize independent module builds only in disjoint-file batches (each agent edits only its module + test, scopes its lint/type gate to its own files); the coordinator runs the full mypy src + pytest reconcile at the end.
  • Long shell captures lie: redirect to logs/x.log and read the file; don't treat an empty capture as "no output".

Tests

41+ flat files in tests/, named test_<module>.py per source module; tests/conftest.py holds shared autouse fixtures (session-store/lock isolation) and shared builder helpers β€” individual files keep additional fixtures inline. The LLM boundary is faked with httpx.MockTransport; the default suite needs no live server. Live-LLM integration tests (test_llm_integration.py, test_turn_loop_integration.py) are module-level-skipped unless LOOSECANVAS_RUN_LLM_INTEGRATION=1 and the server is reachable; the real-repo corpus test reads LOOSECANVAS_TEST_REPO (must resolve under ALLOWED_BASE_DIR). Graph fixtures live in fixtures/ (small_graph.json, annotated_graph.json). For live runs, redirect output to a file under logs/ and read the file β€” don't trust terminal scrollback.

Frontend rules (cytoscapecanvas/, from .github/instructions/frontend.instructions.md)

  • Svelte 5 runes only β€” no legacy export let / $: reactivity.
  • Never .innerHTML (ESLint-enforced); use .textContent.
  • Explicit positions always: every cy.add gets a backend-supplied position: {x, y}; init with preset layout (no reflow).
  • Fog uses events: 'no' (CSS pointer-events doesn't apply to canvas).
  • Patch application is idempotent: dedupe by monotonic patch_id; guard add_element with cy.getElementById(id).length.
  • Structure: Index.svelte is a thin shell; renderer.ts is pure, host-agnostic TypeScript; types.ts defines the TS RendererCommand discriminated union corresponding to the flat M18 Pydantic model (op field) in contracts.py β€” keep them in sync.
  • Component changes reach the app only after gradio cc build (the app imports the editable-installed gradio_cytoscapecanvas wheel).

Pitfalls

  • Gradio cc toolchain on Windows needs PYTHONUTF8=1, the .vscode/bin python3 shim, and explicit --python-path (the VS Code tasks set these). gradio cc create prints an emoji banner that crashes cp1252 after writing files β€” don't re-run on that error.
  • llama.cpp structured output is build-sensitive. Use /v1/chat/completions + the OpenAI-standard response_format wrapper ({"type": "json_schema", "json_schema": {"name": …, "strict": true, "schema": …}}); the bare shape is silently ignored. Disable thinking per request (chat_template_kwargs={"enable_thinking": false}) β€” with thinking on, structured calls return empty content. Never trust HTTP 200: grammar failures fail open, so always model_validate the body and reject on parse failure. Schemas are inlined (no $defs refs) with maxLength on every string field. Full matrix in plan/03-resolved-foundational-decisions.md (Q2). Server flags live in docker-compose.yaml (Q3): -ngl all, --flash-attn on, --jinja (mandatory for tool calling), --reasoning off, --parallel 1. LLAMA_CPP_CTX defaults to 262144 (.env.example) β€” a deliberate long-context trade-off; 65536 is the measured lower-latency fallback on 16 GB VRAM.
  • Long shell captures lie: redirect to logs/x.log and read the file; don't treat an empty capture as "no output".
  • The terminal's decorated multi-line prompt can swallow run_in_terminal output. The interactive PowerShell prompt confuses shell-integration output markers β€” prefer the dedicated tools for inspection.

Security design notes

SEC-08: LLM trust boundary

Repo file content (node labels, summaries, edge labels) flows into LLM context unsanitized by design. Three independent layers prevent exploitation:

  1. Validator existence checks on all IDs (validator.py) β€” the LLM cannot reference nodes/edges that do not exist in the current graph state.
  2. Immutable origin="model_inferred" on all LLM claims (reducer.py, plan/06) β€” model output is permanently marked and never silently elevated to a higher-trust origin.
  3. Strict Pydantic ScenePlan validation (sceneplan_generator.py) β€” structural well-formedness is enforced before any action reaches the reducer.

Residual risk is LLM semantic interpretation (prompt injection in file content), not a validation bypass. Input sanitization is intentionally absent β€” adding it would be false assurance against that residual without fixing it.

EFF-05: Intentional immutability pattern

SceneState uses model_copy(update={"node_positions": dict(scene.node_positions)}) (a shallow copy with a fresh dict) as a deliberate immutability/diff-safety pattern. The two deep=True copies (undo snapshot + restore) are also intentional. These are NOT candidates for a mutable-scene refactor.