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 valuesen 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 → del → gc.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
*argset 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 dematch:(c: Color) => match cetmatch 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.