Contribuer à Catnip

Prérequis

  • Python >= 3.12 avec headers (python3-dev / python3-devel)
  • Rust (stable, édition 2021) avec cargo (rustup.rs)
  • uv (gestionnaire de packages Python) (docs.astral.sh/uv)
  • libgmp-dev (requis par la crate rug pour l'arithmétique précision arbitraire)
  • tree-sitter-cli (installé automatiquement par make setup-dev)
# Debian/Ubuntu
sudo apt install python3-dev libgmp-dev

# Fedora/RHEL
sudo dnf install python3-devel gmp-devel

# macOS (Homebrew)
brew install gmp

Optionnel :

  • Node.js + pnpm (pour CodeMirror et l'extension VSCode)
  • fdfind (fd-find) pour le tracking incrémental du makefile

Setup rapide

git clone <repo>
cd catnip

# Setup complet automatique (venv + deps + compile + install)
make setup
source .venv/bin/activate

C'est tout. make setup crée le venv, installe les dépendances, compile l'extension Rust, et installe le package Python.

Vérifier que tout marche :

catnip -c "2 + 2"   # → 4
make test-quick      # Rust units + Python language (~10s)

Setup manuel (étape par étape)

Si make setup ne convient pas, ou pour comprendre chaque étape :

1. Créer le venv

make venv
source .venv/bin/activate

2. Installer les dépendances de dev

make setup-dev

Installe : pytest, ruff, setuptools-rust, tree-sitter-cli, et les autres dépendances de build. Ne compile rien.

3. Compiler l'extension Rust

make compile

C'est l'étape la plus longue (~2 min la première fois). Le résultat est un .so dans catnip/ que Python importe directement.

Pour un build plus rapide en dev (incrémental, sans LTO, codegen-units élevé), profil fastdev :

make dev   # raccourci de CATNIP_DEV=1 make compile

Le rebuild incrémental passe de ~3 min (release) à ~10 s. Les libs natives et les tests suivent le profil de la dernière build (make test après make dev reste en fastdev).

4. Installer le package

make install

Installe le package Python en mode éditable + enregistre le serveur MCP.

Vérifier l'installation

# Extension Rust chargée ?
python -c "import catnip._rs; print('ok')"

# CLI fonctionnelle ?
catnip -c "list(1, 2, 3).[* 2]"

# Tests rapides
make test-quick

Boucle de dev quotidienne

Après modification de code Rust

CATNIP_DEV=1 make compile   # Rebuild incrémental (~10-30s)
make test-quick              # Validation rapide

Après modification de la grammaire Tree-sitter

make grammar-deps            # Régénère parser.c + highlighters
make compile                 # Recompile avec le nouveau parser
make test                    # Tests complets

Après modification de code Python uniquement

Rien à recompiler, le package est installé en mode éditable. Lancer les tests directement :

make test

Commandes de test

Commande Quoi Durée
make test-rust-fast Tests unitaires Rust ~5s
make test-quick Rust units + Python language ~10s
make test Tests Python complets (VM) ~25s
make test-vm Tests Python mode VM (parallèle) ~15s
make test-ast Tests Python mode AST (parallèle) ~15s
make test-cfg Suite VM + AST au niveau 3 (tier CFG+SSA) ~40s
make lint-rust Clippy (-D warnings) sur tous les crates ~30s
make test-all Clippy + Rust + Python VM + AST + docs + ND ~2 min
make test-long Sweeps longs exclus de test-all ~1 min
make check-docs Assertions et exécution des snippets docs/ ~15s
make check-cat Parsing de tous les .cat suivis par git ~5s
make check-nd-parity Accord des trois exécuteurs sur le broadcast ~15s
make bench Mesure comparée des configurations du moteur ~1 min

Règle pratique : test-quick pendant le dev, test-all avant de proposer un changement.

Côté forme, make format-all passe les quatre formateurs — Rust, Python, JS, Markdown — et make format s'arrête aux deux premiers. Le passage Markdown enchaîne dev/doc_format.py, qui normalise les caractères et la typographie, puis mdformat, qui replie à 120 colonnes ; l'ordre compte, la première étape existant en partie pour que la seconde ne coupe pas où il ne faut pas (voir META). Les deux couvrent docs/ et wip/, et l'ensemble est idempotent : un second passage ne doit rien changer. S'il change quelque chose, c'est que les deux étapes se contredisent.

Il existe un troisième mode d'exécuteur, pytest --executor=standalone, qu'aucune cible ne lance. Il remplace Catnip par CatnipStandalone, un adaptateur qui parle au Pipeline Rust sans passer par Registry, Executor ni Context. Il n'est pas maintenu : 226 tests y échouent (relevé du 2026-07-31, contre 180 en juillet), dont ~157 parce qu'ils interrogent une infrastructure que cet adaptateur n'expose pas — ledger de refcount, JIT, debugger. Ne pas le lire comme une régression, et ne pas s'appuyer dessus pour valider un changement.

test-long lance ce qui est marqué #[ignore] pour raison de durée : aujourd'hui le proptest différentiel du tier CFG en version 4096 cas, contre 256 dans test-rust. Les autres #[ignore] du dépôt sont des marqueurs de défauts connus, pas des sweeps, et n'y sont donc pas — une cible qui les lancerait serait rouge par construction.

test-cfg rejoue la même suite au niveau d'optimisation 3 (pytest --optimize=3), qui ajoute le tier inter-blocs CFG+SSA. Elle sert d'oracle différentiel : un test vert ici et dans test-vm/test-ast est un programme dont le tier ne change pas le comportement observable. Un test qui affirme ce qu'est le niveau par défaut porte le marqueur default_optimize et n'est pas repositionné.

Le mode ND process n'est réellement exercé que par catnip_rs/tests/run_nd_process.rs, que lance make test-run. Le chemin Python, lui, ne le mesure pas : sans binaire worker à jour dans le venv — et make compile ne le reconstruit pas, il faut build-bins puis install-bins — le scheduler retombe sur thread et le programme rend le bon résultat sans rien dire. Un test Python qui pose pragma("nd_mode", "process") compare donc, la plupart du temps, un run thread à un run thread. Le témoin est le spawn lui-même, pas la sortie : le test Rust fait pointer CATNIP_WORKER_BIN sur un script qui enregistre son invocation.

Les prérequis de build-bins couvrent les sources de catnip_rs, catnip_tools, catnip_core, catnip_vm, la grammaire générée, et celles propres à chaque binaire. La liste doit rester complète : make décide seul d'appeler cargo, donc une source absente laisse le binaire périmé sans que le rerun-if-changed du build.rs soit consulté.

Les tests unitaires de catnip_vm construisent eux-mêmes les plugins natifs qu'ils chargent — catnip_hello, catnip-io et catnip-sys sont membres du workspace mais dépendances d'aucun crate, donc rien dans un cargo test -p catnip_vm ne les bâtit. Un cargo build par groupe, une fois par processus de test, produit les .so que la suite dlopen. Deux conséquences : le premier run d'un arbre neuf paie cette compilation, les suivants sont des no-op ; et les trois crates doivent rester membres du workspace. Retirer l'un d'eux rend rouges les tests qui le chargent — de 9 à 53 selon le crate — avec package ID specification ... did not match any packages pour seul message, émis par le cargo imbriqué et non par la suite.

check-docs et check-cat couvrent deux choses différentes : le premier exécute les pages de docs/, le second parse les 150 fichiers .cat du dépôt, où qu'ils soient. Un exemple qui vit hors de docs/ n'est vu que par le second — c'est ce trou qui a laissé une démo de coloration syntaxique écrite en syntaxe Python (while x > 0:) dormir dans l'arbre.

check-docs lance deux outils, parce qu'ils ne regardent pas la même chose. check_doc_assertions.py compare : il ne visite que les blocs portant un # → valeur et vérifie le résultat. check_examples.py exécute : il prend les 538 blocs des pages plus les 66 scripts de docs/examples/, et n'exige que l'absence d'erreur. Un snippet sans assertion n'était donc contrôlé par rien, et le second outil ne tournait dans aucune cible — c'est ainsi que les deux défauts de portée et de broadcast ND du 2026-07-29 ont vécu dans des exemples publiés.

Un snippet qui démontre une erreur n'a pas besoin de marqueur : un nom de type d'erreur en commentaire de fin de ligne (return 42 # SyntaxError: 'return' outside function) suffit à déclarer l'attente, et la ligne réussissante devient l'échec. La distinction porte sur la ligne, pas sur le fichier : le même nom dans une ligne entièrement commentée reste de la prose, puisqu'elle décrit du code qui ne s'exécute pas. Les marqueurs explicites (<!-- check: expect-error -->, <!-- check: no-check -->) restent nécessaires quand le bloc lève sans nommer quoi.

Le nom déclaré doit être celui qui sort : un bloc annoté # TypeError qui lève une NameError échoue, alors qu'il passait tant que n'importe quelle erreur satisfaisait l'attente — c'est ainsi qu'un exemple de STRUCTURES.md a longtemps échoué faute de sa déclaration de struct, en annonçant une erreur d'appel. Deux conséquences pour qui écrit un snippet : le nom de la classe Python (CatnipTypeError) et celui du runtime (TypeError) sont acceptés indifféremment, et une erreur que le moteur VM ne nomme pas — il enveloppe les échecs sémantiques dans un RuntimeError nu — est confirmée par le pipeline Python, qui la nomme. Ce repli ne rattrape qu'un défaut de rapport : si le moteur a nommé un type, c'est celui-là qui compte, sans seconde chance auprès d'un autre moteur.

Un snippet qui appelle sys.exit() se traite comme un programme qui sort : statut zéro, le bloc réussit ; statut non nul, c'est une erreur, donc expect-error. Rien à exclure. Le vérificateur évaluant dans son propre processus, ce cas l'a longtemps terminé au lieu de le faire échouer — SystemExit dérive de BaseException, que la clause de garde ne rattrapait pas : code de retour 1, aucune ligne d'échec, décompte final jamais imprimé. La garde est dans eval_code, que les deux vérificateurs partagent.

check-nd-parity couvre un axe que les tests ordinaires ne voient pas : Catnip a trois exécuteurs (PureVM, VM PyO3, AST) et une opération de broadcast qui ne se comporte pas pareil dans les trois est un défaut, quel que soit celui qui a raison. La suite Python tourne en VM puis en AST, mais jamais dans le PureVM ; personne ne comparait les trois. Deux détails font la validité du contrôle : les expressions viennent d'une source unique envoyée telle quelle aux deux harnais (les générer de chaque côté donnerait un accord qui ne prouve rien), et la comparaison est sémantique — deux exécuteurs qui lèvent tous les deux sont d'accord même si le message diffère, puisque le PureVM et CPython ne rédigent pas pareil. Ce qui est signalé, c'est « l'un lève et l'autre rend une valeur », ou deux valeurs différentes.

Les divergences déjà tracées sont répertoriées dans le script (dictionnaire KNOWN) et n'échouent pas ; en retirer une réactive la garde, ce qui est aussi la façon de vérifier que le contrôle mord encore. KNOWN est vide depuis le 2026-07-25 : les trois exécuteurs s'accordent sur les 97 cas, donc toute divergence qui apparaît fait échouer la cible. Y remettre une entrée est une décision, pas un contournement — elle demande une ligne qui dit laquelle et pourquoi.

Un accord des trois ne suffit pas à conclure qu'un cas est correct : ils peuvent converger vers la même erreur (les trois itéraient un bytes là où la spécification en fait un scalaire, et se déclaraient donc d'accord). Le contrôle répond « ils font la même chose », la spécification répond « c'est la bonne ».

make test-all lance lint-rust en premier (avant les tests) : un warning Clippy arrête la suite tout de suite, sans attendre les tests Python. Les crates PyO3 (catnip_rs, catnip_repl) sont lintés avec --features embedded ; extension-module, le feature par défaut, ne se linke pas hors de Python.

Structure du projet

catnip/              # Module Python (API, CLI, context)
catnip_rs/           # Extension Rust principale (PyO3)
  src/
    parser/          # Transformateurs Tree-sitter → IR
    semantic/        # Analyseur sémantique + passes d'optimisation
    core/registry/   # Dispatch des opérations
    vm/              # VM bytecode (NaN-boxing, JIT)
    ir/              # Opcodes IR (source de vérité)
    cfg/             # CFG + SSA
    jit/             # JIT trace-based (Cranelift)
catnip_grammar/      # Grammaire Tree-sitter (grammar.js)
catnip_repl/         # REPL interactive (ratatui)
catnip_tools/        # Formatter + Linter
catnip_lsp/          # Serveur LSP (diagnostics, formatting, rename)
catnip_mcp/          # Serveur MCP utilisateur
tests/               # Tests Python d'intégration
  language/          # Tests comportement langage (parité VM/AST)
  optimization/      # Tests passes d'optimisation
  serial/            # Tests JIT (non parallélisables)

Problèmes courants

make compile échoue avec "can't find crate"

Le workspace Cargo a plusieurs crates. Vérifier que Cargo.lock est à jour :

cargo update
make compile

Import error catnip._rs

L'extension Rust n'est pas compilée ou pas à la bonne place :

make compile
# Si ça persiste :
make reinstall-lang

Tests qui échouent après modification d'opcodes

Les opcodes Python sont générés depuis Rust. Si les deux sont désynchronisés :

make check-opcodes   # Diagnostique le problème
make gen-opcodes     # Régénère les fichiers Python
make compile         # Recompile (gen-opcodes est aussi appelé automatiquement)