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 devon :7860/7861 is a bare component demo (cytoscapecanvas/demo/app.py) β no LLM, no backend. Use it ONLY when hacking oncytoscapecanvas/frontend/*.svelte|*.ts. Stray demo processes pile up; kill them viaGet-CimInstance Win32_Process -Filter "Name='python.exe'"βStop-Process.uvicorn --reloadcannot 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 (thefastapi: dev servertask 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: localhook running the project.venv(must be active; do not reintroducemirrors-mypyβ it resolves pydantic toAnyand floods false errors). - Black wraps at 88 while ruff allows 120, so inline
# noqa/# noseccan get wrapped off its line β prefer pyprojectper-file-ignores. - A module-level attribute docstring (triple-quoted string after a top-level assignment) trips
check-docstring-firstonce black wraps the assignment β use a#comment instead.
Conventions
- Imports use the package root:
from loosecanvas.contracts import Nodeβ neverfrom 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_loadonly. - Config is env-driven via
.env(see .env.example):LLAMA_CPP_IMAGE/PORT/CTX,GEMMA_MODEL_FILENAME,APP_PORTβ plusLOOSECANVAS_ALLOWED_BASE(note:.env.example's previousALLOWED_BASE_DIR=line is drift; the code never reads that name). (TRACE_LOGGINGwas 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+pytestreconcile at the end. - Long shell captures lie: redirect to
logs/x.logand 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.addgets a backend-suppliedposition: {x, y}; init withpresetlayout (no reflow). - Fog uses
events: 'no'(CSSpointer-eventsdoesn't apply to canvas). - Patch application is idempotent: dedupe by monotonic
patch_id; guardadd_elementwithcy.getElementById(id).length. - Structure:
Index.svelteis a thin shell;renderer.tsis pure, host-agnostic TypeScript;types.tsdefines the TSRendererCommanddiscriminated union corresponding to the flat M18 Pydantic model (opfield) incontracts.pyβ keep them in sync. - Component changes reach the app only after
gradio cc build(the app imports the editable-installedgradio_cytoscapecanvaswheel).
Pitfalls
- Gradio
cctoolchain on Windows needsPYTHONUTF8=1, the.vscode/binpython3shim, and explicit--python-path(the VS Code tasks set these).gradio cc createprints 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-standardresponse_formatwrapper ({"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 alwaysmodel_validatethe body and reject on parse failure. Schemas are inlined (no$defsrefs) withmaxLengthon 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_CTXdefaults 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.logand read the file; don't treat an empty capture as "no output". - The terminal's decorated multi-line prompt can swallow
run_in_terminaloutput. 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:
- Validator existence checks on all IDs (
validator.py) β the LLM cannot reference nodes/edges that do not exist in the current graph state. - 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. - Strict Pydantic
ScenePlanvalidation (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.