0.1.3 (unreleased)

La 0.1.3 fait dire la même chose aux trois moteurs d'exécution (VM, AST, moteur pur Rust) — et là où ils divergeaient, elle tranche laquelle des deux réponses était la bonne, quitte à casser le code écrit sur l'autre.

Ruptures de compatibilité

Les changements qui peuvent modifier le résultat d'un code existant, chacun détaillé dans sa section :

  • une boucle rend la dernière valeur de son corps, et scope sa variable (portée) ;
  • lire un nom après la fermeture de son bloc lève au lieu de rendre None (portée) ;
  • une liaison faite dans un if ou un match niché dans un bloc nu meurt avec le bloc, dans les deux moteurs et à tous les niveaux d'optimisation (portée) ;
  • deux structures sont le même type iff même nom et même forme — change ==, match, les clés de dict (identité de type) ;
  • un nom de type dans un pattern se résout dans la portée au lieu d'être un littéral (identité de type) ;
  • str et bytes sont scalaires dans un broadcast : un ~> sur une chaîne reçoit la chaîne, plus chaque caractère (broadcast) ;
  • un trait ne peut plus déclarer de champs (structures) ;
  • passer une instance à une fonction ne relance plus son init en mode AST (structures) ;
  • écrire dans le scope capturé depuis un callback ND est une erreur de lint (E205) (broadcast) ;
  • une structure passée à une fonction Python est mutable par elle, et une structure capturée par une closure est partagée avec sa portée englobante (structures) ;
  • un broadcast liste-à-liste de tailles inégales lève — la spécification est corrigée, pas les moteurs (broadcast) ;
  • return hors d'une fonction et break/continue hors d'une boucle sont refusés à la lecture du fichier, y compris dans un en-tête de boucle ou une valeur par défaut de paramètre (contrôle de flux) ;
  • un pragma écrit sans valeur lève au lieu d'être ignoré (pragmas) ;
  • écrire dans un champ annoté vérifie et coerce comme le constructeur, au lieu de tout accepter (annotations de type) ;
  • sys.exit() sort en échec pour un argument non entier, au lieu de sortir en succès (modules stdlib) ;
  • l'ABI des plugins natifs passe en v6 : un .so non recompilé est refusé au chargement (modules stdlib) ;
  • true, false et nil ne sont plus des noms définis dans le moteur pur (modules stdlib) ;
  • type() n'est plus un nom défini : utiliser typeof (modules stdlib) ;
  • Catnip(...) lève sur un argument nommé qu'il ne lit pas, au lieu de l'ignorer (CLI et outillage) ;
  • une valeur de CATNIP_EXECUTOR illisible lève au lieu d'exécuter la VM en silence (CLI et outillage) ;
  • une suggestion « Did you mean » passe à la ligne en mode VM comme elle le faisait déjà en mode AST (CLI et outillage) ;
  • Context(globals=...) remplace les builtins au lieu de s'y ajouter en mode VM (CLI et outillage) ;
  • un attribut d'introspection (x.__class__, x.__globals__, vingt autres) est refusé à la lecture du fichier, à l'accès comme à la déclaration (valeurs et opérateurs).

Contrôle de flux mal placé

Les trois cas ci-dessous sont refusés à la transformation du fichier en IR, donc avant toute exécution : aucune instruction précédente ne tourne, et les trois moteurs répondent la même chose au même moment.

return hors d'une fonction lève (BREAKING) : les deux VM rendaient la valeur, l'AST laissait échapper une classe interne. sys.exit porte déjà la valeur de sortie d'un script.

return 42        # SyntaxError: 'return' outside function

break et continue ne traversent plus un appel de fonction (BREAKING) : en mode AST, un break écrit dans une fonction pilotait la boucle de l'appelant — corps abandonné depuis l'intérieur de la fonction, sans erreur et avec un résultat faux.

g = () => { break }
acc = 0
for i in range(0, 3) { acc = acc + 1; g(); acc = acc + 10 }
acc              # rendait 1 en mode AST ; SyntaxError: 'break' outside loop

Une fonction est sa propre portée de contrôle de flux : ni la boucle qui l'entoure, ni celle qui l'appelle. Vaut aussi pour une fonction définie dans le corps de la boucle, pour une méthode de structure et pour un callback de broadcast.

Un en-tête est évalué par la portée englobante (BREAKING) : un bloc étant une expression, il tient dans une condition de while, dans un itérable de for et dans une valeur par défaut de paramètre. Ces positions s'évaluent avant la construction qu'elles ouvrent, donc un break ou un return écrit là ne s'y rattache pas.

while { break; True } { 1 }        # rendait None en mode VM ; SyntaxError: 'break' outside loop
f = (x = { return 5 }) => { 9 }
f()                                 # rendait 5 en mode VM ; SyntaxError: 'return' outside function

Dans une boucle englobante, le même break reste accepté et interrompt celle-là :

acc = 0
for i in range(0, 3) { acc = acc + 1; while { break; True } { 1 } }
acc                                 # → 1

Portée, closures et valeurs de bloc

Une boucle rend la dernière valeur de son corps (BREAKING) : for et while rendaient None en mode VM et la dernière valeur du corps en mode AST. Un bloc rend sa dernière valeur ; en exempter les boucles en faisait les seuls blocs qui n'en rendent pas. C'est donc le mode VM qui est corrigé.

for i in list(1, 2, 3) { i * 10 }     # 30, et non None
for i in list() { i * 10 }            # None -- le corps n'a jamais tourné

Une itération interrompue ne contribue pas : break et continue sortent avant que la valeur du tour ne soit retenue, donc c'est celle du dernier tour complet qui reste.

Une boucle scope sa variable, et chaque closure garde son exemplaire (BREAKING) : la variable de boucle survivait à sa boucle en mode AST et pas en mode VM. Au niveau module, cette survie changeait ce qu'une closure construite dans le corps allait lire : [3, 3, 3] en AST contre [1, 2, 3] en VM.

fns = list()
for i in list(1, 2, 3) { fns.append(() => { i }) }
list(fns[0](), fns[1](), fns[2]())   # [1, 2, 3]

La règle vaut pour toute locale du corps, pas seulement la variable de contrôle : le mode VM ne scopait que la seconde, si bien qu'une autre variable du corps se relisait à l'appel et que les trois closures voyaient la dernière valeur.

adders = list()
for i in list(10, 20, 30) {
    val = i
    adders = adders + list((x) => { x + val })
}
list(adders[0](1), adders[1](1), adders[2](1))    # [11, 21, 31]

Un homonyme du scope englobant est masqué pendant la boucle puis retrouve sa valeur, et l'assignation à un nom extérieur reste une écriture sur ce nom. C'est le point où Catnip diverge de Python, qui rendrait [3, 3, 3] : là-bas la variable de boucle survit et les trois closures lisent la même.

Lire un nom après la fermeture de son bloc lève, et le if ne scope pas (BREAKING) : ce qui scope, c'est ce qui peut entrer en collision avec soi-même — une boucle rebinde sa variable à chaque tour, un bloc-expression isole un calcul. Les branches d'un if et les bras d'un match sont exclusifs : un seul s'exécute, donc ce qu'ils assignent reste lisible après.

score = 85
if score >= 80 { note = "B" } else { note = "C" }
note                    # → "B"

for k in list(1, 2) { tmp = k * 10 }
tmp                     # NameError -- le corps de boucle, lui, scope

Le mode VM rendait None au lieu de lever, ce qui rendait indistinguables « sortie de portée » et « vaut nil » : ni x ?? defaut ni un test de nullité ne pouvaient faire la différence. Trois écarts tombent avec : le while n'ouvrait aucun scope en mode VM, le match rendait 20 en VM contre une erreur en AST, et une condition constante changeait la portée — if True { x = 1 } était réduit au bloc { x = 1 }, qui scope, donc le même fichier ne rendait pas la même chose selon -o level:0 ou level:1.

Une liaison nichée sous un if ou un match appartient à la portée qui les contient (BREAKING) : ce qu'ils lient, affectations comme captures de pattern, appartient à la portée englobante. Quand cette portée est un bloc nu, la liaison meurt donc avec le bloc.

c = 1 == 1
if True { if c { x = 1 } }
x                             # → 1

{ if c { y = 1 } }
y                             # NameError -- le bloc nu scope ce que le if y lie

La règle vaut dans les deux moteurs et à tous les niveaux d'optimisation. Auparavant la liaison fuyait au module, ou au contraire levait NameError à partir du niveau 1 alors que le niveau 0 rendait le bon résultat.

Le moteur pur garde ses globals après un raise rattrapé dans un bloc : après un try/except dans un bloc de module, le bloc englobant fermait la portée d'un autre et supprimait un global pré-existant.

Pipeline.execute_quiet donne au code la même portée que execute : il enveloppait les statements de premier niveau dans un bloc, si bien que les liaisons de module devenaient des liaisons de bloc — qu'une closure capture par valeur au lieu de les résoudre à l'appel. Un hôte passant par cette API n'obtenait pas la sémantique de portée du CLI.

seuil = 10
f = (x) => { x > seuil }
seuil = 20
f(15)
# ⇒ False

Annotations de type

Un champ annoté est vérifié à chaque écriture, plus seulement au constructeur (BREAKING). struct P { x: int } refusait P("oops") et acceptait p.x = "oops" : l'annotation décrivait la façon dont la valeur avait été construite, pas l'état de l'objet, donc aucun invariant ne survivait à la ligne suivante. La coercion suit la même règle, et c'est l'autre moitié du défaut : Q(3) sur un champ x: float stockait 3.0 là où q.x = 3 stockait 3.

struct Q { x: float }
q = Q(1.0)
q.x = 3
q.x
# ⇒ 3.0
struct Q { x: float }
q = Q(1.0)
q.x = "non"
# TypeError: field 'x' of 'Q' expects 'float' but got 'str'

Un champ sans annotation ne paie rien et continue de tout accepter. Les trois moteurs répondent la même chose. Mesuré sur le dépôt entier avant d'être rendu bloquant : aucun code existant ne violait ce contrat.

Identité de type

Deux structures sont le même type iff même nom et même forme (BREAKING) : ==, __hash__, in et les clés de dict comparaient deux instances par le nom du type. Deux déclarations de même nom mais de formes différentes étaient donc confondues, et un dict rendait la mauvaise valeur.

struct P { x }
a = P(x=1)
struct P { y }   # même nom, forme différente
b = P(y=1)
d = dict() ; d[a] = "va" ; d[b] = "vb"
list(a == b, len(d), d[a])

rendait [False, 1, 'vb'] en VM — a == b répondait faux mais a in list(b) répondait vrai — et [True, 1, 'vb'] en AST. Les trois moteurs rendent désormais [False, 2, 'va'], moteur pur compris.

La forme couvre les champs, les contrats, les méthodes et la hiérarchie. L'identité est portée par une signature de forme, un condensé stable qui voyage avec l'instance : elle reste juste après la fin de l'exécution et entre registres (le retour d'un worker ND).

Changement de comportement : deux déclarations identiques de struct P { x } sont maintenant le même type (égales, une seule clé de dict). Python les distingue par l'identité de la classe ; Catnip choisit l'identité de forme. Divergence assumée, au même titre que la portée de la variable de boucle. Une structure venue d'un module importé garde la sienne dans tous les cas.

Le nom de type d'un pattern se résout comme une référence de nom (BREAKING) : match a { P{x} => … } traitait P comme un littéral — le pattern matchait toute instance dont le type s'appelait P, quelle que soit sa forme. Désormais P est résolu dans la portée (locals puis globals) et le pattern matche par l'identité de forme du type résolu. Trois conséquences, alignées sur Python :

  • un alias ou un paramètre tenant un type matche à travers lui :

```catnip struct Point { x; y } Alias = Point match Point(1, 2) { Alias{x, y} => { x + y } } # 3

check = (T, v) => { match v { T{x, y} => { x + y } _ => { -1 } } } check(Point, Point(3, 4)) # 7 ```

  • un P redéfini à une forme différente n'attrape plus les anciennes instances — le pattern exige la forme du P courant, le même critère que ==. Une redéfinition identique matche encore ;

  • un nom non lié dans un pattern lève NameError, un nom lié à une non-structure lève TypeError (match p { Q{x} => … } avec Q = 42). Avant, ces cas retombaient silencieusement sur « pas de match ».

Les variantes d'union gardent le filtrage par nom qualifié "Type.Variante". Un objet Python générique ne matche plus un pattern de structure par simple homonymie de classe : il faut une instance de la structure résolue, comme l'isinstance de Python.

Un nom qui désigne une structure suit la forme, pas le nom : après A = P, redéclarer P à la même forme change ce que A(...) construit — corps des méthodes, valeurs par défaut et init viennent de la déclaration courante. Redéclarer P sous une autre forme en fait un autre type, et A garde la sienne : elle faisait auparavant construire à A(...) une instance de la nouvelle déclaration, qui perdait les méthodes qu'elle apportait et gagnait les champs qu'elle n'avait pas.

struct P { x ; get(self) => { 1 } }
A = P
struct P { x; y }
A(5).get()        # avant : "'P' has no attribute 'get'"  |  maintenant : 1

Une valeur qui repasse par Python et revient est rattachée au type de sa forme ; si plus aucune structure n'a cette forme, elle garde son propre comportement au lieu d'en emprunter un autre.

Un type déclaré dans un callback ND remonte et fonctionne : le type meurt avec le callback, mais ses instances remontaient en nommant un identifiant que le parent ne connaît pas — et que le prochain type déclaré reprend.

struct Box { inner }
res = range(0,4).[~> (n) => { struct P { x; y; z }; Box(inner=P(x=n, y=n, z=n)) }]
struct R { u; v; w; t }
res[0].inner

Trois issues selon la forme du type qui héritait de l'identifiant : plus large, la lecture d'un champ terminait le processus ; de même largeur, elle rendait les bonnes valeurs sous les mauvais noms de champs ; plus étroite, elle tronquait en silence. Aucun mode d'exécution n'y échappait. La construction ci-dessus rend maintenant P(x=0, y=0, z=0), et une instance nichée dans un champ ou une liste remonte comme une autre. Changement de comportement : cette construction levait une erreur, ou pire corrompait ; elle produit désormais une valeur.

Le nom aussi remonte, et dans les trois moteurs : P(x=1) au premier niveau construit le type que le callback a déclaré. Le moteur par défaut le perdait en mode thread — le type atteignait bien l'appelant, ses instances se lisaient, mais le nom était lié dans les variables d'un worker qui meurt avec sa tâche, si bien que le même programme répondait NameError en thread et le type en sequential. Un nd_mode choisit où un callback s'exécute, jamais ce que le programme répond. Conséquence assumée : si l'appelant avait son propre P d'une autre forme, c'est celle du callback qui répond ensuite — une déclaration remontante remplace l'homonyme, comme une redéclaration ordinaire.

En mode process, une instance qui revient d'un worker est reconstruite sur le type de sa forme, ou pas du tout : quand aucun type de cette forme n'existe côté appelant, le lot repasse par le mode thread. Auparavant l'instance était reconstruite sur l'homonyme courant, ses champs non appariés laissés vides.

Redéclarer une structure ne fait plus grossir la mémoire sans fin : chaque exécution d'une déclaration struct enregistrait un type de plus, définitivement, et gardait en vie ses méthodes — donc tout ce que leurs closures avaient capturé. Une boucle qui déclare une structure à chaque tour coûtait 112 Mio sur 200 000 tours, sans rien rendre avant la fin du pipeline : une REPL, une session MCP ou un serveur grossissaient sans borne. Une déclaration reprend désormais l'emplacement d'une définition de même nom dont plus aucune instance vivante ne dépend — les mêmes mesures donnent 0 et 1 Mio. Une instance encore en vie garde sa définition, ses champs, ses valeurs par défaut et le corps de ses méthodes.

Structures, traits et init

Une structure reste la même instance quand elle traverse une frontière (BREAKING). Elle devient un proxy pour passer côté Python ou pour être capturée par une closure, et ce proxy répondait depuis sa copie de champs : deux vérités pour une seule instance. Une fonction Python qui mutait la structure reçue écrivait dans le vide en mode VM (99 en AST, 1 en VM) ; une structure locale capturée par une closure imbriquée devenait privée à cette closure — chaque closure avait la sienne, une mutation faite après la capture y était invisible, et une accumulation ne sortait jamais. Les listes n'ont jamais eu le problème, et une structure globale échappait à la conversion, donc l'incohérence était interne au mode VM :

struct B { v }

g = () => {
    c = B(0)
    f = (y) => { c.v = y }
    f(1)
    c.v
}
g()
# ⇒ 0 avant, 1 maintenant, comme en mode AST

Le proxy est désormais une vue sur son instance tant que le moteur qui la détient est vivant, et sa copie de champs ne répond qu'ensuite — c'est ce qui permet à une closure de survivre à sa session. Un appel de méthode sur une structure capturée suit la même règle. L'identité est préservée : a.nxt is a reste vrai sur une structure cyclique.

Lire ou écrire un champ d'une structure capturée est aussi plus rapide : l'accès repassait par Python pour lire une valeur que le moteur détient, il reste maintenant natif. Mesuré à 206 ns contre 584 en lecture, 178 contre 291 en écriture. Un accès d'attribut sur un objet Python ordinaire paie 20 ns de plus pour la vérification qui le décide.

Un trait ne porte pas de champs (BREAKING) : trait T { x = 99 } était accepté par le mode VM, qui transmettait le champ à la structure, et levait AttributeError en mode AST — et là où il passait, il était faux : trait T { x = 99 } donnait True au lieu de 99. Un trait déclare des méthodes ; l'état appartient à la structure et se transmet par extends. La déclaration est maintenant une erreur de parsing qui nomme le remède. Aucun trait à champs n'existait dans la documentation, les exemples ou les tests.

Une erreur sur le nom d'un trait explique sa cause : trait T { ... } puis T levait le même NameError générique qu'une faute de frappe. Un trait n'est pas une valeur — contrairement à struct, enum et union — et le message le dit désormais, en indiquant implements(T).

Le post-constructeur init s'exécute une fois, à la construction : il était traité à plusieurs endroits, tous faux, et chaque moteur avait son angle mort.

  • En mode AST il était rejoué sur toute instance renvoyée par un appel — une instance simplement passée à une fonction voyait son init relancé.

catnip struct C { x = 0; init(self) => { self.x = self.x + 1 } } g = (c) => { c } a = C() list(a.x, g(a).x, g(g(a)).x) # [1, 1, 1], et non [1, 2, 3]

Changement de comportement (AST) : passer une instance à une fonction ne relance plus son init. La documentation l'exigeait déjà — « init est appelée automatiquement après l'assignation des champs » (docs/lang/STRUCTURES.md) —, c'est un post-constructeur, pas un post-appel.

  • En mode VM, une structure atteinte à travers un module (lib.T()) n'exécutait pas le sien du tout. Et quand le type était porté par une fonction du registre courant (b.ty(3), list.[~> T]), le VM exécutait l'init sur une copie native privée et rendait l'instance d'avant — la mutation était perdue en silence.

catnip struct T { a; init(self) => { self.a = self.a + 100 } } struct Box { ty } Box(T).ty(3).a # 103, et non 3

  • En AST, une structure qui n'écrit pas son propre init n'exécutait pas celui qu'elle hérite. Et sur les deux moteurs, quand l'init primaire est lui-même hérité, sa chaîne coopérative repartait du type de l'instance et rejouait un maillon.

catnip struct A { x = 0; init(self) => { self.x = self.x + 1 } } struct B extends(A) { init(self) => { super.init(); self.x = self.x * 10 } } struct C extends(A) { init(self) => { super.init(); self.x = self.x + 100 } } struct D extends(B, C) {} D(0).x # 1010, et non 10100

Le super démarre désormais juste après le type qui déclare l'init exécuté, pas après le type de l'instance.

Une structure importée exécute son init comme une structure locale.

in, index, count et remove suivent l'égalité que la structure définit : un op == personnalisé gouvernait == mais pas ces quatre-là, qui comparaient les champs. Un même programme pouvait donc répondre que deux valeurs sont égales et que l'une n'est pas dans une liste contenant l'autre. Les comparaisons imbriquées suivent aussi (list(a) == list(b) consulte l'égalité des éléments) ; les clés de dict et de set restent comparées par leur contenu. Au passage, une structure définissant à la fois op == et op_hash est de nouveau utilisable comme clé — elle était refusée, avec un message affirmant qu'il lui manquait le op_hash qu'elle avait.

Les structures cycliques ne font plus tomber la VM : a.next = a suivi de n'importe quel accès (a.next.value) débordait la pile — SIGSEGV, sans broadcast, là où l'exécuteur AST répondait. L'instance rendue à Python est cyclique comme sur l'AST (r.next is r), et une instance ne produit qu'un objet quel que soit le nombre de chemins qui y mènent. Listes chaînées et graphes se déclarent donc sans détour (docs/lang/STRUCTURES.md).

Une déclaration rend None partout : struct P { x } en dernière position rendait None via l'API Python et <type #13> via PurePipeline et catnip-mcp. trait, enum et union rendaient déjà None.

Déclarer un type dans un corps de boucle ne casse plus l'itération : for i in list(1, 2) { struct P { x }; 0 } levait ForIter: object of type 'NoneType' is not a valid iterator, la valeur retirée en trop étant l'itérateur de la boucle. struct, trait et enum étaient touchés, union non. while et for sur un range échappaient au défaut, et un bloc-expression réduit à une déclaration (v = { struct P { x } }) relevait de la même cause.

Une méthode de struct ne libère plus la variable qu'elle capture : définir un struct portant une méthode, à l'intérieur d'une fonction ou d'un callback qui détient par ailleurs une variable non scalaire, terminait le processus (ObjectTable: dead handle). L'abandon était le cas favorable : sur un emplacement déjà recyclé, la même double libération frappait un autre objet, silencieusement. ~~ déclenchait cette classe sans variable apparente, son paramètre recur étant lui-même une valeur non scalaire.

Broadcast

Écrire un attribut d'une struct capturée dans un callback ND lève, dans les trois exécuteurs (BREAKING). Le même programme faisait trois choses différentes : la VM perdait l'écriture en silence, l'AST et le moteur pur écrivaient à travers. b.v = i dans un ~> laissait donc b.v à 0 ou le portait à 2 selon l'exécuteur, sans un mot :

RuntimeError: cannot mutate 'B' inside a parallel ND callback: the captured scope is read-only

C'est le contrat qu'E205 énonce au lint, appliqué à l'exécution. « Capturée » s'entend : la struct préexiste au callback et n'est pas atteignable depuis ses arguments. L'élément reçu, la seed d'un ~~, et toute struct construite dans le callback restent mutables — leur copie est privée. Hors périmètre, inchangés : bag[i] = x sur une liste capturée reste write-through (condamné par E205 au lint seulement), et le broadcast régulier .[f] garde son écriture à travers.

La garde vaut quelle que soit la façon dont l'écriture atteint la capture. Le passage par argument nommé (h(q=b)) la contournait en VM, parce que la struct y perdait son identité et repartait sur une copie privée ; une rentrée par Python (sorted(xs, key=f)f mute la capture) la contournait aussi, la VM n'armant sa garde que pour le premier appel du callback. Les deux écritures mouraient en silence. La provenance ne compte plus : la garde date la naissance de l'instance au lieu de lire sa provenance, ce qui laisse mutable la structure que le callback a lui-même construite, jusque dans un broadcast imbriqué.

La copie privée d'élément est profonde en VM, comme la spécification l'énonce. « La copie descend dans les champs struct imbriqués » (spécification du broadcast) — l'AST et le moteur pur le faisaient, la VM matérialisait en surface : e.q.x = 5 sur un élément reçu était visible dans le callback puis perdu au retour, r[0].q.x rendant 1 là où l'AST rend 5. La copie couvre maintenant la fermeture des champs struct (identités partagées et cycles préservés). Conséquence : une struct à la fois élément et capturée ([b].[~> (s) => { b.v = 42 }]) partageait le même emplacement interne et l'écriture par le nom se perdait ; l'élément copié étant désormais distinct, la garde ci-dessus lève.

Le repli du broadcast régulier écrit à travers, comme son chemin rapide. .[f] est write-through par conception, mais son chemin de repli — pris pour un opérateur méthode liée, ou pendant l'enregistrement d'une trace JIT — clonait les structs et jetait les écritures au retour : range(3).[m.inc] incrémentant une struct capturée laissait le compteur à 0 en VM (3 en AST), et le même programme rendait 298 ou 300 selon que le JIT enregistrait. Les mutations des emplacements clonés sont maintenant réinstallées dans le parent au retour — dans les callbacks ND rien ne change, la garde y refuse l'écriture avant.

str et bytes sont scalaires dans un broadcast (BREAKING) : la classification des opérandes du runtime de broadcast (docs/lang/BROADCAST_RUNTIME.md) range str parmi les scalaires depuis toujours, et "hello".[* 3] le respectait. Mais ~> itérait les caractères, et bytes n'était scalaire nulle part.

b"hi".[* 2]                    # b'hihi', et non [208, 210]
"hello".[~> (c) => { c }]      # "hello", et non ['h', 'e', 'l', 'l', 'o']

Ce qui change pour du code existant : un ~> sur une chaîne recevait chaque caractère, il reçoit maintenant la chaîne. La forme qui itère les caractères s'obtient en la demandant — list(*"hello") déplie la chaîne en éléments.

Une instance de structure est une feuille pour ~> et ~~ : la même table la donne scalaire, et .[f] la traitait déjà comme telle — mais ~> et ~~ tentaient de l'itérer et levaient 'CatnipStruct' object is not iterable.

struct P { v }
P(1).[~> (p) => { p.v * 10 }]   # 10, et non une erreur

~> atteint bien chaque feuille scalaire, quelle que soit la profondeur : sur une structure imbriquée à trois niveaux ou plus, data.[~> f] s'arrêtait après un seul niveau et passait la sous-liste au callback. Avec une lambda arithmétique, rien ne levait — [1, 2] * 10 est une répétition de liste en Python — donc le résultat était faux tout en gardant la bonne forme. Le broadcast opérateur (data.[* 10]) descendait déjà correctement.

list(list(list(1, 2))).[~> (x) => { x * 10 }]   # [[[10, 20]]], et non [[[1, 2, 1, 2, ]]]

Un broadcast liste-à-liste de tailles inégales lève : list(1,2,3).[+ list(10,20)] s'arrêtait au plus court dans la spécification, mais aucun des trois exécuteurs ne l'a jamais fait — ils lèvent tous ValueError: Broadcast size mismatch: 3 vs 2. C'est la spécification qui est corrigée : un résultat plus court qu'attendu ne se distingue pas d'un résultat juste. Le moteur pur, lui, n'implémentait pas du tout la forme liste-à-liste et traitait l'opérande comme un scalaire ; il aligne maintenant récursivement (matrice.[+ vecteur] ajoute le vecteur à chaque ligne).

Un filtre dont le prédicat compare à une collection lève : a.[if > b] avec b une liste rendait, en mode VM, le masque de la comparaison — [True, False, True] — c'est-à-dire qu'il ne filtrait rien tout en ayant l'air de répondre. Le map correspondant reste défini, et son masque s'utilise directement : a.[a.[> b]].

Trois formes ne marchaient que hors du moteur pur Rust — celui qui sert catnip-mcp, le serveur LSP et PurePipeline.

  • Le masque booléen data.[list(True, False, True)] levait IndexError: index 10 out of range. Les sept cas limites — masque vide, masque non booléen, taille incompatible, cible tuple, masque tuple, cible scalaire — rendent désormais la même valeur et le même message dans les trois exécuteurs.

  • Un set ou un dict en cible : la spec dit depuis toujours qu'ils sont itérés sans préservation de type — résultat en list, le dict parcourant ses clés. Le moteur pur levait, ou laissait le conteneur passer intact, et n'entrait pas davantage dans un dict ou un set imbriqué.

catnip set(10, 20, 30).[* 2] # [20, 40, 60] dict(a=1).[* 3] # ['aaa'] -- la clé, pas la valeur list(dict(a=1), dict(bb=2)).[~> (k) => { k }] # [['a'], ['bb']]

  • Un set ou un dict contenant une structure faisait paniquer le moteur en l'affichant : la construction passait, le repr abattait le programme.

catnip struct S { v } print(set(S(1), S(2))) # {S(v=1), S(v=2)}, au lieu d'un arrêt brutal

Une réassignation faite dans un callback remonte à l'appelant : count = count + 1 dans un callback de broadcast ou de ND-récursion laissait count inchangé en mode VM, alors que le mode AST voyait l'incrément.

count = 0
range(1, 5).[~~ (n, recur) => {
    count = count + 1
    n
}]
count        # 4, et non 0

Le lint refuse ce motif par ailleurs (E205, ci-dessous) : ce qui tourne doit d'abord tourner pareil, le diagnostic vient ensuite dire qu'il vaut mieux ne pas l'écrire.

Le linter rejette une écriture prouvée dans le scope englobant depuis un callback ND (E205 fatal, W205 en soupçon, BREAKING) : depuis un callback ~~/~>, le scope capturé est en lecture seule. Réassigner (count = count + 1) ou muter en place (bag.append(x)) un nom capturé ne remonte pas de la même façon selon le mode nd_mode — en thread/process l'écriture est concurrente et non déterministe. Le diagnostic se déclenche dans les trois modes : un code correct ne doit pas changer de résultat avec le mode d'exécution. Replier l'accumulation via une réduction (reduce, fold). Il sort au niveau error, donc catnip lint termine en échec.

Est fatal ce qui est prouvé : réaffectation, écriture par indice ou attribut (bag[i] = x, b.v = x), méthode mutatrice sur une collection dont la définition est visible ([...], dict(...), …), ou méthode d'une struct du fichier qui assigne self. Une lambda nommée passée à l'opérateur ND est analysée comme si elle était écrite sur place — la forme f = (x) => { total = total + x } puis .[~> f] passait sans un mot, avec un résultat dépendant du mode. Le nom de méthode seul ne condamne pas : v.add(i) sur une struct dont le add construit une valeur neuve est une lecture pure. Le receveur dont le type est indéterminable descend en W205, un avertissement. Mesuré sur le corpus du dépôt — 305 fichiers .cat et 605 extraits de documentation — aucun fichier concerné dans un sens ni dans l'autre.

reduce agrège par monoïdes nommés : reduce(data, sum) réduit par le monoïde sum, et reduce(data, sum, len, max) rend (23, 8, 9) en une passe — un multi-accumulateur sans écrire de fold à tuple. Monoïdes reconnus : sum, len, max, min, any, all, concat (le comptage passe par len). Un callback lambda reste sur le reduce(data, f) classique — c'est l'identité de la fonction, pas son ==, qui distingue un monoïde d'un callback, donc un callback dont __eq__ lève n'atteint jamais la comparaison. Sur une collection vide, un monoïde à élément neutre le rend (sum → 0, any → False, all → True), les autres lèvent comme reduce.

Un opérateur de broadcast qui est une valeur hôte ne retient plus ses éléments : xs.[d] (un dict utilisé comme table de correspondance) et xs.[str] (un builtin) laissaient une référence par élément derrière eux, dans le moteur pur, où le processus vit longtemps. Mesuré : 4001 allocations retenues pour 2000 itérations, croissance linéaire.

Récursion ND et mode process

import() depuis un callback ND parallèle est refusé au lieu d'abandonner le processus. Le chargeur d'imports est lié au thread principal ; l'atteindre depuis un worker faisait avorter le processus — pas d'exception, pas de trace, rien à rattraper. Deux exemples livrés le faisaient. Le refus porte désormais sur l'appel plutôt que sur une lecture du corps du callback, donc il vaut aussi quand l'import est atteint à travers une fonction appelée, par un nom résolu à l'exécution, ou quand le chargeur est confié à une fonction Python qui l'appelle :

RuntimeError: import() is not supported inside a thread-parallel ND callback: the import loader is bound to the main
thread. Use pragma('nd_mode', 'sequential'), or hoist the import out of the callback.

Nommer import sans l'appeler ne déclenche plus rien : la version précédente lisait le corps du callback et rejetait tout un broadcast sur la seule présence du nom.

Le mode ND process s'exécute vraiment en processus : pragma("nd_mode", ND.process) retombait silencieusement sur le chemin thread pour tout programme, un garde trop large étant déclenché en permanence par les builtins Python pré-amorcés et par le chargement automatique du module io. Les résultats restant corrects, rien ne le signalait : ni erreur, ni test, seulement l'absence du parallélisme demandé.

Un mode ND qui retombe sur un mode plus simple le signale : quand un élément ne peut pas honorer le mode demandé, le runtime rejoue sur thread puis sequential. Le résultat reste correct, mais le parallélisme demandé n'a pas eu lieu. Deux familles de repli, désormais distinctes : le repli préventif (lambda ou seed non sérialisable, binaire worker absent) est un choix, pas une panne, et reste muet ; le repli réactif (le worker a démarré puis échoué) émet un CatnipNDFallbackWarning — une fois par batch — nommant le mode demandé, le mode effectif et la cause. Le warning se promeut en erreur fatale côté hôte :

import warnings
from catnip.exc import CatnipNDFallbackWarning
warnings.filterwarnings('error', category=CatnipNDFallbackWarning)

Trois replis silencieux se referment avec :

  • Un callback ND récursif s'exécute vraiment en mode process : le worker natif liait le handle recur à NIL, donc tout ~~ qui recursait mourait dans le worker sur 'NoneType' object is not callable et le batch rejouait en thread.

catnip pragma("nd_mode", ND.process) list(3, 4, 5, 6).[~~ (n, recur) => { if n <= 1 { 1 } else { n * recur(n - 1) } }] # [6, 24, 120, 720], calculés en parallèle

  • Un callback ND en mode process peut construire et matcher les types du parent : un worker recevait les définitions de type, mais rien ne liait leurs noms — P(x=n) levait NameError et un pattern P{x} ne matchait pas. Un worker ne gardait par ailleurs qu'un seul type par nom là où le parent peut en avoir plusieurs vivants, et le parent n'expédiait aucune définition tant qu'aucune structure n'entrait dans le lot.

catnip pragma("nd_mode", ND.process) struct P { x } range(0, 4).[~> (n) => { P(x = n * 10) }] # [P(x=0), P(x=10), P(x=20), P(x=30)], en parallèle

Un nom réassigné garde son sens : struct P { x } suivi de P = (n) => { n * 100 } fait que P désigne la fonction, dans le worker comme ailleurs. Et un type que le lot ne peut pas transporter (héritage, trait, méthode abstraite) n'empêche plus les autres de partir — un seul extends dans le programme suffisait à renvoyer tous les callbacks sur le chemin séquentiel, sans le dire.

  • Un callback ND lit les globals du parent en mode process (exécuteur AST) : sous -x ast, process passe par un pool de processus Python, et une closure résout ses globals contre le scope qui l'a définie — qu'un processus neuf n'a pas. Le callback levait NameError sur le moindre nom global. Les globals picklables partent maintenant avec la tâche ; une instance de struct en fait partie et se reconstruit détachée de l'autre côté en gardant sa signature de forme. Un global qui ne pickle pas — un module, un objet Python opaque — reste absent, et ce cas-là replie toujours.

Au passage, une CatnipNameError remontée d'un worker process garde son message : pickle la reconstruisait à partir du message formaté plutôt que du nom, doublant le format (Name "Name 'x' is not defined" is not defined).

pragma("nd_workers", N) dimensionne le pool : la valeur était analysée et transportée, mais jamais lue à la construction du pool, qui prenait invariablement le parallélisme de la machine — nd_workers 2 lançait autant de processus que de cœurs. 0 conserve le dimensionnement automatique.

Le binaire autonome applique pragma('nd_mode', ...) : la directive était analysée, validée — une valeur inconnue était bien signalée — puis ignorée par le binaire catnip, qui exécutait en séquentiel sans le dire. Seule la CLI Python la transmettait au moteur. nd_workers était dans le même cas.

Redéclarer un type du parent dans un callback ND thread ne tue plus le processus : déclarer dans le callback une structure homonyme d'une du parent terminait le programme au démontage (ObjectTable: dead handle). Le résultat était juste ; une seule instance suffisait.

pragma("nd_mode", ND.thread)
struct Outer { s; n }
list(1, 2).[~> (k) => { struct Outer { s; n }; k }]   # rendait [1, 2] puis abandonnait le processus

import() dans un callback ND parallèle lève une erreur nommée au lieu d'abandonner le processus : un callback qui appelle import(...) déréférençait le chargeur d'import — lié au thread qui l'a créé — depuis un worker, et terminait le programme (ImportLoader is unsendable, but sent to another thread). L'exécution parallèle refuse désormais ce callback explicitement. Contournement : pragma("nd_mode", "sequential"), ou sortir l'import du callback. La détection couvre l'appel direct dans le corps du callback ; un import atteint à travers une fonction que le callback appelle n'est pas encore vu et abandonne toujours.

Un pipeline ND thread est plus rapide, et son coût ne dépend plus du nombre de variables de module : chaque tâche recevait une copie de ces variables — une centaine d'entrées avant la première ligne de Catnip — et un verrou par entrée, à l'aller comme au retour. La copie a disparu, un callback résolvant ces noms chez le parent. Mesuré sur 20 000 éléments : 63 % de moins sur un callback trivial, 72 % avec cinquante variables de module.

Une déclaration ND se diffuse comme la lambda qu'elle porte : f = ~~ lambda puis data.[~~ f] levait TypeError: NDDeclaration.__call__() takes 1 positional arguments but 2 were given, seule la lambda écrite sur place fonctionnant. Le broadcast récupère maintenant la lambda de la déclaration, dans les trois modes ND et sur les deux exécuteurs.

factorial = ~~ (n, recur) => {
    if n <= 1 { 1 }
    else { n * recur(n - 1) }
}

list(5, 6, 7).[~~ factorial]
# ⇒ [120, 720, 5040]

Exceptions et profondeur de pile

Une exception Python est rattrapable par son propre type : une exception levée par du code Python appelé depuis Catnip — ArithmeticError, FileNotFoundError, la classe d'exception d'une bibliothèque — n'était reconnue que si son nom figurait dans la table interne des types Catnip. En VM, tout le reste arrivait aplati en RuntimeError : except { e: ArithmeticError => ... } ne la voyait pas. En AST, c'était plus radical — un type absent de la table faisait sauter l'examen des handlers, si bien que même le catch-all _ la laissait s'échapper. Le rattrapage lit désormais la hiérarchie déclarée par Python, donc except { e: OSError => ... } intercepte un FileNotFoundError comme en Python, et le message lié dans e est le message seul, sans nom de type ajouté devant.

Un try n'avale plus le Ctrl-C : en VM, KeyboardInterrupt était traité comme une exception ordinaire, donc except { e: Exception => ... } — et a fortiori _ — l'interceptait ; un programme dont la boucle principale tourne dans un try devenait ininterruptible. Les exceptions qui dérivent de BaseException sans dériver d'Exception ne sont plus rattrapables par aucun handler, catch-all compris, comme en Python. Les blocs finally s'exécutent toujours.

Le suivi des fonctions pures ne touche plus au programme : après chaque exécution, l'hôte relisait un attribut sur chaque variable globale pour repérer les fonctions marquées @pure. Sur un objet injecté qui définit __getattr__ — un proxy, un mock, une ligne d'ORM — cette lecture exécutait du code, avec deux conséquences : 1 + 1 levait ValueError en présence d'un tel objet sans que le programme y touche jamais, et un Ctrl-C tombant pendant la lecture partait avec ce qu'elle ignorait. L'attribut est désormais lu directement dans le dictionnaire d'instance. Un hôte qui exposerait ce marqueur en propriété de classe plutôt qu'en attribut d'instance n'est plus reconnu ; le décorateur @pure pose un attribut d'instance et n'est pas concerné.

Un échec de lecture d'attribut garde son type : en mode VM, un attribut absent sur un objet Python produisait une erreur dont le type n'était inscrit que dans le message, donc except { e: AttributeError => ... } ne la voyait pas. Et sur un objet injecté depuis Python, un @property qui lève ValueError remontait en AttributeError: ValueError: bad value. Did you mean 've'? — en suggérant l'attribut qui existe et qu'on venait de demander. Seuls les vrais attributs absents sont désormais traités comme tels, et la suggestion de nom proche leur reste réservée.

Un raise nu relève l'exception, pas son étiquette : dans un handler, réémettre l'exception attrapée (except { e: RuntimeError => { raise } }) remontait RuntimeError: boom en VM là où l'AST rendait le nu boom — et un re-raise de TypeError ressortait en CatnipRuntimeError au lieu de CatnipTypeError. Les trois chemins de re-raise (bare raise, unwind après finally, fallback quand aucun except ne matche) restaurent maintenant la variante dédiée ; les types user/groupe gardent la leur pour préserver leur MRO. Un aller-retour Python↔VM n'accumule plus un RuntimeError: à chaque tour.

Une erreur qui traverse une récursion ND garde son type et son message : sous ~~, .[~~] et .[~>], chaque niveau réenveloppait l'erreur du niveau précédent. Un nom indéfini au fond d'une récursion ressortait en RuntimeError: RuntimeError: NameError: name '...' is not defined, invisible à un except { e: NameError => ... } placé autour. Même chose après un cap de récursion rattrapé à l'intérieur de la lambda : le drapeau d'abandon restait armé jusqu'à la fin de l'appel, et toute erreur ultérieure sans rapport ressortait comme l'erreur de plafond.

Sortir d'un with par return/break/continue libère le gestionnaire une seule fois : __exit__ était appelé deux fois sur un return, et un break depuis un with placé dans une boucle for terminait le processus. Sur un verrou ou une ressource refcomptée, ce second appel est une sur-libération, pas un close() redondant. Le défaut n'était pas propre au with : try { break } except { ... } dans une boucle terminait le processus de la même façon. Un second défaut, symétrique, était masqué par le premier : break et continue exécutaient aussi les finally des try qui englobent la boucle, de sorte que try { for .. { break } .. } finally { .. } nettoyait deux fois. Un break s'arrête désormais aux gestionnaires installés dans sa boucle. Un return à l'intérieur d'un finally reste correct — il l'emporte sur celui qu'il interrompt — et termine, alors qu'il faisait déborder la pile du compilateur.

Un finally s'exécute après un handler qui sort par la bande : en mode AST, un except qui faisait return, break, continue ou qui relançait une exception quittait le try sans passer par son finally. Le bloc n'était pas supplanté, il était sauté.

f = () => {
    try { raise ValueError("x") }
    except { _ => { return 1 } }
    finally { ferme_les_fichiers() }     # ne tournait pas
}

Le cas le plus discret est un handler qui relance : l'exception repart vers l'appelant, et la libération des ressources que le finally protégeait n'a jamais lieu.

Débordement de pile

Une famille de SIGSEGV — un crash, pas une erreur rattrapable — sur les chemins qui récursent une frame native par niveau. Ils sont refermés en RuntimeError catchable, avec le même message dans tous les moteurs — y compris les binaires standalone (catnip-run, catnip-mcp), qui n'ont pas le trashcan de CPython pour amortir la récursion et débordaient là où l'extension était déjà protégée. Un except RuntimeError se comporte donc pareil partout.

  • Broadcast (.[...]) et ND-map (~>) : la pile C débordait sur une liste assez profonde (~14 400 niveaux pour 8 Mo de stack). Cap à 1000 niveaux, la limite de récursion par défaut de CPython ; aucun broadcast réel n'en approche.
  • Parsing : un littéral [[[...]]] écrit en source débordait la transformation parse-tree → IR, un chemin compile-time distinct du broadcast runtime. Même cap.
  • Affichage (str/repr/print) : le cap est plus bas (200), une frame de repr étant bien plus grosse, et le seuil vise la plus petite pile réaliste (2 Mo des workers async).
  • Récursion ND (~~) : plutôt qu'un plafond, la récursion fait croître sa pile à la demande, donc ~~ descend aussi profond que la mémoire le permet. Un garde-fou anti-emballement (10 000 niveaux) transforme une récursion sans cas de base en erreur rattrapable au lieu d'un OOM.

La libération n'a jamais été en cause : une liste imbriquée 500 000 fois se détruit sans broncher, et aucun cap ne s'applique à sa destruction.

La récursion ND terminale profonde ne fait plus tomber la VM, et son cap est un RecursionError attrapable : un ~~(seed, lambda) ou un broadcast .[~~ f] dont le corps rappelle recur(...) en position terminale — le cas usuel — débordait la pile (Unrecoverable stack overflow fatal) ou rendait un message re-préfixé des centaines de fois. La récursion tient maintenant sur une seule pile de frames interne, bornée par le cap, et les profondeurs sous le cap qui débordaient calculent leur résultat (docs/dev/VM.md). Le cap lui-même levait un RuntimeError côté VM et, côté AST, un RecursionError Python brut qui échappait au try/except de Catnip — même le joker _ ne l'attrapait pas. RecursionError rejoint la hiérarchie d'exceptions (sous-classe de RuntimeError, miroir de Python) : le cap est attrapable par except RecursionError et except RuntimeError, avec un message nu identique sur les deux exécuteurs, et le nom est résolvable comme valeur (positions except/raise, contextlib.suppress).

Valeurs, littéraux et opérateurs

Un attribut d'introspection ne se lit ni ne s'écrit depuis Catnip (BREAKING) : x.__class__, x.__globals__, x.__dict__ = ... et dix-neuf autres sont refusés à la lecture du fichier, avec leur position. La liste complète est dans Expressions ; typeof(x) remplace x.__class__.__name__.

La raison est la portée de ce qu'un objet Python donne : [].__class__.__bases__[0].__subclasses__() remonte d'un littéral de liste à toute classe chargée dans l'interpréteur, puis __init__.__globals__["__builtins__"] à open, exec et __import__. La chaîne part d'un littéral, donc elle traversait n'importe quel contexte, y compris restreint.

Les autres noms encadrés de soulignements restent accessibles : x.__len__(), col.__eq__(other), shape.__geo_interface__. Le refus vise la navigation, pas les protocoles. Refuser tout dunder fermait la même route mais emportait de l'interop qui n'a pas d'autre nom : __geo_interface__ est la façon dont l'écosystème GeoJSON s'écrit, et __eq__ celle dont un objet-expression évite le désucrage de !=.

Ces noms ne se déclarent pas non plus : champ ou méthode de structure, méthode de trait, membre d'énumération, variante d'union et champ de variante — sept sites qui acceptaient la déclaration et la construction, pour un membre que plus rien ne pouvait relire. C'est un refus et non un avertissement : le membre n'existerait qu'en écriture.

struct P { __class__ }     # SyntaxError: cannot declare '__class__': the name would not be readable back

Un nom qui ne devient jamais un attribut n'est pas concerné : __class__ = 1 reste une variable ordinaire, et un champ __len__ reste déclarable comme il reste lisible. Les protocoles appelés par le runtime ne bougent pas : with appelle __enter__ et __exit__, for appelle __iter__, la truthiness appelle __bool__. getattr(x, "__class__") reste un contournement en contexte normal ; un contexte restreint n'a ni getattr, ni vars, ni dir.

Un entier écrit plus grand que 64 bits reste exact : 99999999999999999999 valait 1e+20, et 9223372036854775808 == 9223372036854775809 répondait True — la lecture d'un littéral décimal retombait sur le flottant, qui accepte n'importe quelle suite de chiffres et la tronque à 53 bits de mantisse. L'arithmétique promouvait pourtant déjà comme il faut (2 ** 100 et 23! étaient exacts). Les trois bases préfixées (0x, 0b, 0o) suivent : 0xFFFFFFFFFFFFFFFF était rejeté par Invalid base-16 number là où l'écriture décimale du même nombre passait déjà.

Un décalage rend la même valeur, quel que soit le niveau d'optimisation : 1 << 64 valait 1, 1 << 200 valait 256 et 5 << 62 perdait son bit de poids fort. Écrits entre deux littéraux, ces décalages étaient calculés à la compilation sur un entier machine ; derrière des variables, le même calcul passait par le runtime, qui promeut en entier long et répondait juste. Le résultat dépendait donc de la forme du code et du niveau d'optimisation, sans rien signaler.

1 << 64          # 18446744073709551616
5 << 62          # 23058430092136939520

Deux écarts voisins suivent. Un décalage à droite plus large que l'entier machine (x >> 64) était refusé comme un type invalide, alors qu'il a une valeur : il sature à 0, ou -1 pour un nombre négatif. Et un compte négatif (1 << -1) rendait une valeur en compilation, un TypeError au runtime ; c'est un ValueError: negative shift count, comme en Python, quelle que soit la taille de l'opérande.

Un décalage à gauche par un compte qui ne tient pas sur 32 bits est refusé par son nom : 1 << 5000000000 rendait 1, le compte étant tronqué à 0. Python, lui, calcule — le résultat pèse 625 Mo. Le mode VM préfère refuser (shift count too large) ; le mode AST, qui délègue à Python, calcule. C'est la seule divergence assumée entre les deux moteurs sur cet opérateur.

Une base négative élevée à une puissance fractionnaire rend un complexe : (-8) ** (1/3) et (-8) ** 0.5 rendaient nan en mode VM. Python rend la racine principale complexe plutôt que de perdre le résultat — ce que le mode AST faisait déjà. Les exposants entiers ((-8) ** 2, (-8) ** -1) sont inchangés.

Un NaN reçu de Python se retrouve dans les collections : NaN est le seul flottant qui n'est égal à rien, pas même à lui-même, donc Python le retrouve par identité — règle qui vaut pour in, index, count, remove, les clés de dictionnaire et les ensembles. En traversant la VM il perdait son objet, et ces six opérations répondaient « absent » pour une valeur pourtant présente.

nan = float('nan')
l = list(nan, 1)
nan in l         # True
l.index(nan)     # 0

Deux float('nan') distincts restent distincts : c'est bien l'identité qui est rendue, pas une règle « NaN vaut NaN ». Les NaN issus d'un calcul (inf - inf) n'ont pas d'objet d'origine et n'en gagnent pas.

Un dict garde son ordre d'insertion : dict(a=1, b=2, c=3) et {'a': 1, 'b': 2, 'c': 3} étaient construits à l'envers en mode VM — visible à l'itération, sur keys(), values(), items() et à l'affichage. Python garantit l'ordre d'insertion depuis 3.7 et Catnip en hérite.

Les comparaisons ordonnées rendent ce que l'objet répond : <, <=, > et >= forçaient leur résultat à travers un test de vérité en mode AST. Sur deux tableaux numpy, a < b levait ValueError: ambiguous truth value là où le mode VM et Python rendent le tableau des comparaisons élément par élément.

np = import('numpy')
a = np.array(list(1, 2, 3))
b = np.array(list(3, 2, 1))
a < b        # [ True False False]

numpy échoue bruyamment ; une classe dont __bool__ ne lève pas voyait tout un tableau de résultats réduit à un seul True, sans rien signaler. Les comparaisons chaînées (a < b < c) restent booléennes : elles sont désucrées en and, et c'est le and qui décide, exactement comme en Python. == et != restent booléens dans les deux modes.

La VM traite une valeur à __bool__ ambigu comme Python : dans une condition (if/while), une négation (not), un and/or ou un filtre (.[if ...]), une valeur dont bool() lève — un tableau numpy, une expression Polars — passait silencieusement pour « vrai » dans la VM, là où l'AST et Python lèvent. La VM propage désormais l'erreur avec son type d'origine. Corollaire : un objet Python faux (liste vide, 0) dans un and/or s'évalue correctement au lieu d'être toujours pris pour vrai.

Une valeur par défaut peut être autre chose qu'une constante : f = (x = -5) => { x } liait x à None, et avec lui tout défaut qui n'était pas un littéral nu — 2 + 3, list(), une variable capturée, une variable du module. Un défaut non constant est désormais compilé comme du code exécuté à l'entrée de la fonction, ce qui lui donne accès à ce que voit le corps : une capture, un global, ou un paramètre précédent ((prix, remise = prix * 0.1)). Il est réévalué à chaque appel, donc une valeur par défaut mutable n'est jamais partagée d'un appel à l'autre. Un argument fourni continue de gagner, None compris.

Une liste de paramètres accepte une virgule finale : f = (x, y,) => { x + y } était refusé par Unexpected token ',', là où la même virgule passait déjà dans un appel (f(2, 3,)), dans des arguments nommés et dans un unpack ((a, b,) = ...). lambda_params était la seule règle à virgules de la grammaire sans virgule finale optionnelle. Les méthodes de structure et le paramètre variadique final suivent la même règle.

Le formatage des flottants de catnip-mcp suit Python : via PurePipeline et le serveur MCP, 1e20 s'affichait 100000000000000000000 au lieu de 1e+20. L'API Python et le mode VM étaient déjà corrects.

Mémoire, runtime et JIT

Les cycles de références sont réclamés au fil de l'eau : deux fonctions qui s'appellent l'une l'autre, ou une structure qui se référence elle-même (p.nxt = p, ou deux instances qui se pointent), se lient par des références croisées que le comptage de références ne peut pas défaire. Elles n'étaient libérées qu'à la mort de la VM : une fonction déclarant un groupe mutuellement récursif et appelée en boucle en accumulait un par appel — 1,85 Kio par appel, soit 370 Mo sur 200 000 appels, et au-delà d'environ 200 000 groupes retenus le programme s'interrompait en fin d'exécution, sa pile épuisée par la libération en cascade. Une boucle construisant des nœuds cycliques en retenait un par tour, sans limite. Les deux moteurs — celui par défaut et le moteur pur qu'utilisent catnip-run et catnip-mcp — les récupèrent maintenant sans toucher à ce qui reste joignable : une fabrique qui ne renvoie qu'un membre reste appelable, ses sœurs comprises, et un nœud cyclique encore accessible garde ses champs. Un cycle refermé par une liste, un dictionnaire ou une closure n'est pas couvert et reste retenu. Un cycle refermé dans un callback de broadcast ne l'était pas non plus : l'enregistrement vivait sur la VM du callback, qui meurt avec lui, et le composant n'était jamais proposé à la collecte. Une boucle de broadcasts en retenait un par élément, sans limite.

Deux fonctions sœurs déclarées dans une boucle ne s'accumulent plus : chaque passage retenait celui d'avant, une fonction capturant le nom sous lequel elle se lie elle-même — dont la valeur, à cet instant, est encore celle du passage précédent. 2 fonctions retenues par tour, cumulées d'un appel au suivant. Une déclaration seule dans la même boucle n'était pas concernée.

Un argument passé sur un paramètre à défaut calculé n'est plus retenu : appeler g(0, valeur) sur g = (a, b = k) => { .. } — un défaut qui lit une variable, pas un littéral — gardait une référence sur l'argument à chaque appel, sans borne sur une session longue. Le cas du défaut littéral (b = 1) n'était pas touché.

Le moteur pur libère ce qu'il lit : douze chemins du moteur sans Python — celui qu'utilisent catnip-mcp et PurePipeline — retenaient une valeur après s'en être servi. Le plus grave n'était pas une fuite mais une double libération : la récursion ND relâchait une graine que l'appel avait déjà consommée, ce qui corrompt le tas dès que la graine est autre chose qu'un nombre (formes f = ~~(lambda) puis f(p), et recur(p) avec p sur le tas ; la forme directe ~~(p, lambda) était équilibrée). Les onze autres sont des rétentions sur des chemins ordinaires : and/or et not sur un conteneur, if xs { .. } sur une liste, chaque interpolation de f-string, chaque import de module, quatre chemins d'erreur autour d'une clé non hashable, et le résultat d'un prédicat de filtre sur une cible scalaire. Le mode VM faisait déjà ces libérations.

Le moteur pur n'exécute plus une fonction à la place d'une autre : dans une session qui enchaîne les évaluations — REPL, serveur MCP, PurePipeline — une méthode déclarée après une fonction pouvait exécuter cette fonction.

f = () => { 42 }                       # première évaluation
struct P { x ; get(self) => { 1 } }    # deuxième
P(1).get()                             # avant : 42

Même chose pour une méthode d'union et pour une lambda imbriquée.

Quatre fuites d'un processus long-vécu (serveur, REPL, session MCP) sont fermées. Aucune n'était visible sur une exécution unique, et aucune ne demande de changement côté programme.

  • Les constantes d'un corps de fonction : execute_prepared() recompilait l'IR à chaque appel. Le bytecode est désormais compilé une fois par prepare(), ce qui supprime au passage une recompilation complète par exécution.

  • La table des fonctions : ~590 octets par exécution, jamais réclamés avant le teardown. Les fonctions runtime sont désormais refcomptées, comme les instances de struct. Au passage, un helper @pure local redevient éligible à l'inlining JIT.

  • La réassignation d'une variable de struct capturée dans une closure mutable (b = Box(k) après avoir lu b) : l'ancienne instance délogée n'était pas relâchée auprès du registre.

  • Un accumulateur de struct mutable dans une boucle assez chaude pour déclencher le JIT (cur = P(cur.v + 1)) plantait avec dead handle on decref. Un accumulateur scalaire n'était pas touché.

Le JIT ne peut plus abandonner le processus sur un conflit de type : une boucle chaude écrivant un flottant dans une variable qu'elle ne relit jamais faisait paniquer la génération de code natif. La trace est maintenant refusée : la boucle reste interprétée, ce qui est lent et non faux.

for i in range(0, 2000) { y = i * 1.5; 0 }    # terminait le processus

Deux dépendances corrigées en amont : crossbeam-epoch passe en 0.9.20 et ferme RUSTSEC-2026-0204 ; il arrive par rayon, qui porte le mode threads du broadcast ND. Cranelift passe en 0.134, ce qui remplace au passage un memmap2 signalé pour arithmétique de pointeur non vérifiée. Le cache JIT sur disque étant namespacé par la version de Cranelift, la première exécution après mise à jour recompile ses traces au lieu de les relire.

Pragmas

Un pragma sans valeur est refusé (BREAKING) : pragma("tco") était accepté sans un mot et n'avait aucun effet, alors qu'une directive inconnue à deux arguments était bien signalée. Toutes les directives prennent une valeur, donc l'arité 1 est maintenant une erreur, aux mêmes moments et dans les mêmes termes que les autres.

pragma("tco")     # ne faisait rien ; PragmaError: Pragma 'tco' requires a value

Le niveau d'optimisation 3 active le tier inter-blocs CFG+SSA ; le défaut passe à 2. Les niveaux 1, 2 et 3 étaient indistinguables : tout ce qui était supérieur à 0 activait les mêmes passes locales. Le niveau 3 désigne désormais le tier inter-blocs (LICM, hoist des définitions invariantes de boucle ; DSE globale ; GVN, qui subsume la CSE syntaxique), et le défaut s'arrête un cran en dessous pour que ce tier soit demandé et non subi.

catnip script.cat                # niveau 2 : passes locales
catnip -o level:3 script.cat     # ajoute le tier inter-blocs

pragma("optimize", 3) fait la même chose pour son fichier, avec la précédence habituelle (CLI et env l'emportent sur le pragma). Le passage du défaut de 3 à 2 ne change aucun comportement, puisque les deux niveaux étaient identiques avant ce câblage. Le tier était jusqu'ici joignable par une variable d'environnement interne, supprimée.

Sur une boucle de 300 000 tours contenant une redondance, le niveau 3 mesure −25 % en mode VM et −34 % en mode AST ; sur un script court, le coût de construction du graphe est dans le bruit. Limite connue : les passes ne descendent pas dans les arms d'un match.

Modules stdlib (io, sys, http)

Ces modules ont deux implémentations — un plugin natif servi au moteur pur Rust (donc au serveur MCP), un backend PyO3 servi au CLI Python — et jusqu'ici rien ne comparait ce qu'elles répondent au même code. http fait exception : il n'a que le backend natif.

sys.exit() suit la règle de CPython, et sort en échec pour un argument non entier (BREAKING) : en mode VM, sys.exit(0) affichait RuntimeError: exit(0) et terminait en 1 — un programme qui sort proprement était rapporté en échec. sys.exit("fatal") sortait en succès, message perdu, et sys.exit(True) aussi. Les deux exécuteurs suivent maintenant CPython, vérifié contre l'interpréteur de référence :

Argument Statut Sortie standard d'erreur
absent, None 0 --
un entier sa valeur (tronquée aux 8 bits de poids faible par l'OS) --
un entier hors d'un C long 255 --
toute autre valeur 1 str() de la valeur
sys = import('sys')
sys.exit("fatal")   # sortait en 0 ; imprime "fatal" et sort en 1

La troncature couvre toute la plage du long : la frontière entre entier machine et grand entier est à 47 bits, celle d'un int C à 32, et tout ce qui les sépare sortait en 255. sys.exit(2**47+3) rend maintenant 3. Le message part dans sys.stderr, donc une redirection le capte. sys.exit() reste insensible à except : c'est un BaseException, un joker _ => ne l'attrape pas, comme en Python. Vaut pour les deux modules sys (import('sys') et import('sys', protocol='py')) et pour un sys.exit() appelé depuis un callback de broadcast.

io.open lie ses huit paramètres, par position comme par nom : le plugin natif appliquait sep/end aux quatre fonctions de sortie, le backend PyO3 les refusait sur write et writeln ; à l'inverse, les six paramètres nommés que la spécification publie et que le backend PyO3 honore (encoding, buffering, errors, newline, closefd, opener) étaient refusés côté natif comme des noms inconnus. Et le backend natif ne lisait que les deux premiers paramètres positionnels, si bien que io.open(p, 'r', -1, 'latin-1') servait de l'UTF-8 sans rien dire. Le nom est désormais toujours accepté, et c'est la valeur qui est refusée quand le moteur pur ne sait pas la servir — il ne décode que l'UTF-8 et ne débraye pas son tampon :

io = import('io')
f = io.open("/etc/hostname", encoding='utf-8', buffering=-1)   # les deux moteurs
f.close()

open(buffering=0) répond buffering=0 is not supported by the native io backend (default only) au lieu de unexpected keyword argument, qui envoyait chercher une faute de frappe dans un nom que la spécification publie. Un neuvième argument et un nom donné deux fois sont refusés dans les termes de CPython (takes from 1 to 8 positional arguments but 9 were given, got multiple values for argument 'mode').

Les arguments nommés traversent la frontière des plugins (ABI v6, BREAKING) : io.print("a", "b", sep=", ") donnait deux résultats selon le backend, l'ABI ne transportant aucun nom d'argument. Elle transporte maintenant les noms et les valeurs, et les modules acceptent ceux de leurs équivalents Python : sep, end, flush sur print/eprint, flush sur write/writeln, mode sur open, code sur sys.exit.

io = import('io')
io.print("a", "b", sep=", ", end="!\n")   # ⇒ a, b!

Un nom inconnu est refusé, dans les termes de CPython (got an unexpected keyword argument 'nope'), et non ignoré. Ce qu'un plugin ne peut toujours pas recevoir, faute d'objets Python dans le moteur pur : file= sur print. La rupture est pour les plugins : le numéro d'ABI est vérifié à l'égalité stricte, donc tout .so non recompilé est refusé au chargement au lieu d'être lu de travers. Recompiler suffit (make build-native-libs).

with f = io.open(...) fonctionne sur le moteur pur : le with se réécrit en __enter__/__exit__, que le descripteur de fichier natif n'exposait pas — la forme documentée échouait sur unknown method. Le fichier est maintenant fermé à la sortie du bloc, y compris quand celui-ci lève, et l'exception continue de se propager. Le descripteur compte ses porteurs : __enter__ rend le même fichier sous une seconde valeur, et la mort de l'une ne ferme plus ce que l'autre utilise encore.

Les erreurs de fichier disent ce qui a échoué : la cause des échecs d'écriture et de lecture de ligne était jetée au profit d'un « write error » nu ; lire un fichier fermé annonçait un problème de mode d'ouverture ; et ouvrir un répertoire réussissait pour échouer au read() suivant, alors que CPython lève à l'ouverture.

Un fichier ouvert et jamais fermé se ferme avec le programme : lié à un nom global, il restait ouvert jusqu'à la fin du processus, les globals du moteur pur n'étant libérés qu'à la réinitialisation d'un pipeline. CPython ferme au dernier déréférencement ; c'est de nouveau le cas ici. Les autres formes — variable réécrite, fichier temporaire, ouverture dans un bloc — libéraient déjà correctement.

Une méthode d'objet natif se lit comme une valeur : f.read répondait « unknown attribute » sur le moteur pur, faute d'une valeur « méthode liée ». Elle existe maintenant, et garde son objet vivant tant qu'elle-même l'est — un fichier ne se ferme pas sous la méthode qui le lit. C'est aussi ce qui fait arriver les mots-clés jusqu'à une méthode.

type() n'est plus un nom défini ; typeof rend les mêmes noms sur les trois moteurs (BREAKING) : le moteur pur exposait type() seul, sans documentation, comme doublon de typeof sous un nom qui désigne autre chose en Python — là-bas type(x) rend un objet, pas une chaîne. Le code qui l'employait tournait au serveur MCP et échouait à la ligne de commande. typeof reste la seule façon d'obtenir un nom de type — et il réutilisait dans le moteur pur les noms destinés aux messages d'erreur, si bien que typeof(None) répondait NoneType là où la spécification dit nil, et str au lieu de string. Les messages d'erreur gardent leur vocabulaire — unhashable type: 'list' reste ce que CPython écrirait.

true, false et nil ne sont plus acceptés par le moteur pur (BREAKING) : il les servait comme globals, alors que la grammaire ne connaît que True/False, que la spécification ne documente que ceux-là, et que le runtime Python lève un NameError dessus. Un source écrit en minuscules tournait donc au serveur MCP et échouait à la ligne de commande. True, False et None sont les seules formes.

Le moteur pur charge enfin ses modules stdlib : import('io'), ('sys') et ('http') répondaient « module not found » sur ce moteur — donc au serveur MCP et à tout embarqueur sans Python —, si bien qu'aucune entrée-sortie n'y était possible. La cause était le build : io et sys se compilent par défaut en extension PyO3, et ce .so-là exige libpython, que ce moteur n'a pas. La variante « plugin natif » est maintenant produite, et make install-bins la dépose dans le lib/ du répertoire des binaires, un des chemins que le moteur interroge déjà.

io = import('io')
f = io.open("data.txt")   # lit pour de vrai, sans Python dans le processus

print, input et open répondent aussi en mode AST par l'API : les trois sont des builtins du host de la VM et n'étaient pas des entrées du Context, si bien que Catnip(vm_mode='off') levait NameError dessus. Le CLI masquait l'écart en chargeant io:!, dont les versions écrasent les builtins — il reste donc inchangé. Deux conséquences pour qui embarque le runtime : open est disponible par défaut dans les deux exécuteurs, et passer ses propres globals ne le retire pas en mode VM, le host les injectant pour son compte (voir Étendre le contexte). Le moteur pur, lui, ne connaît que print : input et open y appartiennent au module io, qu'il ne sait pas résoudre par son nom.

eval_catnip rend le stderr du programme et sait lui donner une entrée : le serveur MCP détournait le descripteur 1 pendant l'exécution — sans quoi un io.print corromprait le flux JSON-RPC — mais laissait le 2 intact et pointait le 0 sur /dev/null. Ce qu'écrivait io.eprint partait donc dans les journaux du client au lieu de la réponse, et io.input() n'avait jamais rien à lire. Les deux flux reviennent maintenant dans la réponse (stdout et stderr), et un paramètre stdin alimente la lecture, ligne par ligne.

Le protocole ne partage plus ses descripteurs avec le programme évalué : le serveur lisait le JSON-RPC sur le descripteur 0 et l'écrivait sur le 1 — ceux-là mêmes que l'exécution détourne le temps d'une évaluation, avec un lecteur de transport qui tourne sur son propre fil. Le protocole travaille maintenant sur des descripteurs dupliqués au démarrage. Effet voulu du même changement : ce qu'un programme écrit sur sa sortie standard hors d'une fenêtre d'évaluation arrive dans les journaux au lieu de couper le flux. Le serveur ne démarre plus s'il ne peut pas séparer les deux. Conséquence assumée : le serveur et le plugin io natif demandent des descripteurs POSIX et ne compilent que pour Linux et macOS, les deux plateformes que le projet construit.

io.input() ne garde plus rien entre deux appels (moteur pur) : la lecture passait par le tampon statique de std::io::stdin(), qui lit d'avance. Sur "un\ndeux\n", un appel qui rend "un" laissait "deux\n" dans un tampon que l'appel suivant aurait servi — depuis un descripteur que l'hôte avait entre-temps changé, voire pour une évaluation qui n'avait fourni aucune entrée. La lecture se fait maintenant caractère par caractère sur le descripteur, ce qui coûte un appel système par caractère.

http ne laisse plus un programme écrire ce qu'il veut dans sa propre réponse, et prend un dict d'en-têtes. Le content_type donné à Request.respond(), Request.start_chunked() et http.serve() partait tel quel dans le bloc d'en-têtes. Un retour à la ligne y termine le champ : ce qui suivait devenait des en-têtes à part entière, et après une ligne vide, un corps de réponse entier — un serveur qui construisait son type de contenu depuis une donnée reçue laissait son client lire ce que l'émetteur avait choisi. Les valeurs portant un caractère de contrôle sont maintenant refusées (RFC 7230 §3.2.6), avant que la requête ne soit consommée. Dans la même famille : status doit tenir dans 100..=599 et le port de serve() dans 0..=65535 — au-delà, la conversion tronquait sans rien dire, et respond("x", 70000) répondait HTTP/1.1 4464.

Conséquence à connaître avant de mettre à jour : ce retour à la ligne était le seul moyen d'ajouter un en-tête de réponse — un Set-Cookie, un en-tête CORS. Ce code cesse de fonctionner ; son remplaçant est le dict d'en-têtes que respond(), start_chunked() et start_sse() acceptent maintenant. Les noms y sont vérifiés comme les valeurs l'étaient déjà : un nom doit être un token (RFC 9110 §5.6.2), donc sans deux-points, espace ni retour à la ligne. Content-Type est refusé dans le dict puisqu'il a son propre argument ; Content-Length et Transfer-Encoding le sont parce qu'ils contrediraient le cadrage de la réponse. Un en-tête que le module pose par défaut sans cadrer quoi que ce soit, lui, se remplace : donner Cache-Control remplace le no-cache de start_chunked() plutôt que de s'ajouter à côté.

Un timeout hors domaine ne tue plus le processus. http.request(..., dict(timeout=1e300)) et Server.recv_timeout(1e300) passaient leur flottant à la conversion en durée, qui panique sur une valeur non finie ou trop grande ; le profil de release abandonne sur panique, donc l'appel emportait tout le processus par SIGABRT. Les deux lèvent maintenant une erreur Catnip. Une valeur négative garde son sens précédent (zéro, donc un seul tour), nan est refusé plutôt que lu comme zéro.

Un événement SSE ne peut plus en ouvrir un autre. Chunked.send_event() écrivait son event_type tel quel : une fin de ligne y fabriquait des événements entiers, mesurés côté client à deux reçus pour un envoyé. Elle y est refusée. Dans les données, où les fins de ligne sont légitimes, \r et \r\n deviennent une ligne data: de plus comme \n le faisait déjà — seul \n était traité, si bien qu'un \r partait brut et que le client, qui coupe aussi sur \r, lisait des champs que le programme n'avait pas écrits.

Un argument du mauvais type ne prend plus la valeur par défaut (BREAKING). respond("x", "oops") répondait 200, recv_timeout("bientôt") attendait une seconde, dict(timeout='5') partait sans échéance et une valeur d'en-tête non textuelle était retirée de la requête, qui partait quand même. Une valeur que l'appelant a écrite, d'un type que l'appel ne sait pas lire, revenait ainsi en résultat faux plutôt qu'en erreur. Absent et nil valent toujours le défaut.

multipart() reconnaît le paramètre boundary quelle que soit sa casse, comme les en-têtes de partie qu'il lisait déjà ainsi (RFC 2045 §5.1). Un client qui écrivait BOUNDARY= faisait rendre une liste vide.

Une erreur nomme l'entrée que tu as écrite, plus le verbe HTTP. http.request('GET', ...) annonçait ses erreurs sous http.get:, que l'appelant n'avait pas écrit — et request('PATCH', ...) sous http.patch:, une fonction que le module n'a pas. Un programme qui filtrait sur http.get: pour attraper une erreur de request() doit lire http.request: désormais.

Un corps de requête entrant est plafonné à 32 Mo (BREAKING). Request.body() et Request.multipart() lisaient sans limite ce que le pair envoyait, alors que le client du même module plafonne ses lectures depuis la 0.0.9. L'asymétrie allait dans le mauvais sens : côté client le pair est un serveur qu'on a choisi de contacter, côté serveur c'est quiconque se connecte. Mesuré avant correction : 200 Mo annoncés et envoyés font enfler le processus de 207 Mo. Les deux méthodes prennent maintenant une limite en argument, avec le même défaut que max_body, et lèvent au-delà plutôt que de rendre un corps tronqué — un programme qui reçoit des uploads plus gros passe la sienne : req.body(100 * 1024 * 1024). Dans la même méthode, un corps qui n'est pas de l'UTF-8 valide dit maintenant ce qu'il est, au lieu d'annoncer une requête déjà consommée.

Un objet http se lit depuis n'importe quel thread, et survit à celui qui l'a créé. Serveurs, requêtes, réponses et writers chunked étaient rangés dans une table propre à chaque thread, et repérés par leur indice dedans. Deux conséquences, toutes deux silencieuses. Un objet créé dans un thread était détruit à la fin de ce thread — le port se libérait, le serveur s'arrêtait — pendant que la valeur qui le désignait restait utilisable. Et cette valeur, lue depuis un autre thread, y désignait l'objet rangé au même indice : mesuré, un Server.addr rendant l'adresse d'un serveur créé ailleurs. C'est précisément ce que le mode asynchrone documenté demande de faire, puisqu'il consiste à confier les Request reçues à des workers. La table est maintenant unique au processus et ses identifiants sont émis une seule fois : une valeur qui survit à son objet ne désigne plus rien plutôt que le suivant venu.

CLI et outillage

Le formatter coupe une liste de paramètres trop longue : une signature de 140 colonnes restait sur une seule ligne, au-dessus de line_length, parce que le séparateur des paramètres était un texte fixe sans point de rupture. Les paramètres suivent maintenant les trois règles déjà appliquées aux arguments d'un appel : une virgule terminale ou un premier paramètre placé à la ligne gardent un paramètre par ligne, sinon les paramètres sont réunis tant qu'ils tiennent. Une signature écrite sur plusieurs lignes n'est donc plus rabattue sur une ligne trop longue. La queue de la signature (), le type de retour, =>) est mesurée avec le dernier paramètre. Seule l'accolade ouvrante du corps reste en dehors, donc le dépassement résiduel est d'une colonne, et seulement quand la signature mesure exactement la limite.

Catnip(...) refuse un argument nommé qu'il ne lit pas (BREAKING), et accepte jit= et tco=. Tous les mots-clés passaient par kwargs.get, donc un nom non lu ne configurait rien et ne disait rien : Catnip(jit=True) s'exécutait avec le JIT éteint, et une faute de frappe comme optimze=3 tournait au niveau par défaut.

Catnip(optimze=3)   # TypeError: Catnip() got unexpected keyword argument: optimze. Accepted: auto, cache, …
Catnip(jit=True)    # active réellement le JIT
Catnip(tco=False)   # override appelant : un pragma du fichier ne le renverse plus

jit= n'a pas d'override côté analyseur, contrairement à optimize= et tco= : un pragma("jit", ...) dans le fichier le reprend encore.

Un Context restreint restreint réellement, dans les deux exécuteurs (BREAKING) : Catnip(context=Context(globals=dict(x=1))) laissait open("/etc/hostname") lire le fichier en mode VM — le mode par défaut —, parce que le host injectait ses 69 builtins sans consulter le contexte ; le mode AST refusait les mêmes noms. Un second chemin annulait la restriction dans les deux modes : CPython sème __builtins__ dans tout mapping qu'il reçoit comme globals, si bien que __builtins__["__import__"]("os") atteignait n'importe quoi. Troisième : import était reposé sans condition à chaque exécution — y compris par-dessus un import fourni par l'appelant, qui est une façon légitime de filtrer les modules et que seul le mode AST honorait. Seul le wrapper par défaut est désormais remplacé.

cat = Catnip(context=Context(globals=dict(x=1)))
cat.parse('open("/etc/hostname")')
cat.execute()   # CatnipNameError: Name 'open' is not defined

Ce que le contexte ne déclare pas n'existe plus, import compris. Survivent les mécanismes que le bytecode résout pour son compte — True, False, None et les helpers d'expansion des littéraux de collection —, qui n'ouvrent rien. Le contexte par défaut déclare les 69 noms, donc rien ne change pour lui. Pour shadower un nom sans perdre le reste, partir du contexte complet : ctx = Context() puis ctx.globals['abs'] = ....

La restriction porte sur les noms, pas sur les objets : exposer une valeur, c'est exposer ses attributs et leur fermeture transitive. Elle ne remplace pas une frontière de processus.

Reprendre une capacité après coup la reprend vraiment : la restriction ne s'appliquait qu'au moment où le contexte était posé. Un del ctx.globals['import'] plus tard ne changeait rien en mode VM — le nom restait dans le moteur, et l'export de fin d'exécution le remettait dans les globals Python —, alors que le mode AST le refusait déjà.

cat = Catnip()
cat.parse('1 + 1')
cat.execute()

del cat.context.globals['import']   # vaut aussi pour open, print, les 69 autres
cat.parse('import("os")')
cat.execute()                        # CatnipNameError, dans les deux exécuteurs

C'est le cas d'un hôte qui réutilise une instance entre deux requêtes et ferme une capacité entre les deux. Seuls les noms que le moteur a semés sont repris : ce qu'un programme a défini ne peut pas disparaître de sous ses pieds.

CATNIP_EXECUTOR refuse une valeur qu'il ne sait pas lire (BREAKING). La variable était transmise telle quelle au pipeline, qui lit tout ce qui n'est pas off comme la VM : CATNIP_EXECUTOR=bogus exécutait donc la VM, et CATNIP_EXECUTOR='ast ' — un espace en trop dans un .env ou une variable de CI — aussi. Le même mot sur -x a toujours été refusé par son nom. La casse et les espaces autour de la valeur sont désormais ignorés, et le reste lève.

CATNIP_EXECUTOR='ast '  catnip script.cat   # l'AST, comme demandé
CATNIP_EXECUTOR=bogus   catnip script.cat   # Error: Unknown executor 'bogus': expected 'vm' or 'ast'

Les trois lecteurs de la variable normalisent maintenant pareil : Catnip() en bibliothèque stockait vm_mode='AST' tel quel là où le CLI comprenait la même valeur.

Une variable d'environnement vide compte comme absente, et une valeur illisible lève (BREAKING). Les deux règles manquaient à toutes les variables que lit la configuration. CATNIP_EXECUTOR= posait '' avec la source env par-dessus un executor écrit dans le fichier ; CATNIP_CACHE= réactivait le cache par-dessus un enable_cache = false ; et CATNIP_CONFIG= rendait un chemin relatif vide. NO_COLOR la tient désormais de sa propre spécification, qui dit « present and not an empty string ».

Côté valeurs illisibles, CATNIP_OPTIMIZE était seule à refuser. CATNIP_CACHE=bogus laissait le cache activé — l'inverse de ce que demande la seule variable dont le rôle est de le désactiver — et les deux CATNIP_FORMAT_* étaient ignorées en silence. Les trois lèvent en nommant la variable et la valeur.

CATNIP_CACHE=bogus catnip script.cat
# Error: Invalid value 'bogus' for CATNIP_CACHE: expected on/true/1/yes or off/false/0/no

Une option d'optimisation illisible affiche une erreur, plus une trace Python : catnip -o bogus répondait par une quinzaine de lignes de traceback se terminant sur ValueError. Les quatre chemins qui lisent une option — -o, la variable CATNIP_OPTIMIZE, catnip repl, catnip debug et catnip config show — rendent maintenant une ligne et le code de sortie 1 :

$ catnip -o bogus -c "1 + 1"
Error: Unknown optimization option 'bogus'. Valid options: tco, jit, level, memory

-o accepte la liste séparée par virgules de CATNIP_OPTIMIZE : catnip -o jit,level:3 échouait sur Invalid value '3' for 'jit' alors que CATNIP_OPTIMIZE=jit,level:3 fonctionnait. -o jit,level:3 et -o jit -o level:3 sont désormais la même chose.

-x/--executor l'emporte sur le fichier de configuration. Le drapeau n'était appliqué que s'il différait du défaut calculé, une comparaison qui ne distingue pas une valeur tapée d'une valeur remplie par Click. -x vm était donc accepté puis ignoré chaque fois que vm était aussi le défaut : un catnip.toml avec executor = "ast" ne pouvait pas être renversé en ligne de commande. -x ast fonctionnait au même endroit, par la seule coïncidence que ast diffère du défaut.

Le cache ne s'écrit plus dans le répertoire de travail : avec un HOME défini mais vide — ce que produisent env -i, certains services et conteneurs — le répertoire de cache était résolu en chemin relatif, et Catnip déposait ses fichiers dans le répertoire courant. Ceux du JIT comprennent des stencils Cranelift, c'est-à-dire du code machine que le processus relit et exécute au démarrage suivant : à cet endroit, il devient partagé avec tout ce qui peut écrire dans ce répertoire. Une valeur vide est maintenant traitée comme absente, pour HOME comme pour les XDG_*. Et quand aucun dossier personnel n'est déterminable — un conteneur sans entrée passwd —, les caches se désactivent au lieu de se rabattre sur le répertoire courant : catnip cache le dit, l'historique de la REPL reste en mémoire, et l'exécution est inchangée.

catnip cache voit le cache JIT : les traces et le code natif compilé partagent le répertoire du cache disque sans lui appartenir, et aucune commande ne les couvrait — catnip cache stats annonçait 0 entrée sur un répertoire qui en contenait 2831. stats les compte à part, clear les supprime. Le cache des traces perd par ailleurs ses générations périmées au démarrage : la version du cache dérive du nombre d'opcodes, donc chaque opcode ajouté laissait derrière lui une génération entière que rien ne collectait. Ni quota ni TTL sur ce cache : sa taille est bornée par le nombre de boucles chaudes distinctes de la génération courante.

catnip cache stats ne s'interrompt plus sur un fichier qui disparaît : la taille du cache JIT était sommée en stat()-ant le résultat d'un glob, en course avec le nettoyage que tout processus catnip lance au démarrage — d'où un FileNotFoundError pour avoir demandé des statistiques.

sys.argv ne contient plus la ligne de commande du process hôte : hors mode script, le module sys retombait sur l'argv du processus. catnip -c "code" rendait donc ['/…/python3', '.venv/bin/catnip', '-c', "<le source complet>"] — le chemin de l'interpréteur et le code lui-même dans une variable du langage. Les trois modes prennent maintenant la forme que leur donne CPython : [script, args…], ["-c", args…], et [""] pour l'entrée standard et la REPL. Vaut aussi pour qui embarque le moteur : sans déclaration explicite, un argv par défaut de [""] est installé au chargement de sys.

Un import relatif ne sort plus de son package. La remontée saturait au répertoire racine du système de fichiers, si bien qu'un point de trop résolvait silencieusement vers un fichier situé au-dessus du projet au lieu d'échouer — là où Python lève « attempted relative import beyond top-level package ». Depuis un répertoire contenant lib.toml, la remontée s'arrête à cette racine et l'erreur la nomme. Hors package rien ne change.

Deux imports du même nom sous deux protocoles ne se répondent plus l'un pour l'autre. import('sys') rend le module Catnip épuré et import('sys', protocol='py') le module Python complet ; le cache par nom du chargeur ignorait le protocole, donc le premier appel décidait pour le second. Concerne le chargeur Python, emprunté quand un Context n'est pas relié à une instance Catnip.

Un traceback nomme le script, et les deux moteurs rapportent le même détail. Le CLI résolvait le chemin puis appelait parse() sans lui, si bien que toute frame d'erreur s'annonçait File "<input>", line 7, in outer pour un fichier dont le chemin était connu deux lignes plus haut. Le mode AST, de son côté, calculait ligne et colonne pour ne les montrer nulle part. Les deux exécuteurs rendent désormais le même message, la même position et le même extrait de code fautif ; seule la pile d'appels reste propre à la VM, qui est le seul moteur à enregistrer des frames. L'écart est décrit dans CLI.

La même suggestion de nom se lit pareil dans les deux moteurs (BREAKING). Name 'nope' is not defined suivi d'un « Did you mean » était rendu de deux façons selon l'exécuteur : . Did you mean 'open'? sur la même ligne sous la VM, \n Did you mean 'open'? en retrait sous l'AST. La seconde est celle que CatnipNameError produit elle-même, pour les suggestions comme pour les indices de trait ; les deux moteurs suivent maintenant le rendu de la classe.

Second défaut au même endroit : l'enrichisseur n'écrivait que dans args, si bien que str(exc) portait la suggestion et exc.message — l'attribut que lisent le formateur de message et un hôte qui embarque Catnip — ne la portait pas.

Une session de débogage rapporte une erreur comme l'exécution ordinaire. Elle en avait son propre rendu, et les deux divergeaient sur quatre points : pas de pile d'appels — le débogueur construisait pourtant déjà les frames —, le type gardait son préfixe interne (CatnipNameError au lieu de NameError), le message portait sa position (File '...', line N, column C:), et le fichier s'annonçait <input> même pour un script nommé sur la ligne de commande. Les deux frontends, console et session programmatique (API Python, tools MCP), rendent désormais par colors.format_exception.

Conséquence pour un hôte qui lisait str(exc) sur ce chemin : le message ne contient plus la position. Elle reste lisible sur .filename, .line et .column, et visible dans l'extrait de code, comme partout ailleurs.

DebugSession.wait_for_event(timeout=...) lève au lieu d'abandonner le processus. Le flottant reçu allait directement à la conversion en durée, qui panique sur une valeur négative, infinie ou nan — et le profil de release abandonne sur panique. wait_for_event(timeout=-1), une faute de frappe ordinaire, tuait donc la session et le processus qui la pilotait. La session reste utilisable après le refus.

Le linter rapporte ce que le pipeline refuse (E400) : catnip lint lançait déjà l'analyseur pour ses diagnostics de types, mais jetait le cas où celui-ci rejette le fichier. Un pragma("nope", 1), un pragma("optimize", 99) ou un pragma("tco") sortaient en succès, sans un mot, alors que l'exécution les refuse. La transformation en IR était dans le même cas, et le trou y était plus large : return 42 hors d'une fonction, un break hors d'une boucle, un champ non-default après un champ à valeur par défaut — tout ce que le fichier refuse à la lecture — passaient le lint en silence. Les deux étages remontent maintenant en E400, qui se supprime par # noqa: E400 ou --disable E400 — ce qui fait taire le linter et non le moteur.

Ces messages pointent l'élément fautif au lieu du début du fichier : sur un fichier de cinq cents lignes, un break mal placé se rapportait ligne 1. Les quatre-vingt-dix sites d'erreur de la transformation portent maintenant la position du nœud le plus précis en cause, et le marqueur qui la transporte ne peut plus se retrouver affiché tel quel — @pragma:0 Optimization level must be 0-3 était ce que le serveur MCP rendait à l'utilisateur. Le même trou avait un second effet : quand l'analyse échouait, les diagnostics dérivés des types (I103 d'exhaustivité) retombaient silencieusement sur leur version syntaxique, moins précise.

W300 couvre les quatre façons de terminer un bloc : le code mort après un return était signalé, celui après un raise, un break ou un continue ne l'était pas. Le message nomme maintenant le mot-clé en cause (Unreachable code after break). Le partage avec W311 est inchangé : la terminaison à l'intérieur d'un bloc appartient à W300, celle qui vient de branches toutes terminales à W311. Aucun fichier du dépôt ne s'allume.

Le linter voit le code mort après un ; et les parenthèses sous ?? : break; x = 1 ne signalait pas x = 1, et (d['k']) ?? 1 passait sans le W304 que la forme nue déclenche — même clé, même KeyError.

W304 lit les clés tuple : m['a', 'b'] ?? x sur un dict lève KeyError comme la forme simple, mais l'exclusion des indices multiples — pensée pour l'indexation ND — le laissait passer. Un indice dont tous les éléments sont des littéraux string est un accès dict et porte le diagnostic ; dès qu'un élément n'en est pas un (m[0, j]), la forme reste lue comme de l'indexation ND.

L'éditeur montre les mêmes diagnostics que la ligne de commande : le serveur LSP n'a jamais rapporté E300 ni E400, parce que les diagnostics qui demandent l'IR étaient assemblés dans le pont Python, hors de sa portée. Un fichier signalé par catnip lint pouvait donc passer pour propre dans l'éditeur.

Le serveur LSP honore la configuration lint. disable = ["E205"] faisait taire le CLI mais laissait l'erreur rouge dans l'éditeur : le serveur lintait avec les défauts, sans jamais lire de configuration. Il résout maintenant le même fichier que le CLI (CATNIP_CONFIG d'abord, valeur vide comme absente, sinon le chemin XDG), au démarrage du processus — changer la configuration demande un redémarrage du serveur. Différence assumée : un TOML illisible replie le serveur sur les défauts là où le CLI refuse, un serveur de diagnostics devant continuer à publier. --deep et --check-names restent des options du CLI.

Le formateur refuse un fichier dont il n'a pas lu la syntaxe (BREAKING) : il imprimait depuis l'arbre incomplet que le parseur lui laissait, en rendant un code de sortie 0. Sur un identifiant que la grammaire rejette, catnip format -i réécrivait le fichier avec le token coupé en deux par un retour à la ligne — une modification silencieuse d'un source que l'outil n'avait pas compris, là où catnip lint signalait l'erreur. La commande affiche maintenant la position fautive et sort en 1, sans toucher au fichier. Conséquence pour un appelant : le formateur ne prend plus un fragment isolé, une liste d'arguments ou une parenthèse nue n'étant pas un programme complet.

Le formateur ne perd plus de code ni de commentaires. Un commentaire placé entre deux tokens d'une construction multiligne pouvait prendre la place d'un opérande dans l'arbre relu par position : x = a + # why suivi de b à la ligne rendait x = a + # why — l'opérande supprimé, la sortie invalide, le code de sortie 0. Même mécanisme pour un commentaire en tête de parenthèses (l'expression disparaissait) ou entre un décorateur et sa définition (la définition était absorbée dans le commentaire). D'autres positions perdaient seulement le commentaire : bras de match, clauses except, chaînes de méthodes multilignes, paramètres de lambda, lignes de commentaire dans une liste d'arguments ou une collection. Les commentaires sont maintenant séparés des enfants signifiants avant tout accès par position, et réémis — en fin de ligne quand ils y étaient, sur leur propre ligne sinon. Seul déplacement : un commentaire entre la condition d'un if/while et son bloc remonte au-dessus du statement, la place qu'il occupait n'existant pas dans la forme normalisée.

Le formateur aligne sur la largeur d'affichage, plus sur les octets : les colonnes d'un groupe de bras de match étaient comparées par index d'octet, si bien qu'un motif accentué comptait une colonne de trop. 'température' => et 'pression' => finissaient décalés d'un cran — et le linter validait ce désalignement, puisque c'est le formateur qui l'avait produit. Le déclenchement souffrait du même biais dans l'autre sens : un groupe déjà aligné par son auteur n'était pas reconnu comme tel dès qu'un accent s'y trouvait. La mesure est maintenant la largeur occupée à l'écran (UAX #11) : un accent vaut une colonne, un idéogramme ou un emoji en vaut deux. Un seul fichier du dépôt changeait de forme.

Le formatage atteint son point fixe dès la première passe. Un appel cassé sur plusieurs lignes puis suivi d'un accesseur (bb.dddd(eeee,).iii()) voyait sa continuation indentée d'un cran de plus à chaque relecture : catnip format --check échouait sur la sortie de catnip format aux largeurs inférieures à la limite par défaut. La limite de ligne elle-même se mesure en colonnes d'affichage et plus en octets : une ligne de 96 colonnes portant 80 lettres accentuées n'est plus coupée.

Un dict {} multiligne garde sa première entrée collée à l'accolade. Une accolade suivie d'un retour à la ligne se relit comme un début de bloc : la forme que le formateur produisait pour un dict à virgule terminale était donc un programme qu'il refuserait lui-même de relire. Les trois déclencheurs de la forme verticale (virgule terminale, source déjà multiligne, commentaires) produisent désormais le même layout, "a": 1 sur la ligne de { et } seul sur la sienne.

Deux dernières formes que le formateur laissait passer telles quelles : un lvalue d'affectation (obj . attr = 1, a [ i ] = x) n'était jamais normalisé. Et une ligne dépassant la limite n'était pas cassée quand la coupe utile vivait dans les arguments d'un appel suivi d'accesseurs (bbbb(cccc(dddd), ee='ffff').gggg().hhhh() restait entière à largeur 40) : la mesure du point de coupe s'arrêtait à la parenthèse fermante. Le suffixe entre maintenant dans la mesure, et le rendu ne change que là où la ligne débordait déjà. La doc de l'alignement précise au passage un comportement existant : deux premières lignes alignées valent opt-in pour le groupe entier, un bras ajouté plus long réaligne l'ensemble.

--no-align, et deux façons de moins de surprendre : le CLI n'offrait que --align, qui forçait un défaut déjà actif — le mode sans alignement n'était atteignable que par fichier de configuration. Les deux formes existent maintenant et forcent chacune leur sens, l'absence des deux suivant la configuration ; l'aide de l'option ne promet plus l'alignement des =, que le formateur n'a jamais fait. Un flux stdin qui n'est pas de l'UTF-8 valide est refusé en une ligne (Error: stdin is not valid UTF-8, code 1) au lieu d'un traceback Python. Un enum/union imbriqué — les deux seules constructions rendues verbatim — est réindenté relativement à sa première ligne au lieu de garder ses colonnes d'origine, sauf s'il contient une string multiligne, dont le contenu ne bouge jamais. La ligne vide qu'un auteur laisse après le shebang est conservée, plafonnée à une.

Une erreur de syntaxe portant un accent au mauvais octet ne tue plus le processus : l'extrait cité par le message d'erreur était tronqué à l'octet 20, en plein milieu d'un caractère multi-octets quand la coupe tombait dedans — panic Rust, abort, code de sortie 134, sur catnip format, catnip lint et catnip -c, et le serveur LSP embarque le même chemin. La coupe tombe maintenant sur une frontière de caractère.

pip install catnip-lang fournit la commande catnip : le paquet ne déclarait que catnip-repl dans [project.scripts], si bien qu'une installation depuis PyPI n'exposait aucune commande catnip — alors que la documentation en montre la sortie. Un console script appelle désormais catnip.cli:main. Là où le binaire est installé lui aussi, les deux occupent le même chemin et le dernier posé gagne : ils exécutent les mêmes scripts et servent les mêmes sous-commandes, mais leurs options ne se recouvrent pas (docs/user/CLI.md les compare).

La commande catnip-repl fonctionne, et la REPL rend son propre code de sortie : elle cherchait un binaire externe et se trouvait elle-même dans le PATH, ce que sa propre garde anti-récursion traduisait en Error: catnip-repl binary not found — suivi d'un conseil d'installation inapplicable à qui installe depuis PyPI, et d'un code de sortie 0 annonçant le succès. Elle lance désormais la REPL ratatui par l'extension PyO3, le chemin qu'emprunte déjà catnip repl. Sur les deux points d'entrée, quitter par Ctrl+C rend 130 et une sortie normale rend 0 — auparavant catnip repl rendait 0 quoi qu'il arrive, et un abandon par Ctrl+C affichait Error: Rust REPL 'catnip-repl' not found in PATH. Le binaire standalone reste distribué pour les hôtes sans Python.

Le paquet Debian fournit un catnip qui fonctionne : son binaire était compilé sans l'exécuteur AST, dont le paquet Python a besoin au chargement. Résultat, catnip -c '1 + 1' comme n'importe quelle sous-commande sortaient sur une erreur d'import. La recette Debian et celle du dépôt sont désormais identiques.

Un harnais de mesure remplace benchmarks/ : make bench, ou python -m bench run --config opt0,opt3. Il compare des configurations du même moteur — niveau d'optimisation, exécuteur, JIT — sur des workloads dimensionnés pour que le calcul domine, archive ses mesures en JSON et sait comparer deux fichiers pour détecter une régression. Il refuse de mesurer une configuration qu'il n'applique pas, en relisant les réglages sur l'instance après parse(). Le protocole est décrit dans BENCHMARKING. La comparaison de deux fichiers exclut les paires dont le résultat a changé : un ratio de temps entre deux exécutions qui ne calculent plus la même chose se lisait en « gain ».

L'extra codex-geospatial n'installe plus geopy : le paquet y était déclaré depuis le renommage des extras en 0.1.2 sans qu'aucun exemple ne l'importe — codex/geospatial/haversine_distance.cat calcule la distance géodésique avec math seul. pip install catnip-lang[codex-geospatial] installe donc un paquet de moins ; un code hôte qui comptait dessus par transitivité doit maintenant le déclarer lui-même.