0.1.2 (2026-07-15)

L'indexation N-dimensionnelle arr[y, x] est parsée : plusieurs index séparés par des virgules dans une même paire de crochets forment une clé tuple (obj[a, b] == obj[(a, b)] == __getitem__((a, b))), en lecture comme en écriture, chaque position acceptant une expression ou un slice (arr[1, 0:2]). C'est la forme attendue par numpy et pandas ; auparavant seul le premier index était retenu, silencieusement. Le formatter préserve désormais tous les index (il n'en gardait qu'un, une perte de code) et normalise l'espace après chaque virgule.

Les opérateurs bit-à-bit délèguent aux objets Python (mode VM) : &, |, ^ et ~ sur un tableau numpy s'appliquent maintenant élément par élément (a & 254, a | b), au lieu de lever TypeError. Ils rejoignent les opérateurs arithmétiques, qui retombaient déjà sur le protocole de l'opérande quand ce n'est pas un entier natif ; le mode AST le faisait déjà, seul le VM manquait ce repli.

Les écritures de closure sont correctes sur toutes les surfaces de compilation : le compilateur de secours (littéraux Decimal, debugger, API Compiler) enregistrait un slot local pour une variable englobante écrite depuis une closure -- les lectures suivantes visaient un slot jamais rempli (résultat None si l'écriture conditionnelle ne s'exécutait pas, instantané périmé si le binding mutait entre-temps). Le chemin nominal n'était pas touché ; les surfaces résiduelles compilent désormais le même bytecode que le compilateur de production.

cached() fonctionne sous le binaire standalone : le host cherchait depuis toujours une fonction standalone_cached qui n'existait pas -- l'injection échouait en silence et cached() levait NameError. La fonction existe maintenant, adossée à une instance Memoization Rust par process.

Le JIT ne ment plus quand un type change (mode VM) : une fonction appelée d'abord avec un int puis avec un float (ou l'inverse) pouvait retourner un résultat faux sans erreur -- le code compilé déboîte chaque slot avec le type observé au moment du trace et ne revérifie jamais ; l'entrée de boucle for laissait passer le mauvais type (0 au lieu de 750.0), et le chemin warm-start recompilait par-dessus un symbole existant, échec silencieux qui appariait des guards frais à du code périmé. L'entrée JIT valide désormais le contrat de type slot par slot, les guards ne sont écrits qu'après une compilation réussie, et un offset compilé ne se ré-arme plus. Au passage, les boucles while sur floats exécutent enfin leurs traces compilées : de 2,5× plus lent avec JIT à 8,7× plus rapide.

Le chemin ND process ne fuit plus ses résultats (mode VM) : les boucles de collecte du worker natif relâchent désormais la Value thawée après son clonage dans la liste résultat -- chaque élément str/list/struct de batch strandait un handle ou un slot registry (mesuré : +1 par élément, delta zéro après fix). Même discipline appliquée aux opérandes des formes combinator ~~/~> et aux écrasements de globals struct/BigInt à travers une closure. Un worker qui meurt en plein batch fait maintenant échouer le batch entier (repli thread) au lieu de désordonner silencieusement les résultats puis de bloquer la collecte. La profondeur ND du pipeline PyO3 passe de 200 à 300 (marge conservée sous le point de débordement mesuré de la stack C ; la VM pure reste à 10 000, sa profondeur vivant sur le heap).

Le pragma jit agit en mode AST et sous le debugger : l'initialisation JIT importait un module disparu et, sur échec, désactivait silencieusement le flag que le pragma venait de poser. L'import fantôme est supprimé ; le flag survit et le chemin VM du debugger active le JIT comme documenté.

Le mode ND process parallélise vraiment, structs compris (mode VM) : pragma("nd_mode", ND.process) distribue désormais un broadcast/ND sur un pool de workers Rust natifs (catnip worker, IPC postcard) au lieu d'être rabattu silencieusement sur les threads. Les instances de struct plates (frontière v1 : pas d'extends/implements/abstract, champs freezables) voyagent avec leur définition de type (FrozenStructType), que le worker reconstruit par nom -- field access, appel de méthode, construction et match s'exécutent nativement. Sur un workload CPU-bound (64 structs), le mode process passe à ~5,5× le mode thread (que le GIL sérialise). Ce qui n'est pas freezable (callback Python, struct hors frontière v1, grand entier en champ, global non freezable référencé par la lambda, builtin redéfini par une fonction) retombe automatiquement sur le mode thread -- jamais de pickle, jamais de divergence silencieuse entre modes.

Un ledger de refcount permanent monte la garde (mode VM) : les classes heap gérées à la main (handles pyobj, BigInt, Complex, instances de struct) exposent des compteurs d'allocations vivantes, et une grille de tests vérifie que chaque frontière rend chaque référence — delta exactement nul, par classe, sans témoin à connaître d'avance, sur un cycle de session complet ET sur des exécutions répétées du même pipeline (la seule frontière où une fuite de compte registry est visible : le registry meurt avec le pipeline). À ses deux premières exécutions, la grille a exposé et fait fermer cinq fuites que huit reviews n'avaient pas vues : la frame top-level ne rendait pas les références de ses slots non-pyobj à la sortie (x = 10**25 au niveau module = une allocation abandonnée par exécution), le résultat d'une exécution n'était relâché que s'il était pyobj (execute_quiet ne relâchait rien, et un résultat struct nu épinglait un compte registry par exécution), la conversion des constantes compilées internait un handle par sous-objet de chaque constante composite (struct P { a } : quatre par session), et la réinjection des globals à chaque exécution écrasait l'entrée précédente avec un release aveugle aux structs (p = P(1) ré-exécuté = un compte registry orphelin par run).

Un proptest différentiel patrouille les trois exécuteurs : un générateur de programmes Catnip aléatoires mais valides par construction (graine déterministe, zéro dépendance) exécute chaque programme sur les quatre oracles — équilibre du ledger en fin de session et en exécutions répétées, mode VM et mode AST, plus convergence des résultats entre les deux — et réduit tout échec à un programme minimal avant de le rapporter. Ses trente premiers programmes ont exposé deux dernières fuites d'instances (le host éphémère des callbacks broadcast rendait ses globals sans savoir relâcher un struct ; la copie privée d'un résultat broadcast pass-through gardait sa référence de création) ; les trois cents suivants sont passés sans un écart.

L'arithmétique en grands entiers ne fuit plus ses intermédiaires (mode VM) : chaque opération chaînée sur un BigInt ou un Complex ((b + 1) + 2 : le résultat de b + 1 est consommé par l'addition suivante) abandonnait l'allocation entière de l'intermédiaire — 49 MB perdus sur 200 000 itérations d'une boucle de trois opérations, mesuré. Tout le bloc arithmétique (binaires, unaires, comparaisons) relâche désormais ses opérandes sur chaque chemin, erreurs comprises. Même règle pour les collections : un grand entier placé dans une liste, un tuple, un dict (littéral ou d[k] = v) ou une struct rangée dans une collection abandonnait sa référence — de 14 à 49 MB sur 200 000 itérations selon la forme. La même passe a fermé les opérandes abandonnés de typeof(), des interpolations f-string, de and/or, de l'écriture d'attribut sur un objet Python, du broadcast, de la création de closure, et les fuites des chemins d'erreur des comparaisons (un __eq__ Python qui lève ne coûte plus deux références).

Les chemins d'erreur des opérateurs bitwise, de l'accès attribut et de l'affectation de champ ne fuient plus (mode VM) : la review adverse de l'audit ci-dessus a fermé ses derniers angles morts, tous mesurés au témoin. Une opération bitwise sur un opérande non supporté (obj | 1, ~obj, une tentative d'union de sets) abandonnait ses opérandes en levant ; un accès à un attribut inexistant (obj.missing) abandonnait le receveur ; l'affectation d'un champ de struct inconnu (p.nofield = v) abandonnait la valeur ; +struct sans op_pos abandonnait son opérande. Attrapées dans une boucle try/except, ces erreurs coûtaient une référence par itération — jusqu'à 19 MB sur 200 000 tours pour la dernière.

L'assignation destructurante ne fuit plus son sujet (mode VM) : (a, b) = xs, les patterns star/nested et le tuple-unpacking des boucles for abandonnaient une référence du sujet à chaque exécution — dans une boucle, une par itération. Les items couverts par un star ou consommés par un sous-pattern composé fuyaient aussi. Selon la provenance de l'objet, la référence abandonnée le retenait indéfiniment ou — pour un objet créé dans la session — laissait le runtime pointer un objet que le ramasse-miettes avait légitimement collecté. Les trois opcodes d'unpacking relâchent désormais leur sujet sur tous les chemins, erreurs comprises.

Le registre de structs ne peut plus s'aliaser lui-même pendant une cascade (mode VM) : libérer une instance de struct relâche ses champs ; un champ Python peut, en mourant, faire mourir un proxy d'une autre instance du même registre, ce qui rouvrait le registre en pleine libération. Tant que le registre se mutait par référence exclusive, ce second accès chevauchait le premier — un aliasing de deux références mutables, comportement indéfini. Les slots d'instances passent désormais en mutabilité intérieure : tout le chemin de libération emprunte le registre en partagé, et la réentrance ne prend qu'un emprunt de plus au lieu d'un second exclusif. Le verrou interne n'est tenu que par fenêtres courtes qui ne se chevauchent jamais, si bien qu'une violation future se signale par une panique nette plutôt que par une corruption muette.

Écraser un struct global via un module importé ne le retient plus (mode VM) : une extension ou un import qui réassigne un nom déjà lié à une instance de struct (context.globals réécrit) épinglait l'ancienne instance à chaque écriture — son compteur montait sans jamais redescendre. Le proxy de globals des chemins d'import/extension connaît maintenant le registre nourricier et relâche l'instance délogée pour de bon. Reste le chemin des workers de broadcast, plus étroit encore (leur registre naît après leur host), documenté comme tel.

Un match ne fuit plus ses items ni ses captures partielles (mode VM) : un élément heap d'un pattern tuple non capturé par un binding ((_, y), couverture star) fuyait une référence par match réussi ; un pattern composé qui échouait à mi-course fuyait les captures déjà liées. Le matcher emprunte désormais son sujet — chaque binding prend sa propre référence — et l'opcode relâche sujet, items et captures abandonnées sur chaque chemin, y compris en erreur.

La frontière d'exécution Python a une sémantique unique : un callback Catnip invoqué depuis du code Python arbitraire (fonction du contexte, HOF) reçoit des copies privées de ses arguments struct, dans les deux modes — le mode AST les partageait (une mutation dans le callback fuyait vers l'appelant Catnip à travers le détour Python, là où le mode VM isolait). Règle générale, extension de la sémantique broadcast : rester en Catnip = référence partagée ; traverser Python = copie privée. Les appels internes (dispatch, init, tail calls) sont marqués et gardent le partage.

Une méthode qui mute self fonctionne en mode AST : p.m() avec m(self) => { self.log = 1 } ne modifiait pas p — la méthode était liée à un clone du proxy, et self était une copie détachée dont les mutations mouraient avec l'appel, silencieusement (alias, indirections et self retourné compris ; seul init persistait, par un chemin distinct). Le mode VM n'était pas affecté. Le bound method référence désormais l'instance receveuse elle-même : une seule identité de self, dans les deux modes.

Un pattern d'enum hors scope est rejeté partout : la VM pure (MCP/standalone) matchait Color.red par nom interné même quand Color n'était pas visible dans le scope — le runtime Python le rejette (CatnipNameError) depuis la décision d'uniformisation. Le serveur MCP applique désormais le même contrat, et un pattern composé qui échoue à mi-course ne retient plus les captures déjà liées.

Les arguments nommés fonctionnent au constructeur de struct dans la VM pure (MCP/standalone) : S(a=1) levait TypeError: CallKw: cannot call non-function — le chemin d'appel à mots-clés ne connaissait pas les types struct. Sémantique identique au runtime Python : positionnels d'abord, mots-clés par nom, défauts pour le reste ; nom inconnu, doublon positionnel/nommé et champ requis manquant sont des erreurs.

La mutation d'un élément struct dans un callback broadcast a désormais une sémantique unique : le callback reçoit une copie privée profonde de l'élément — muter la copie, à n'importe quelle profondeur (y compris un struct imbriqué), ne modifie jamais la collection source, et retourner la copie mutée la place dans le résultat (items = items.[(p) => { p.log = p.log + 1\np }]). Les trois exécuteurs convergent : le mode AST et la VM pure mutaient l'instance partagée en place (la mutation « fuyait » vers la source), et le mode VM perdait la mutation d'un élément retourné (la copie du callback n'était jamais transplantée quand son slot heurtait l'original — le pattern de transformation rendait silencieusement les objets d'origine). Un champ non-struct (liste, dict) reste partagé par référence. Même règle de visibilité que la capture de closure : copie privée à la frontière d'exécution. Détail : docs/lang/BROADCAST_SPEC.md, décision 4.

Le broadcast d'une collection de structs par un callback est passé de O(N²) à O(N) (mode VM) : mapper une liste de N structs par un callback Catnip (items.[f]) relançait une VM enfant et clonait le registry de structs entier à chaque élément — 2,7 s pour 3000 structs. Un fast-path in-VM invoque le callback sur la VM courante (pas de VM fraîche, pas de clone de registry), récursif dans les listes et tuples imbriqués. ~700× à N=3000, scaling linéaire jusqu'à 16k. Retombe sur l'ancien chemin pour un filtre, un opérande, un callback non-Catnip, ou pendant l'enregistrement d'une trace JIT.

Un appel qui échoue ne fuit plus ses arguments (mode VM) : entre le moment où un opcode d'appel dépile ses arguments et celui où il les consomme, toute sortie d'erreur les abandonnait — arité incorrecte, argument nommé inconnu ou en double, champ typé rejeté, struct abstrait, méthode inexistante : une référence par argument heap par appel raté. Chaque sortie d'erreur relâche désormais ce qu'elle a en main, y compris l'instance à moitié construite d'un init qui échoue et la frame déjà liée d'un appel abandonné. Le cache de mémoïsation ND fuyait, lui, même sans erreur : un cache-hit renvoyait le résultat mémorisé sans jamais relâcher les arguments de l'appel évité.

Passer un argument à une fonction Python ne fuit plus (mode VM) : tout argument heap (objet Python, grand entier, struct) passé à un callable Python — builtin, fonction du contexte, méthode d'objet, constructeur résolu dynamiquement — laissait une référence orpheline par appel : l'opcode convertissait ses arguments pour l'appel mais ne relâchait jamais les valeurs qu'il avait poppées. Sur un hôte long-vie, chaque str(x) comptait. Tous les sites de dispatch relâchent désormais leurs arguments après conversion (appels directs, en position tail, avec kwargs, méthodes — receveur compris quand il n'est pas passé en self), y compris les arguments surnuméraires (acceptés-et-ignorés, ou captés par un vararg) qui étaient abandonnés au binding. La fuite se voyait surtout en broadcast : dans un callback, un constructeur de struct se résout comme callable Python, donc chaque élément produit fuyait ses arguments.

Les valeurs par défaut des champs de struct ne fuient plus (mode VM et VM pure) : le type possède une référence par
défaut évalué à sa définition (struct S { b = obj }), qu'aucun teardown ne relâchait — une référence par type par
session, redéfinitions comprises. Le registry les relâche désormais à sa destruction, et un champ hérité via extends
prend sa propre référence à la copie (le partage silencieux d'une seule référence entre parent et enfants aurait fait de
chaque héritage un double-release en puissance). Au passage, un argument nommé visant un champ déjà rempli par un
positionnel lève désormais TypeError: got multiple values en mode VM comme en mode AST (l'écrasement silencieux
perdait une référence et divergeait de Python). La VM pure (MCP/standalone) avait la même fuite sur trois faces de plus
les défauts des champs de trait (le registry des traits ne relâchait rien non plus, et chaque struct implémentant recevait une copie non comptée), le transplant d'import (le type cloné du module vers l'hôte partageait les références de l'original), et un argument nommé qui déloge un positionnel dans un appel de fonction (la référence délogée était écrasée sans release). Sur un serveur MCP qui réimporte à chaque évaluation, chaque module à défaut de champ comptait.

Importer un module ne fuit plus (VM pure, MCP/standalone) : chaque import retenait au-delà du reset() une référence par valeur heap exportée (instance de struct, closure, liste) — le namespace gardait les globals du module vivants pour les closures exportées mais aucun teardown ne les relâchait, et la valeur du dernier statement du module était abandonnée sans release. Sur un hôte long-vie qui réimporte à chaque évaluation, l'accumulation était linéaire. Le namespace draine désormais les globals du module à sa destruction, et tous les appelants du pipeline (loader, serveur MCP) relâchent le résultat d'execute. Un troisième mécanisme, du même symptôme : un groupe de closures mutuellement récursives exporté (letrec) forme un cycle de références fortes que seul le drain de réinitialisation du VM casse ; ce drain est désactivé pour le VM enfant du module, donc le cycle épinglait le groupe une fois les globals drainés — ses handles de cassage sont désormais transférés au VM parent, qui le réclame à son propre reset. Enfin, les imports sélectifs et wild (import('m', 'x'), import('m', wild=true)) injectent des noms au lieu de rendre le namespace : ils relâchent désormais la référence qu'ils en extraient, sans quoi le namespace n'était jamais détruit — donc son drain jamais déclenché — et ces deux modes fuyaient l'intégralité du module (le cache le maintient vivant jusqu'au reset pour les closures injectées). Symétriquement, un import qui échoue — module qui lève, nom sélectif absent, META.exports de mauvais type — draine désormais les globals du module avorté au lieu de les abandonner sur le chemin d'erreur (et un groupe de closures sœurs défini avant l'erreur voit aussi son cycle cassé). Enfin, les pipelines à usage unique du serveur de debug MCP (debug_start, debug_eval) vident leurs globals avant destruction, comme les pipelines d'évaluation.

Une instance de struct stockée dans un global ne fuit plus (mode VM) : p = P(x) au top-level retenait le slot de l'instance — et une référence de chaque champ — au-delà de la fin de session, et chaque réassignation (p = P(y)) abandonnait un slot de plus sur un hôte long-vie (REPL, serveur MCP). Deux mécanismes fermés : l'entrée de la map host délogée par une réassignation ou un del est désormais relâchée avec le registry des structs en main (le release générique ne sait pas libérer un slot d'instance), et le registry libère à sa destruction les champs des slots encore vivants au lieu de les abandonner.

Le broadcast ne vole plus les références du parent (mode VM) : chaque callback broadcast s'exécute dans une VM enfant qui copie les instances du parent, mais les compteurs globaux des champs restaient partagés entre les deux copies — muter un champ côté enfant (p.log = 1) relâchait la référence du parent, dont le champ pouvait pointer dans le vide. L'enfant prend désormais sa propre référence par champ copié et la rend à sa mort : les mutations enfant consomment leur part, jamais celle du parent, et la libération terminale des slots survivants s'applique du coup aussi aux sessions broadcast (elles gardaient l'ancienne fuite de fin de session). Au passage, chaque invocation de callback retenait une référence de plus sur son résultat ; elle est relâchée après conversion.

Une closure exportée dans un conteneur ou un champ de struct résout le bon template : un module qui exporte handlers = [f] (ou r = R(f)) où f bake une lambda imbriquée rendait un résultat faux en mode VM — l'index enfant de la closure, non décalé au transplant du module, collisionnait avec un slot parent (m.handlers[0](5) rendait 5 au lieu de 6). Le walk de transplant descend désormais dans les listes/tuples/dicts et les champs de struct (proxy Python et instance native), décalant chaque index VMFunc en place. Le VM pur (MCP/standalone) couvre le même cas en reconstruisant l'instance de struct exportée, et remappe au passage son type : une instance importée dans une session qui définissait déjà ses propres types struct résolvait silencieusement le mauvais type.

Le runtime pur appelle une closure stockée dans un champ de struct : r.h(5), où h est un champ tenant une lambda et non une méthode, échouait avec « pas de méthode h » dans le VM pur — même sur une instance locale, il ne cherchait le nom que parmi les méthodes et les méthodes statiques. Il résout désormais un champ appelable en premier (précédence champ > méthode > statique), comme le runtime PyO3 en mode VM et AST : le champ est appelé sans liaison de self, un constructeur de struct rangé dans un champ construit, un builtin s'exécute.

Un pattern d'enum exige que son type soit dans le scope : match x { Color.Red => … }, quand Color n'était pas importé, matchait quand même en VM (résolution par nom qualifié interné, indépendante du scope) mais tombait sur le wildcard en AST — une divergence silencieuse entre exécuteurs. Les deux lèvent désormais CatnipNameError : un pattern qualifié résout son type comme n'importe quelle référence (il faut l'aliaser, Color = m.Color, ou l'importer en wild), sinon c'est une erreur de programme, pas un non-match.

Les payloads de variantes union sont typés : un champ de payload annoté (union U { A(x: int) }) portait son type jusqu'aux exécuteurs mais ne le vérifiait jamais -- U.A(1.5) construisait U.A(x=1.5) sans broncher, là où struct P { x: int } refuse P(1.5). Le contrat est désormais appliqué comme pour un champ de struct, sur l'analyse statique (E300) et les trois exécuteurs (VM PyO3, VM pur, AST) : mismatch prouvable → erreur de compilation, valeur non prouvable → TypeError à la construction, tour numérique int → float coercée. Un champ dont le type est un paramètre générique (Some(value: T)) reste, lui, vérifié au franchissement d'une frontière, pas à la construction.

catnip format ne perd plus les annotations de type : le pretty-printer reconstruit le code depuis l'arbre syntaxique, mais quatre convertisseurs de déclaration ignoraient le champ de type — catnip format sur (cb: (int) -> int) => … rendait (cb) => …, effaçant silencieusement le contrat. Les cinq positions typées (paramètre, retour de lambda, défaut typé, champ de struct, retour de méthode) sont désormais rendues, et l'espacement interne d'un type est canonisé ((int,int)->bool(int, int) -> bool) sans casser l'idempotence du reformatage.

Le linter --deep ne crie plus au loup sur du code idiomatique : trois familles de faux positifs sont supprimées. Le nom d'un argument nommé (dict(clé=valeur)) était compté comme une lecture de variable non initialisée (W310) ; une affectation write-through dans une boucle ou un match (acc = True) passait pour une variable locale qui masque puis meurt (W204 + W200) ; une cible d'affectation dans un with ou un bloc-expression était lue au lieu d'être définie (W310). Les vrais positifs — paramètre masquant, variable de boucle inutilisée, écriture d'un global capturé sans relecture — sont conservés.

catnip lint --check-names ne rejette plus les types nominaux, la récursion nommée ni les valeurs de pragma : trois familles de faux positifs E200 (« Name 'x' is not defined », opt-in) sont supprimées. Le corps des union et enum était traversé — variantes, self, noms de méthodes étaient tous comptés comme indéfinis, alors que les struct et trait vivaient déjà dans leur propre namespace ; une fonction récursive nommée (f = (n) => { f(n - 1) }) ne se voyait pas dans son propre corps, le nom étant lié après l'analyse du corps ; et l'attribut d'un pragma qualifié (ND.process) était résolu comme une variable libre. Désormais union/enum sont traités comme struct/trait, le nom d'une fonction est lié avant que son corps ne soit analysé, et seul le namespace d'un pragma qualifié reste une référence. Les vrais noms indéfinis restent signalés.

Les slots de fonction runtime sont réclamés à l'exécution dans le runtime pur : la table de fonctions de catnip_vm était grow-only — chaque closure et chaque méthode liée y ajoutaient un slot jamais libéré avant le reset(), si bien qu'une boucle liant des méthodes (while i < N { m = P(i).get }) retenait N receveurs jusqu'à la fin de la requête. Les slots runtime sont désormais des valeurs à compte de références (Arc, tag NaN-box dédié), au même titre que les instances de struct : un slot se libère dès que sa dernière référence meurt, receveur et captures relâchés en cascade. Les définitions de fonctions permanentes (templates, indices bakés dans le bytecode) restent index-based. La self-référence d'une fonction récursive est un handle faible pour ne pas former de cycle, et la frame appelante garde une référence forte à sa propre fonction le temps du corps — une closure récursive sans liaison survivante (mk()(5)) retrouve donc bien son nom.

locals()/globals() ne corrompent plus le tas dans le runtime pur : ces intrinsèques matérialisent le scope en dictionnaire, qui possède ses entrées et les décrémente à sa destruction — mais le dictionnaire était construit sans prendre de référence. Une valeur heap (instance de struct, bigint, liste) surfacée par locals() était donc décrémentée deux fois, une fois par le dictionnaire et une fois par le teardown de la frame ou de la closure : double libération, corruption du tas. Le bug dormait depuis toujours (les scalaires ne sont pas comptés, et aucun test n'appelait locals() sur une valeur lourde) ; il a fait surface quand le drain des captures de closure ci-dessous a activé le versant capture. Chaque valeur est désormais clonée à l'insertion dans le dictionnaire.

Le broadcast et les opérateurs ND ne double-libèrent plus leur élément dans le runtime pur : dans catnip_vm, un opérateur qui ne renvoie pas son élément (xs.[(x) => { 99 }], ~>(xs, f)) corrompait le tas. L'élément, emprunté à la liste cible, était passé tel quel à la fonction, qui le déplace dans ses locals et le relâche à son retour — pendant que le nettoyage de la boucle le décrémentait une seconde fois. Un second défaut se cachait derrière le premier : l'opcode de broadcast ne relâchait jamais sa liste cible, une fuite que le double-décrément compensait par accident — l'élément mourait avant qu'elle ne devienne visible. Corriger l'un sans l'autre échange un double-free contre une fuite ; il fallait les deux. La fonction reçoit désormais une référence clonée (l'appelant garde la sienne) et chaque opcode relâche les valeurs qu'il a dépilées. Les opérateurs ~~ n'étaient pas touchés : la récursion ND emprunte sa graine au lieu de la consommer. catnip_rs (runtime PyO3) gérait déjà le cas via RAII.

Les closures relâchent leurs captures dans le runtime pur : dans catnip_vm, une fonction qui capture une variable lourde — un receveur lié, une instance de struct, un bigint — ne la relâchait jamais. La map capturée possédait bien une référence par entrée, mais son scope n'avait pas de destructeur : à chaque reset() (une fois par requête du serveur MCP) la table de fonctions était détruite sans que les captures ne soient décrémentées, si bien que chaque évaluation définissant une closure fuyait ses captures pour la vie du pipeline. Le scope de closure draine désormais ses captures à sa destruction, et l'écrasement d'une variable capturée relâche l'entrée remplacée — parité avec le runtime PyO3, qui faisait déjà les deux. Le compteur d'instances vivantes revient à sa base après un reset(), capture ou pas.

Une méthode liée sans être appelée retrouve son receveur : m = p.get puis m() rendait self = None dans le runtime pur. Le receveur était rangé dans la closure de la méthode, que le corps — qui lit self comme premier paramètre — ne consulte jamais. Il est désormais curryfié : porté par le slot de fonction et préfixé aux arguments à l'appel, exactement comme l'appel direct p.get() préfixe déjà le receveur. Le receveur est lié quelle que soit la forme d'appel — positionnel, par mot-clé (m(x=1)), via un ordre supérieur (map(m, …)) ou comme opérateur de broadcast (xs.[m]) : tout site de dispatch d'une valeur-fonction préfixe le receveur, pas seulement l'appel direct. Appels répétés et arguments supplémentaires composent (m(1) + m(2)), et le receveur est relâché à la destruction du slot. Le runtime PyO3 rendait déjà la bonne valeur.

Les instances de structs ne fuient plus dans le runtime pur : catnip_vm (binaire standalone, serveur MCP, LSP) rangeait ses instances de structs dans un registre indexé dont le décrément de refcount était, pour un struct, un no-op silencieux. Une instance rangée dans un local, un opérande, un conteneur ou un global n'était jamais relâchée : croissance non bornée sur un hôte long-vécu. Les instances sont désormais des Arc<StructCell>, refcountées comme les collections natives — decref se suffit à lui-même, la mort d'une instance cascade dans ses champs, et le registre ne garde plus que les types. Distinct de la famille de fuites PyO3 ci-dessous : ici c'est la VM pure, et catnip_rs (déjà struct-aware) n'était pas concerné. La bascule a exposé un lot de fuites que le no-op masquait — elles vidaient déjà des bigints et des listes : le jumeau oublié d'un handler de destructuration, le receveur d'un appel de méthode statique, les opérandes de is et type, le conteneur source d'un a, b = …, les éléments d'un match sur une liste, et les globals qu'un reset() abandonnait à chaque requête du serveur MCP. Un compteur d'instances vivantes, borné à la portée d'une fonction, revient à sa base : dix-huit oracles le vérifient.

Les types de fonctions sont complets : (int, str) -> bool annote un paramètre callback, de la grammaire au runtime. Statiquement, une lambda littérale est comparée composant par composant (arité, paramètres contravariants, retour covariant) ; à l'entrée de la fonction, la valeur doit être callable et son arité accepter l'arité déclarée (défauts et variadiques comptés, constructeurs de structs sur leur plage de champs) ; et le retour de chaque appel du callback est vérifié côté appelant, y compris à travers une closure qui l'a capturé — un callback qui ment sur son retour est arrêté là où le mensonge devient observable. La flèche absorbe à droite ((int) -> int | None retourne une union ; parenthéser pour une union de fonctions). Champs de structs et éléments de composites (list[(int) -> int]) sont couverts par les mêmes vérifications.

Une famille de fuites mémoire fermée dans le runtime PyO3 : le contrat de propriété des handles d'objets Python a été cartographié puis uniformisé — chaque store de globals possède exactement une référence par entrée, prise par son écrivain. Dans le lot : le retour de fonction qui abandonnait ses locals sans les relâcher (une référence fuyait par local objet-Python restant, à chaque retour), la resynchronisation des globals après un appel Python re-entrant (double libération latente), les redéfinitions de types (l'ancien objet type restait épinglé, ou pire, son pointeur pendait dans les tables de dispatch), et les chemins d'écriture des closures (use-after-free path-dépendant). Les oracles : le refcount CPython d'un objet témoin doit revenir à sa base après une session complète, sur les deux chemins de teardown (refcount pur et GC cyclique). La machine virtuelle participe désormais entièrement au ramasse-miettes cyclique — les fonctions qu'elle définit, leurs closures et une closure qui se référence elle-même (récursion) sont enfin visibles du collecteur : un hôte long-vie (REPL, serveur) qui définit des fonctions au fil de l'eau récupère leur mémoire au lieu de l'accumuler.

Les arms gardés de match tiennent parole, dans les deux machines : un match { x if cond => … } lie sa capture deux fois — une fois pour évaluer le garde, une fois pour le corps — et la machine pure (binaire standalone, MCP) consommait la liaison dès le premier passage ; le corps s'exécutait alors avec une variable jamais liée, sur une erreur plutôt qu'en silence. Corrigé en clonant la liaison au lieu de la déplacer. Au passage, une référence fuyait à chaque match qui capture : la copie du sujet, retenue le temps de la liaison, n'était jamais rendue. Le même oracle témoin tranche : le refcount d'un objet passé à un match revient à sa base — capture ou pas, garde ou pas.

La sémantique des closures est tranchée : capture par copie à la création — une closure est un instantané mutable privé (le compteur canonique fonctionne : c'est sa capture qui s'incrémente), rien ne remonte au scope parent. Exception : les globals du module, résolus vivants à l'appel (late binding). La spécification (docs/lang/SCOPES_AND_VARIABLES.md) est réécrite, et les deux exécuteurs convergent : la grille de dix cas qui les départageait est désormais un test permanent — chaque cas asserte la même valeur en VM et en AST. Un coin reste ouvert et documenté : l'écriture d'un global jamais lu depuis une fonction, où le comportement VM dépend aujourd'hui de détails de compilation.

Un noyau differential dataflow prend racine dans catnip_core (inerte pour l'instant) : multiset versionné, transitions compactées, opérateurs stateless two-phase (map/filter/concat) — génériques, sans dépendance VM, avec leurs invariants algébriques prouvés en Coq (compaction neutre, homomorphismes). Le graphe, l'état et le pont VM suivront par étapes.

Deux résultats faux silencieux du bytecode, dans les deux machines : du code mort après un break ou un continue dans un for sur range pouvait faire atterrir la sortie de boucle n'importe où — résultat faux sans le moindre message, ou plantage de pile selon le programme. Cause : l'optimiseur de bytecode retire le code inatteignable et réajuste les cibles des sauts simples, mais pas celles encodées dans les instructions de boucle compactes (offset de sortie relatif, cible de retour absolue). Second défaut de la même famille : un if imbriqué dont une branche se termine par break faisait exécuter les deux branches du if englobant — le compilateur omettait le saut de sortie du then dès que la dernière instruction émise « ressemblait » à une fin, alors qu'elle appartenait à une seule branche interne. Les deux sont corrigés à la source (toutes les cibles passent par la même carte de réadressage ; le saut de sortie est toujours émis, l'optimiseur retire ceux qui sont inatteignables).

Le gate CFG a maintenant un harnais par propriétés : un générateur de programmes bien formés et terminants par construction exécute chaque échantillon avec et sans le round-trip CFG interne et exige le même résultat — la référence est la spécification, aucun oracle à écrire. Première campagne : sept défauts réels débusqués et fermés, dont les deux ci-dessus (hors moteur CFG) et cinq dans le moteur sous gate : recherche de merge qui traversait les arêtes de retour (une boucle nichée dans une branche de if était reconstruite hors de sa branche), boucles naturelles non fusionnées par en-tête (un continue fabriquait une sous-boucle partielle), et trois trous de LICM (définitions conditionnelles hoistées, sorties précoces ignorées, bras de match opaques au CFG et au SSA). Deux preuves Coq bornent au passage les invariants les plus fragiles : la recherche de merge en marche avant retourne le merge du builder quel que soit l'ordre de parcours, et le lot de copies séquentialisé d'une arête réalise la sémantique parallèle de tous les phis du join.

Deux fuites de références étroites fermées : le remap d'un type union ou enum au transplant d'un module reconstruisait la valeur sans rendre l'ancienne référence, et la résolution d'un nom via les globaux Python sur-incrémentait les entiers longs et les instances de struct (une référence de trop par lecture, jamais rendue). Les résolveurs de portée normalisent désormais tous leurs retours en références possédées — et les chemins qui jetaient le résultat après un simple test d'existence ne construisent plus rien du tout.

Le bytecode rend désormais sa mémoire : les pools de constantes, de défauts et de littéraux de match d'un CodeObject gardaient leurs références après la mort du code — chaque session qui compilait laissait derrière elle une poignée d'objets impossibles à récupérer. Les pools ont maintenant un propriétaire unique (ils déménagent du compilateur vers le CodeObject au lieu d'être copiés), une libération unique à la destruction du code, et une compilation qui échoue nettoie ses brouillons. Le correctif couvre les deux machines (VM Python et PureVM du binaire standalone, dont le clonage au transplant prend désormais ses propres références). Au passage, la destruction d'un objet Python ne se fait plus sous le verrou de la table globale : un destructeur Python peut y ré-entrer, et le verrou n'est pas réentrant — le blocage était possible avant, il ne l'est plus.

Le GC pouvait vider les builtins d'un pipeline vivant : un passage du ramasse-miettes cyclique pendant qu'un Pipeline était en usage pouvait effacer tous ses noms (NameError: name 'print' is not defined sur un builtin arbitraire, à une position qui changeait avec les seuils d'allocation). Le pipeline n'atteint sa machinerie d'import que par la table de globaux Rust, invisible au collecteur : le chargeur d'imports revendiquait seul ces références, et sans référence Python externe le cluster passait pour un cycle mort — son nettoyage drainait la table partagée sous le pipeline vivant. Corriger le rapporteur ne suffisait pas : en collecte jeune, un pipeline déjà promu n'est pas visité et sa protection disparaissait. Le pipeline rapporte désormais lui-même ses références (contexte, chargeur, handles) et l'hôte VM détient une référence directe vers son chargeur, jamais soustraite quelle que soit la génération collectée. Les sessions mortes restent détachées comme avant, et deux tests gardent le miroir qui manquait : la survie d'une session vivante à une collecte.

Optimisations inter-blocs CFG+SSA (gate interne) : le round-trip IR → CFG → SSA → reconstruction exécute désormais trois passes, chacune gardée pour refuser plutôt que dégrader. LICM sort les définitions invariantes d'un while dans un bloc gardé par une copie de la condition (une boucle à zéro itération n'exécute rien de spéculatif). DSE élimine les affectations jamais lues — seulement celles dont la valeur est un littéral scalaire ou une référence, tuées par une réaffectation sur tous les chemins, sans appel ni opération faillible dans l'intervalle (un opcode « pur » peut lever, et une closure lit les globaux par nom au moment de l'appel). GVN remplace les expressions redondantes prouvées scalaires immuables par des copies (alias, ou temporaire __gvnN quand le canonique est réassigné). Validé en différentiel d'exécution sur la suite entière, VM et AST, passes actives. Aucune surface utilisateur : rien ne change sans l'activation interne (CATNIP_CFG_INTERNAL), qui reste hors des binaires distribués.

Génériques nominaux sur les paramètres (Option[int], Result[T, E]) : une annotation de type paramétrée cesse d'être décorative. x: Option[int] vérifie que la valeur est une Option et substitue l'argument de type dans les payloads de variants : une charge Some(value: T) doit satisfaire int. Un variant nullaire (Option.None) n'a pas de payload et passe ; l'arité fait partie du contrat (Option[int, str] sur une union à un paramètre est une erreur de compilation). Le contrôle a lieu à l'entrée de la fonction (nouvel opcode VM CheckGeneric), au champ de struct, et comme membre d'union (int | Option[int]) ou d'un composite (list[Option[int]], clés set/dict). L'inférence statique est étendue : un constructeur littéral mal typé (Option.Some("x") inféré Option[str]) est rejeté sur un type de retour ou un défaut Option[int], sans attendre l'exécution. Actif en VM, AST et binaire standalone, à parité, y compris à travers un import de module. Limite v1 : la substitution ne descend pas dans un composite (items: list[T] vérifie le conteneur, pas l'élément).

Cycles Context : fuite par alias de type et par méthode liée : deux bugs de comptage du collecteur cyclique -- pas un refcount déséquilibré -- pinçaient le Context entier d'une session (sensible pour un process long-vivant : REPL, serveur, MCP). (1) ImportLoader.__traverse__ parcourait la map des globaux VM et reportait chaque handle ObjectTable séparément ; or un slot ne détient qu'une référence Py, partagée par tous les handles qui le visent. Un type lié à deux noms (v = P) apparaissait donc deux fois, sa référence était sur-comptée, et le gc_refs négatif qui en résultait faisait croire au collecteur que le type était référencé de l'extérieur. (2) BoundCatnipMethod (le résultat de P(1).f) et son jumeau non-lié CatnipMethod ne participaient pas au GC : ces pyclass tenaient func/instance -- qui rejoignent le Context -- sans __traverse__/__clear__, fermant un cycle invisible au collecteur. La dédup-par-slot vit désormais dans un helper visit_obj_handles au niveau de l'ObjectTable (correct par construction pour toute traversée future de handles), les deux classes de méthode participent au GC, et tests/language/test_gc_context.py garde la régression (construire → delgc.collect()Context collecté, VM et AST).

Frontière plugin : attributs verrouillés (ABI v5, BREAKING) : les attributs statiques d'un plugin étaient reconstruits depuis leurs bits bruts puis incrémentés en refcount sans contrôle -- un plugin présentant un pointeur (un bug, ou un descripteur mal formé) le faisait déréférencer à l'aveugle. Comme pour les valeurs de retour, un attribut déclare désormais s'il est host-construit (PLUGIN_ATTR_HOSTVALUE, nouveau champ flags de PluginAttr) ; sinon il doit être un scalaire, validé par from_raw_scalar. Un pointeur non déclaré est rejeté à l'admission au lieu d'être déréférencé. L'ABI passe de v4 à v5 -- tous les plugins doivent être recompilés.

Closures imbriquées et mutation de global (runtime pur) : trois corrections de la VM pure (catnip-mcp, catnip-run), alignées sur la VM principale. Une closure doublement imbriquée lisant une variable d'un scope englobant que la closure intermédiaire ne nommait pas tombait sur l'homonyme global, ou échouait (NameError) : la capture ne gardait que les noms cités directement, rompant la chaîne ; elle capture désormais tous les locaux du frame englobant. Une closure mutant un global du module (un compteur) n'écrivait que la table globale, pas le slot local que le module relit -- la mutation restait invisible (counter rendait 0 au lieu de 2) ; les slots du module sont re-synchronisés depuis les globaux au retour au niveau module. Enfin, relire un global juste après l'avoir écrit dans la même closure (n = n + 1 puis n) rendait None : la première écriture réservait un slot local jamais rempli, et les lectures suivantes basculaient dessus au lieu du scope englobant ; l'écriture traversante ne crée plus de slot, la lecture reste résolue par nom (une mutation externe entre les deux reste visible).

Variantes d'enum/union en mode ND thread : une variante retournée par un worker (pragma("nd_mode", ND.thread)) se dégradait en entier brut. Le worker rayon n'héritait que de la StructRegistry et de la FunctionTable du parent, pas de la SymbolTable ni de l'EnumRegistry -- resolve_symbol échouait dans sa table vide et la variante retombait sur son index de discrimination. Une variante nullaire perdait en plus ses méthodes. Les registres transitent désormais vers les workers par un handle typé NdRegistryHandle (Send + Sync) qui porte les quatre registres plus la table symbole→méthodes ; un guard RAII restaure les thread-locals du worker au retour. Le mode sequential n'était pas touché.

sys.platform : même valeur côté natif et PyO3 : le backend natif (runtime pur) suivait la convention de sys.platform de Python (darwin pour macOS), mais le backend PyO3 rendait std::env::consts::OS brut (macos). Sur macOS les deux backends répondaient donc différemment. Les deux passent désormais par une fonction commune (darwin, win32, sinon la valeur de l'OS). Sur Linux, rien ne change (linux).

io.input() : prompt durci côté natif : le prompt était écrit via la macro print!, qui panique si l'écriture sur stdout échoue (stdout fermé, pipe rompu) ; une panique qui traverse extern "C" est un comportement indéfini. Le prompt est maintenant écrit directement sur le stdout verrouillé en best-effort, sans macro paniquante.

catnip-mcp : mcp.toml redevient la source des descriptions de tools : load_mcp_defs() patchait les descriptions depuis mcp.toml sur le routeur stocké, mais la macro #[tool_handler] reconstruisait un routeur neuf via Self::tool_router() à chaque tools/list -- le patch était donc ignoré et c'étaient les descriptions compilées dans les attributs #[tool(...)] qui étaient servies, mcp.toml restant décoratif (les resources, construites directement depuis le TOML, n'étaient pas concernées). Le handler pointe désormais sur le routeur patché (#[tool_handler(router = self.tool_router)]) et les descriptions inline sont retirées : mcp.toml est la source unique pour les tools comme pour les resources.

catnip-mcp : la sortie des programmes ne corrompt plus le protocole : le plugin io natif (runtime pur, qui sert le serveur MCP) écrit print/write directement sur les descripteurs du process. Comme catnip-mcp parle JSON-RPC sur ce même stdout, un print dans un eval_catnip injectait du texte hors-protocole dans le flux -- et la sortie du programme était de toute façon perdue pour l'appelant. eval_catnip capture désormais le stdout pendant l'exécution et le renvoie dans un champ stdout ; stdin pointe sur /dev/null, donc input() voit EOF au lieu de consommer les octets du protocole. Le runtime PyO3 (catnip, catnip-run) n'était pas touché : il route l'I/O par sys.std*.

Indentation profonde avant else/elif : le scanner regarde la ligne suivant un newline pour décider si un else/elif/except/finally continue le bloc précédent ou ouvre un nouveau statement. Ce lookahead bornait à 50 le nombre d'espaces d'indentation sautés ; au-delà, le mot-clé n'était jamais atteint et la continuation devenait un statement orphelin (parse cassé). La borne est retirée -- le saut va jusqu'au premier caractère non-blanc.

io.input() : terminateur de ligne uniforme : le backend PyO3 d'input() ne retirait que \n, là où le backend natif retire \r\n. Sur une entrée CRLF, les deux backends rendaient des valeurs différentes ("x\r" vs "x"). Le backend PyO3 retire désormais le terminateur complet, comme le natif.

Composites list[T] / dict[K, V] sur les paramètres : une annotation list[T] ou dict[K, V] vérifie le conteneur et ses paramètres -- d'abord le conteneur (seule une liste satisfait list, seul un dict satisfait dict), puis chaque élément d'une list[T] et chaque clé/valeur d'un dict[K, V], récursivement (list[list[int]], dict[str, list[int]]). Sans coercion. Un mismatch prouvable est refusé à l'analyse (E300 : un littéral [1, 2, 3] passé à x: list[str]), le reste à l'entrée de la fonction (TypeError) via un nouvel opcode VM CheckComposite. La variance est hybride : un littéral fraîchement construit est covariant ([1, 2, 3] satisfait list[float] par la tour numérique), une valeur déjà typée est invariante (une list[int] ne satisfait pas list[float] -- muter à travers l'alias serait incohérent). Enforçable aussi comme membre d'union (int | list[int]). Actif en VM, AST et workers ND.

Correction d'un crash sur l'accès direct à un champ de struct temporaire : S([1, 2, 3]).items -- lire un champ liste, dict ou chaîne sur une instance qu'on ne garde pas dans une variable -- pouvait libérer la valeur du champ en même temps que l'instance et faire planter le runtime (« dead handle »). Le champ lu devient maintenant une référence indépendante qui survit à la libération de l'instance. Un champ scalaire, ou une instance tenue par une variable, n'étaient pas touchés.

Unions de types sur les paramètres : une annotation peut désormais être une union -- (x: int | str), (p: Point | None). La valeur est acceptée si elle satisfait l'un des membres : les primitifs suivent la tour numérique (un bool appartient à int, un int à float), les nominaux suivent le sous-typage. À la différence d'un paramètre primitif simple, une union ne coerce pas -- elle ne saurait pas vers quel membre, donc la valeur passe inchangée (f(1) sur x: int | str reste un int). Un mismatch prouvable est refusé à l'analyse, le reste à l'entrée de la fonction (TypeError, même contrat que les autres boundaries). Une union dont un membre n'est pas vérifiable (composite non modélisé comme set[int], ou nom de type inconnu) reste inerte : on ne rejette jamais sur une preuve absente. Couvre l'Optional canonique T | None. Actif en VM, AST, broadcast et workers ND process.

Arithmétique typée spécialisée : quand l'analyse prouve que les deux opérandes d'une opération arithmétique sont des int (ou des float) -- typiquement via un paramètre annoté, dont le type est garanti à l'entrée de la fonction --, l'opération polymorphe est réécrite en sa variante typée (AddInt/AddFloat, SubInt/SubFloat, MulInt/MulFloat, DivFloat). La variante typée saute le dispatch de type et la recherche de surcharge d'opérateur à l'exécution, et fournit au JIT une trace déjà typée (pas de garde de type au runtime). La réécriture est sound : un paramètre réassigné, lié par un pattern de match, une variable de boucle, un binding except ou masqué par une définition locale retombe sur l'opération polymorphe. La division vraie (/) reste interprétée : elle produit toujours un float et doit lever sur diviseur nul, ce que ni la division entière ni la division float native ne préservent.

Inlining JIT des fonctions @pure : une boucle chaude qui appelle une fonction marquée @pure voit désormais le corps de cette fonction inséré directement dans la trace compilée, sans franchir la frontière d'appel à chaque itération. Cela vaut pour les helpers @pure définis au niveau module comme pour ceux tenus dans une variable locale de la frame qui porte la boucle. L'inlining est sound : une garde d'identité, vérifiée à l'entrée du code natif, s'assure que la fonction est toujours la même qu'à la compilation ; une réassignation fait déopter la boucle vers l'interpréteur au lieu d'exécuter l'ancien corps figé.

Modulo compilé sans piège : le modulo entier (%) dans une boucle chaude est maintenant compilé en code natif au lieu de retomber sur l'interpréteur, tout en préservant deux sémantiques que le srem natif viole. Un diviseur nul est gardé : il déopte vers l'interpréteur qui lève ZeroDivisionError, là où le srem natif provoquait un SIGFPE (crash atteignable dès qu'une boucle compilée atteignait un diviseur nul après le seuil de chauffe). Et le signe suit la sémantique plancher de Python (signe du diviseur), via un correctif appliqué au résultat tronqué de srem — sans quoi une boucle compilée sur des opérandes de signes opposés donnait un résultat différent de l'interpréteur.

JIT — fin du double-comptage au side-exit : quand une boucle chaude compilée sort en plein milieu d'une itération (une branche rare prise, un dépassement SmallInt, un diviseur nul), elle rend la main à l'interpréteur, qui rejoue l'itération entière depuis le début. Le code natif restaure désormais les locals dans leur état début d'itération, pas leur état mi-itération : sans quoi tout accumulateur déjà mis à jour avant la sortie (total += i suivi d'une branche gardée) était compté deux fois, une fois par le natif et une fois par l'interpréteur au replay. Le résultat était silencieusement faux, et seulement sur les boucles assez chaudes pour compiler dont une garde finissait par flipper.

JIT — crash BigInt et réutilisation de trace cross-programme : deux défauts latents du JIT, corrigés. Une boucle chaude tenant déjà un grand entier (BigInt, issu d'un dépassement SmallInt avant le seuil de chauffe) au moment où le traceur l'observe n'est plus compilée : le BigInt n'étant ni int ni float natif, le traceur abandonne la trace (la boucle reste interprétée) au lieu d'émettre une garde flottante incohérente qui faisait planter Cranelift (SIGABRT). Indépendamment, l'état compilé en mémoire est désormais indexé par hash de bytecode et offset de boucle, et non plus par offset seul : un second programme exécuté dans la même VM (REPL, embedding) ne réexécute plus la trace native d'un premier programme dont une boucle partageait le même offset — un résultat silencieusement faux jusqu'ici, sur ce chemin précis (jamais atteint en exécution one-shot, où un programme = une VM).

JIT — re-compilation des boucles cross-programme : prolonge le correctif ci-dessus. Une fois l'état compilé indexé par (hash, offset), le second programme d'une VM réutilisée (REPL, embedding) renvoyait le bon résultat mais restait interprété : le détecteur de hot loops voyait encore « sa » boucle au même offset comme déjà chaude et ne la traçait jamais. Détection et symbole de la fonction Cranelift sont à leur tour indexés par (hash, offset) — la boucle du second programme redevient chaude, se trace et se compile. La collision de symbole qui surgissait alors (trace_{offset} redéclaré dans le module Cranelift partagé) est levée du même coup.

Annotations de type vérifiées à l'exécution : une annotation de type primitive (int, float, str, bool, None) sur un paramètre est désormais un contrat appliqué, pas une décoration. À l'entrée de la fonction, la valeur reçue est vérifiée et coercée selon la tour numérique (int/bool/bigint élargis en float, bool en int) ; str est vérifié sans coercion, seule une chaîne passe. Tout échec du contrôle -- type incompatible, ou bigint trop grand pour tenir dans un float -- lève une TypeError avant que le corps ne s'exécute : un contrat d'exception unique, attrapable uniformément (except TypeError) et identique en mode VM comme en mode AST. Une incompatibilité prouvable statiquement (p.ex. f = (x: int) appelée avec une str littérale) est rejetée encore plus tôt, à la compilation, au lieu d'être ignorée. Même comportement dans les workers ND process. L'annotation porte sur le type de la valeur, pas sur sa présence : un paramètre annoté reste optionnel (omis → None), et les composites non modélisés (set[...], génériques Option[T]) guident l'analyse mais ne sont pas vérifiés à l'exécution.

Types nominaux vérifiés au boundary : un paramètre annoté avec un type nominal -- struct, enum, union taggée ou trait -- est désormais vérifié à l'entrée de la fonction, avec sous-typage. (b: Base) accepte une instance d'un sous-struct (Child extends(Base)), (x: Drawable) accepte une valeur dont le struct implements(Drawable), et une variante d'union ou d'enum est acceptée pour le type qui la déclare. Contrairement aux primitifs, un nominal n'est jamais coercé : la valeur passe telle quelle ou la fonction lève une TypeError (même contrat d'exception, attrapable par except TypeError, identique en mode VM et AST). Un nom de type inconnu (typo, ou composite non modélisé comme set[int]) laisse l'annotation inerte. Le boundary est sous-typant là où le pattern matching de struct reste exact. Actif en VM, AST et en broadcast ND à VM partagée (thread/sequential) ; le mode ND process (binaire standalone), qui ne fait pas encore traverser les valeurs nominales, n'enforce pas encore ce contrat.

Imports de modules .cat à travers la frontière de VM, dans le binaire standalone comme en in-process.

Appels inter-fonctions dans un module importé : une fonction exportée par un module .cat peut appeler ses fonctions sœurs -- et ses helpers privés -- une fois le module importé (récursion, récursion mutuelle, ordre supérieur, imports transitifs compris). Un module s'exécute dans une VM enfant détruite après chargement ; sa table de fonctions est désormais transplantée dans la VM appelante (indices et captures de closure réindexés) au lieu de laisser des références pendantes -- avant, l'appel levait « invalid function index ».

Binaire standalone : imports cross-module unifiés : le binaire embarqué chargeait deux copies de l'extension native -- une liée statiquement pour la VM parente, une .so chargée pour les modules enfants -- chacune avec ses propres tables, si bien qu'aucun transplant ne franchissait la frontière. Le binaire enregistre maintenant l'extension statique comme module unique : les imports de modules .cat (fonctions, mais aussi variantes d'union/enum traversant la frontière) se comportent dans le binaire comme via l'API Python.

Transplant de fonctions importées : plus de use-after-free : charger un module .cat transplante sa table de fonctions dans le VM appelant via un pointeur thread-local. Ce pointeur, posé par execute et -- contrairement aux tables de symboles et d'enums -- jamais restauré au retour, pouvait survivre à l'Executor qu'il désignait : un import suivant transplantait alors dans une table déjà libérée (segfault sporadique sous exécutions répétées dans le même processus, p.ex. un worker de make test parallèle). Le transplant ne lit désormais ce pointeur que lorsqu'un dispatch VM est réellement actif sur le thread -- un import suspend le parent dans son propre dispatch, ce qui garde sa table vivante ; au niveau top-level le pointeur résiduel est ignoré au lieu d'être déréférencé.

Namespaces de module : plus de fuite ni de use-after-free sur les attributs (runtime pur) : un ModuleNamespace (mode pur Rust : imports .cat et modules stdlib natifs) ne relâchait jamais les valeurs de ses attributs à sa destruction -- sys.argv, une liste native, fuyait à chaque chargement. Ajouter cette libération a révélé que les attributs statiques d'un plugin natif (p.ex. PROTOCOL, VERSION) sont partagés depuis un descripteur unique entre tous ses chargements : les libérer depuis un namespace invalidait ceux qu'un autre lisait encore (corruption sporadique sous chargements répétés en parallèle). Chaque namespace prend désormais sa propre référence sur ces attributs, symétrique de leur libération.

Variantes d'union/enum à travers les callbacks : une variante passée à une lambda de broadcast .[] -- ou créée dedans via Union.variant -- garde son identité : appel de méthode, ==, match et typeof fonctionnent. Un callback s'exécute dans une VM enfant qui clone désormais la table de symboles et le registre d'enums du parent, en plus des registres de structs et de fonctions. Sans ce partage, la variante retombait sur son entier de discrimination -- str affichait l'index et le dispatch de méthode levait 'int' object has no attribute .... Les variantes nullaires d'union sont par ailleurs internées dès MakeUnion (et non à la première traversée), ce qui aligne la VM PyO3 sur la PureVM ; typeof lit alors le nom de l'union depuis le symbole qualifié.

Portée lexicale dans les closures imbriquées : une closure imbriquée qui référence un nom lié à la fois par une fonction englobante (paramètre ou local) et présent comme global du module résout désormais la liaison englobante, pas le global. Avant, la résolution consultait les globals avant la chaîne des scopes englobants : un helper dont le paramètre portait le nom d'un global lisait silencieusement la valeur globale -- résultat faux, sans erreur ni warning, ne se manifestant que sur collision de noms. Le correctif est double -- une closure capture désormais un local englobant même quand il masque un global homonyme (la suppression de capture ne vaut qu'au niveau module, où les slots sont les globals), et LoadScope marche la chaîne des variables capturées de toutes les closures englobantes avant les globals (le niveau enclosing de LEGB). Valable en VM et AST, sur les lambdas imbriquées comme dans les callbacks de broadcast .[].

Égalité structurelle des méthodes de collection (runtime pur) : prolongeant la généralisation de 0.1.1, limitée aux opérations de premier niveau, list.index/count/remove et tuple.index/count ne comparent plus par identité -- un payload de struct ou de variante se trouve désormais par xs.index(...), via la même égalité structurelle que ==/!= et in/not in. L'égalité bas-niveau des collections (PartialEq) couvre aussi dicts et sets, qui rejoignent listes et tuples déjà comparés champ à champ.

Affichage des structs et variantes imbriqués (runtime pur) : str()/repr()/print et les f-strings descendent maintenant dans les collections (listes, tuples, dicts, sets) et résolvent chaque élément contre les registres -- [P(x=1, y=2)], [Color.red], {1: Color.red} au lieu de [<struct #0>] ou d'identifiants opaques (0.1.1 ne corrigeait que les valeurs affichées directement, pas celles contenues dans une collection).

Structs hashables comme clés (runtime pur) : une struct instance -- et les variantes d'union à payload, qui en sont -- peut servir de clé de dict ou de membre de set. Le hash est structurel (type + hash des champs, récursif), cohérent avec l'égalité par valeur ; à la première utilisation comme clé, l'instance est gelée et toute mutation ultérieure est refusée (cannot mutate ... after it has been hashed), comme dans le runtime Python. Un struct surchargeant op_eq reste unhashable (cohérence hash/eq). Les variantes nullaires (symboles) étaient déjà hashables. hash(complex) est par ailleurs couvert par un test. Le runtime pur (MCP, LSP) rejoint ainsi le CLI Python.

op_hash personnalisé honoré (runtime pur) : dernier écart de hashabilité comblé. Un struct définissant op_hash voit sa méthode appelée pour produire le hash, au lieu du hash structurel -- à parité avec le CLI Python et à tout niveau d'imbrication (le hash d'un champ struct passe par son propre op_hash, comme le hash() récursif côté Python). hash(obj) d'un struct à op_hash retourne la valeur brute (hash(obj) == obj.op_hash()) ; l'égalité reste structurelle, donc la cohérence a == b ⇒ hash(a) == hash(b) reste à la charge de l'utilisateur. La construction de clé passe désormais par un contexte (KeyCtx) capable d'exécuter une méthode VM pendant le hash -- ce que l'accès registre seul ne permettait pas, raison pour laquelle l'écart subsistait.

Objets plugin natifs : AttributeError honnête en mode AST : accéder à un membre inexistant d'un objet plugin natif (http.Server/Response, fichier io, ...) lève désormais AttributeError en mode AST, au lieu de rendre une méthode liée fantôme -- r.typo réussissait et hasattr(r, 'typo') répondait True. L'exécuteur AST abaisse obj.m(...) en getattr-puis-call : au moment du getattr, le pont ne peut pas distinguer un attribut d'une méthode et liait donc tout nom optimistement. L'ABI des plugins natifs passe en v3 avec un probe has_member(handle, name) (par objet, pour les modules exposant plusieurs types comme Server/Request/Response) ; une méthode n'est liée que si le membre existe. La VM, qui distingue attribut et méthode syntaxiquement (GetAttr vs CallMethod), était déjà correcte.

Frontière des valeurs verrouillée par preuve, ABI plugin v4 (BREAKING) : la représentation NaN-boxée distingue trois classes de tag -- scalaire (donnée inline), index (handle borné), pointeur (Arc brut). Le constructeur de frontière from_raw_scalar n'admet qu'un scalaire depuis des bits non fiables : le JIT (restauration des locals, résultat d'appel compilé) passe par lui, si bien qu'un pointeur fabriqué ne peut plus être déréférencé silencieusement. La frontière FFI des plugins natifs passe en v4 : un plugin reçoit un PluginHostApi et construit toute valeur structurée de retour (string, liste, dict, bytes, bigint) dans le heap du host, jamais dans le sien ; un résultat traverse par OBJECT (handle), HOSTVALUE (valeur host-construite), ou comme scalaire validé -- le host ne déréférence donc jamais un Arc appartenant au plugin. BREAKING : un plugin tiers compilé contre l'ABI v3 doit migrer (signature d'init (host: *const PluginHostApi), retours structurés via les builder callbacks). Verrou prouvé en Coq (CatnipValueClassProof, CatnipBoundaryProof, CatnipPluginBoundaryProof).

Recyclage des slots de structs exportées : une struct exportée vers Python (stockée dans une list, renvoyée d'un broadcast) incrémentait un refcount dans le StructRegistry que rien ne relâchait -- PyO3 ne câble pas fn __del__, si bien que le slot n'était jamais recyclé (fuite bornée, mais réelle pour un process long-vivant : REPL, serveur, MCP). Le finalizer réel impl Drop for CatnipStructProxy (via tp_dealloc) relâche le slot ; et un VM enfant de broadcast transfère désormais la propriété de sa struct résultat au proxy plutôt que de laisser le transplant en copier le compteur en fantôme. Discipline prouvée en Coq (CatnipOwnershipProof).

Proxy échappé pendant un callback broadcast : dernier fantôme du même domaine. Une struct matérialisée vers Python pendant un callback (qui échappe et survit à l'appel) ancrait son proxy sur le registre enfant ; le transplant copiait son refcount dans le parent, puis à la mort de l'enfant le proxy résolvait un registre mort et son décrément ne touchait jamais le parent -- slot fantôme. La frontière n'est connue qu'au transplant : seul un slot transplanté (neuf dans l'enfant) a vu son compteur copié vers le parent et doit donc être ré-ancré sur lui ; un slot pass-through (déjà dans le parent) garde son ancre enfant, sinon son décrément over-décrémenterait le parent (use-after-free). Le runtime enregistre les proxies matérialisés par un enfant et ré-ancre ceux des slots transplantés au transplant (avec propagation aux broadcasts imbriqués). Garanties étendues en Coq (CatnipOwnershipProof, sections re-anchor / pass-through).

Slots de structs transplantés recyclables : même domaine, autre fuite. transplant_to_parent (broadcast) recopie les structs de l'enfant dans le registre parent en comblant les trous d'index avec des None, mais ne les ajoutait jamais à la free-list -- un index libéré par l'enfant (struct créée puis relâchée avant la fin du callback) restait inutilisable, jamais réalloué. Fuite lente du Vec d'instances pour un registre long-vivant (REPL, serveur, MCP), distincte des fantômes de refcount ci-dessus (ici c'est le slot du Vec, pas le compteur). Les trous intermédiaires sont désormais poussés sur la free-list pendant le transplant ; le slot effectivement transplanté en est exclu.

Fuite au retarget d'un proxy entre VM sœurs : quand un proxy de struct créé par une VM est passé à une autre VM de lignée distincte (deux interpréteurs, ou exécutions imbriquées), le round-trip vers le runtime recrée la struct dans le registre courant et réoriente le proxy vers ce nouveau slot. Il oubliait de relâcher la référence que le proxy détenait encore sur son ancien slot : si l'ancienne VM était encore vivante, ce slot restait épinglé pour toute sa durée de vie. Distinct des fuites broadcast ci-dessus (aucun broadcast requis). Le retarget décrémente désormais l'ancien slot avant de réorienter le proxy ; sans effet si l'ancienne VM est déjà détruite.

Fuite de refcount d'operands dans le runtime pur : la boucle d'opcodes du PureVM (catnip_vm -- binaire standalone, MCP, LSP) ne relâchait pas les operands qu'elle dépilait. Le stack possède ses références (LoadConst, LoadLocal incrémentent au push ; PopTop décrémente), mais les opcodes de dispatch -- opérateurs binaires et de comparaison, ==/!=, appels de fonction et de méthode, indexation ([]), accès d'attribut, appartenance (in), construction et mutation de collections, itération -- passaient leurs operands par emprunt à une fonction host puis les laissaient sur place : chaque valeur pointeur (string, list, dict, tuple, set, bigint, complex) consommée fuyait une référence, jamais libérée tant que la VM vivait. Invisible aux tests (une fuite ne change pas un résultat), mais réelle sur un process long-vivant qui boucle (serveur MCP, scripts). Chaque opcode relâche désormais ses operands sur le chemin host, le résultat host étant toujours owned ; les chemins frame-transfer (bind_args, set_local, call_struct_op) et les constructeurs (from_list, construct_struct) consomment déjà et restent inchangés. La frontière FFI host_make_dict est corrigée du même défaut (tokens de clé non relâchés après to_key, plus le nettoyage sur clé dupliquée ou non-hashable). La VM PyO3 n'était pas touchée (refcount Python/ObjectTable distinct). Un oracle strong_count (valeur témoin placée en operand, exécution, vérification que le solde de références revient à zéro) couvre chaque classe d'opcode et garde la discipline contre les régressions.

Appels variadiques et valeurs par défaut (runtime pur) : la liaison d'arguments du PureVM (catnip_vm -- binaire standalone, MCP, LSP) accusait trois écarts avec la VM PyO3, masqués par l'absence de test variadique. Les varargs n'étaient pas collectés : (x, *rest) => rest appelé f(1, 2, 3, 4) rendait 2 -- le premier argument surnuméraire, les suivants jetés -- au lieu de [2, 3, 4] ; le compilateur posait pourtant vararg_idx, mais bind_args l'ignorait et liait min(args, params) slots sans collecter l'excédent. Une valeur par défaut pointeur (string) était liée au slot local sans incrément de référence puis décrémentée à la destruction de la frame : sur-libération de la constante du CodeObject, use-after-free silencieux (la valeur survit tant que la mémoire libérée n'est pas réécrite). Un argument positionnel au-delà de l'arité (appel mal formé) était consommé sans être ni lié ni relâché -- fuite de référence. bind_args lit désormais vararg_idx et collecte l'excédent en liste, incrémente les défauts pointeurs, et relâche les positionnels surnuméraires : le runtime pur rejoint la VM PyO3, déjà correcte. Couvert par des tests variadiques end-to-end (PurePipeline) et deux oracles strong_count (défaut non sur-libéré, positionnel en excès relâché).

Fuite d'operands sur les chemins d'erreur d'appel (runtime pur) : dernier reliquat du chantier operand-leak du PureVM (catnip_vm -- binaire standalone, MCP, LSP). Les opcodes d'appel (Call, CallMethod, CallKw, call_struct_op, call_non_vmfunc) dépilent leurs operands avant de choisir le dispatch ; sur succès bind_args les consomme, mais sur une branche d'erreur qui n'atteint jamais le transfert ils restaient possédés. Tout argument pointeur (string, list, dict, ...) passé à un appel qui échoue -- méthode/variante/attribut introuvable, appel d'un non-callable, mauvaise arité, FrameOverflow -- fuyait pour la durée de vie du process : accumulation lente sur un serveur long-running (MCP) bombardé de requêtes fautives. Un helper release_operands relâche désormais les operands possédés avant chaque erreur atteignable (le receiver struct/union/module est un tag non refcompté, donc seul Value::decref -- symétrique du teardown de frame -- est requis, sans risque de double-free) ; le chemin host de module relâche aussi ses args empruntés. Restent hors périmètre, documentées comme non-fuites, les gardes d'invariant interne (index de fonction invalide, instance libérée) inatteignables sous bytecode valide. Couvert par trois oracles strong_count et un test end-to-end qui martèle les chemins d'erreur réels puis vérifie l'intégrité de l'état VM.

Linter : détection de catch-all de match unifiée : trois couches décidaient indépendamment si un pattern est un catch-all irréfutable, et deux divergeaient de la source de vérité (catnip_core::is_catchall). L'analyse CFG (--deep) ne reconnaissait que _, pas un pattern variable nu (n =>) pourtant tout aussi irréfutable : un match v { 1 => { x = 1 } n => { x = 2 } } exhaustif déclenchait un faux positif W310 (« x peut-être non initialisée ») et manquait le W311 sur le code mort qui suit. À l'inverse, la règle d'exhaustivité I103 descendait dans tous les enfants du pattern, classant un tuple (a, b) -- réfutable -- comme catch-all et taisant l'avertissement. Les trois couches appliquent désormais la même règle (_ ou tout pattern_var, or-pattern récursif, garde absente). Un faux positif W310 étant le pire mode d'échec d'un linter, c'est l'écart le plus visible qui disparaît.

Linter : deux nouvelles règles W303 et W401 : W303 (Info) signale une boucle while <identifiant> dont le corps ne modifie jamais la variable de condition -- ni réaffectation, ni appel (un closure capturé pourrait la muter), ni for/match/try/lambda, ni break/return/raise. La condition est alors invariante : la boucle ne s'arrête jamais (ou ne tourne jamais). Périmètre mono-identifiant volontairement étroit pour zéro faux positif ; complète W302 (while True). W401 (Warning) signale un builtin à effet de bord (print, input, open, breakpoint) appelé dans un broadcast .[], mais uniquement si le fichier active un mode ND parallèle (pragma("nd_mode", ND.thread) / ND.process) : par défaut le broadcast est séquentiel et ordonné, donc l'idiome data.[(x) => { print(x) }] reste valide. Sous thread/process l'ordre est non spécifié et les effets de bord se chevauchent.

Linter : nouvelle règle W304 (Warning) : un subscript à clé string en opérande non-final de ?? (d['k'] ?? defaut, y compris l'opérande médian d'une chaîne a ?? d['k'] ?? x) est signalé -- l'opérateur ne coalesce que None, une clé absente lève toujours KeyError, ce n'est pas un « get avec défaut ». La forme couvrant absence et valeur nulle est d.get('k') ?? defaut. Périmètre étroit (dernier membre = index à clé string/fstring littérale) : les clés calculées et indices entiers, qui peuvent viser des listes, ne sont pas signalés. Lint : flag --strict : par défaut seules les erreurs produisent un code de sortie non-zéro ; --strict promeut les warnings en fatals (Info et Hint restent consultatifs). Permet d'utiliser le linter comme gate CI — catnip lint --deep --strict dossier/ échoue au premier warning au lieu de l'afficher en laissant passer.

Linter : désactivation globale par code : en plus de # noqa (une ligne) et des seuils (0 = off), un code de diagnostic se désactive pour toute l'analyse. Section [lint] disable = [...] dans catnip.toml, ou flags CLI catnip lint --disable W401,I200 (répétable). --enable réactive un code éteint par la config sans toucher aux phases opt-in (--check-names, --deep) : l'ensemble effectif des codes tus est (fichier ∪ --disable) \ --enable. Filtrage post-analyse, à côté de noqa ; couvre aussi les diagnostics sémantiques (I103). Le LSP reste sur la config par défaut.

Annotations de type vérifiées (E300) : les annotations optionnelles (x: int, champs de struct, type de retour) ne
sont plus inertes. Le linter émet E300 (erreur) sur toute incompatibilité prouvable : défaut de paramètre
((x: int = "no")), défaut de champ (struct P { x: int = "no" }), type de retour vs corps ((): int => { "no" }), et
-- aux sites d'appel -- argument vs paramètre d'une fonction à liaison prouvablement unique (f("no"), positionnels
et par mot-clé) ainsi qu'argument vs champ au constructeur d'un struct (P(1, "no")). La vérification des appels est
monomorphe : cibles statiquement uniques seulement (jamais réassignées, ni passées comme valeur, ni masquées par un
paramètre) ; les arguments absorbés par un *args et l'arité ne sont pas vérifiés. Périmètre sound (zéro faux positif)
primitifs et types nominaux uniquement ; un composite non modélisé ou type inconnu (set[int]) ne déclenche rien. En sous-produit, une annotation alimente l'inférence du scrutinee de match : (c: Color) => match c et match p.x (champ typé) profitent désormais de la vérification d'exhaustivité (I103).

Binaire standalone : cache JIT corrigé (résultats faux sur boucles) : deux défauts du warm-start JIT pouvaient rendre un résultat faux dans le binaire standalone (le chemin Python via _rs était correct). 1) Le hash de bytecode n'était pas posé sur le chemin d'exécution standalone : toutes les clés de trace retombaient sur hash 0, si bien que deux programmes dont une boucle partage un offset se partageaient une trace -- for i in range(2000) {...} puis une boucle imbriquée donnaient un résultat erroné. 2) Le cache de code natif Cranelift n'était pas namespacé par la version de Cranelift : un build lié à une version plus récente chargeait des stencils émis par une plus ancienne (miscompilation silencieuse, p.ex. une boucle for rendant 1 au lieu de la dernière valeur). Le préfixe inclut maintenant cl{cranelift_codegen::VERSION} et le hash de bytecode est posé sur tous les chemins d'exécution.

Extras de dépendances : examples-* renommés codex-* : les extras d'installation optionnels portaient le préfixe examples- alors qu'ils fournissent les dépendances des intégrations Python de codex/ (numpy, pandas, torch, ...), pas celles des exemples du langage de docs/examples/. Chaque extra mappe maintenant un dossier de codex/ (codex-data-analytics, codex-machine-learning, codex-web, ...) et codex-all les agrège tous. redis passe de web à codex-persistence et tqdm à codex-utils, suivant leur dossier réel. L'extra examples-all ne garde que la seule dépendance externe des exemples du langage (cryptography). Migration : catnip-lang[examples-data] devient catnip-lang[codex-data-analytics], catnip-lang[examples-all] devient catnip-lang[codex-all].

Rename et positions du serveur LSP corrigés : le serveur LSP (catnip lsp) renommait de travers. Il capturait les identifiants homonymes jouant un autre rôle -- l'attribut de obj.x, la méthode de arr.len(), la clé d'un kwarg f(x=2) -- et, déclenché depuis un usage dans un bloc imbriqué, ne renommait qu'une partie des occurrences tout en débordant sur une variable de même nom qui en shadow une autre. Il fait désormais une vraie résolution lexicale : remonter au scope qui lie le nom, collecter ses occurrences, élaguer les sous-scopes qui le redéclarent. Les colonnes des diagnostics et des renommages, elles, étaient décalées d'une unité et comptées en octets ; elles sont maintenant 0-indexées et exprimées dans l'unité négociée avec le client (UTF-8 s'il l'offre, sinon conversion vers UTF-16), donc un caractère accentué plus tôt sur la ligne ne déplace plus les marqueurs.

import('sys') ne fait plus planter le process sur un environnement non-UTF-8 : la construction de sys.argv et sys.environ lisait les arguments et les variables d'environnement en supposant de l'UTF-8 valide. Un seul argument ou une seule variable non-UTF-8 -- une variable Latin-1, par exemple -- faisait paniquer l'initialisation du plugin, et une panique qui traverse la frontière extern "C" est un abort du process. La lecture est désormais lossy (les octets invalides deviennent U+FFFD), comme le fait Python.

Les écritures de io signalent leurs échecs : io.print, write, writeln et eprint ignoraient silencieusement un échec d'écriture et renvoyaient nil comme si de rien n'était. Une écriture sur un tube fermé (catnip script.cat | head) ou un disque plein lève maintenant une erreur, au lieu de prétendre avoir réussi -- même contrat que print en Python.