Back to flin
flin

Ce que l'audit nous a appris sur la construction d'un langage

Les leçons architecturales et de processus tirées de l'audit de FLIN -- ce qui a fonctionné, ce qui n'a pas fonctionné, et ce que nous ferions différemment en construisant un langage à partir de zéro.

Thales & Claude | March 30, 2026 11 min flin
EN/ FR/ ES
flinauditlessons-learnedmethodologyreflection

Auditer 186 252 lignes de code n'est pas simplement un exercice de recherche de défauts. C'est un miroir tendu face à chaque décision architecturale, chaque choix de processus et chaque compromis fait au cours de 301 sessions de développement. Les défauts eux-mêmes sont des symptômes. Les leçons sont dans les patterns -- quelles catégories de défauts récurrent, quels sous-systèmes accumulent la dette technique le plus rapidement, et où le processus de développement crée des angles morts systématiques.

L'audit de FLIN nous a enseigné sept leçons sur la construction d'un langage de programmation. Certaines ont confirmé ce que nous soupçonnions déjà. D'autres nous ont surpris. Toutes façonneront la manière dont nous construisons des logiciels à l'avenir.

Leçon 1 : les tables de dispatch doubles sont un défaut de conception

La découverte la plus critique de l'audit -- l'opcode CreateMap dupliqué -- n'était pas un bug individuel. C'était une vulnérabilité structurelle. La VM de FLIN a deux fonctions d'exécution (run() et execute_until_return()) qui doivent toutes deux gérer les mêmes opcodes. Cette architecture de dispatch double est la cause racine d'une catégorie entière de défauts.

Lorsque la Session 273 a audité execute_until_return() pour la couverture des opcodes, elle a trouvé que seulement 59 des 170+ opcodes étaient gérés. C'est un taux de couverture de 35 %. Chaque opcode manquant était un échec silencieux -- une instruction continue qui sautait l'opération comme si elle n'avait jamais été émise par le compilateur.

rust// The architectural problem: two loops that must stay synchronized
// but have no mechanism to enforce it

// Solution 1: shared dispatch function
fn dispatch_opcode(
    &mut self,
    opcode: OpCode,
    code: &[u8],
) -> Result<DispatchResult, VmError> {
    match opcode {
        OpCode::CreateMap => { /* single implementation */ }
        OpCode::Add => { /* single implementation */ }
        // ... all opcodes in one place
    }
}

// Solution 2: macro-generated match arms
macro_rules! opcode_dispatch {
    ($self:expr, $opcode:expr, $code:expr) => {
        match $opcode {
            OpCode::CreateMap => $self.handle_create_map($code)?,
            OpCode::Add => $self.handle_add($code)?,
            // ... generated from a single definition
        }
    };
}

La leçon n'est pas spécifique à FLIN. Tout système avec des tables de dispatch parallèles -- des gestionnaires d'événements à deux endroits, des parsers de protocoles avec plusieurs points d'entrée, des processeurs de commandes avec différents modes d'exécution -- est vulnérable à la même divergence. La correction est toujours la même : partager une implémentation unique, que ce soit via une fonction commune, une macro ou la génération de code.

Leçon 2 : les échecs silencieux sont les bugs les plus coûteux

Trois des cinq découvertes les plus graves de l'audit impliquaient des échecs silencieux :

  • CreateMap supprimant silencieusement les clés pour les entrées Value::Text
  • Entity.where() retournant silencieusement toutes les entités au lieu des résultats filtrés
  • Les validateurs rejetant silencieusement les sauvegardes sans erreur ni avertissement

Chacun de ces bugs était coûteux non pas à cause des dommages qu'ils causaient, mais à cause du temps passé à les diagnostiquer. Quand les traductions d'un développeur FLIN ne fonctionnent pas, il ne soupçonne pas la couche d'opcodes. Quand une requête d'entité retourne trop de résultats, il accuse sa syntaxe de filtre. Quand une sauvegarde semble réussir mais que les données ont disparu au rafraîchissement, il questionne la base de données.

rust// The cost of silence: a developer's debugging journey
//
// "My translations don't work"
//   -> Check translation map: looks correct
//   -> Check t() function: works in console
//   -> Check template rendering: correct syntax
//   -> Check scope: variables accessible
//   -> Hours later: the map construction silently dropped keys
//
// "My filter doesn't work"
//   -> Check predicate syntax: correct
//   -> Check entity data: present
//   -> Check query: returns results (too many)
//   -> Hours later: the predicate was popped and discarded

// The correct pattern: fail loudly
OpCode::QueryWhere => {
    let predicate = self.pop()?;
    let entity_type = self.pop()?;

    match self.apply_predicate(&entity_type, &predicate) {
        Ok(filtered) => self.push(Value::List(filtered))?,
        Err(e) => {
            // NEVER silently fall back to returning all entities
            return Err(VmError::QueryError {
                entity: entity_type,
                predicate: format!("{:?}", predicate),
                cause: e.to_string(),
            });
        }
    }
}

Le principe que nous avons adopté après l'audit : chaque opération qui peut échouer doit soit réussir avec le résultat correct, soit échouer avec un message d'erreur qui pointe vers la cause. Il n'y a pas de terrain intermédiaire acceptable où une opération « réussit » avec des données erronées.

Leçon 3 : la représentation des valeurs doit être transparente pour les opérations

FLIN a deux représentations pour les chaînes : Value::Text(String) pour les petites chaînes en ligne et Value::Object(ObjectId) pointant vers un ObjectData::String alloué sur le tas. Cette optimisation réduit la pression d'allocation pour les petites chaînes courantes. Mais elle a créé un contrat que chaque opération sur les chaînes doit honorer : les deux représentations doivent produire un comportement identique.

L'audit a trouvé des violations de ce contrat dans OpCode::Trim, dans extract_string() et dans OpCode::CreateMap. Chaque violation était un bug distinct avec un symptôme distinct, mais ils avaient tous la même cause racine -- un bloc match qui gérait Value::Object mais oubliait Value::Text.

rust// The pattern that creates bugs
match value {
    Value::Object(id) => self.get_string(id)?.do_something(),
    _ => String::new(),  // WRONG: Value::Text is not handled
}

// The pattern that prevents bugs
match value {
    Value::Object(id) => self.get_string(id)?.do_something(),
    Value::Text(s) => s.do_something(),
    _ => return Err(VmError::TypeError { ... }),
}

La leçon plus large : lorsqu'un système a plusieurs représentations pour le même concept sémantique, il doit y avoir une fonction unique qui les normalise. Chaque consommateur devrait appeler le normaliseur plutôt que de faire du pattern matching directement sur les représentations.

rust// The normalizer pattern
fn as_string(&self, value: &Value) -> Result<Cow<str>, VmError> {
    match value {
        Value::Text(s) => Ok(Cow::Borrowed(s)),
        Value::Object(id) => {
            let s = self.get_string(*id)?;
            Ok(Cow::Borrowed(s))
        }
        _ => Err(VmError::TypeError {
            expected: "text",
            got: value.type_name(),
        })
    }
}

Leçon 4 : le développement par sessions crée des lacunes d'accessibilité

L'audit des fonctions a révélé que 95 % des fonctions intégrées étaient implémentées en bytecode mais que seulement 12 % étaient accessibles depuis les templates. Cette lacune est apparue parce que chaque session se concentrait sur le fonctionnement de bout en bout de sa fonctionnalité spécifique, ce qui signifiait implémenter la fonction en bytecode et la tester dans le contexte bytecode. L'exposition au template était une étape séparée qui était systématiquement reportée.

C'est une conséquence structurelle du développement par sessions. Chaque session a un objectif. L'objectif est toujours « faire fonctionner X », pas « faire fonctionner X depuis chaque contexte d'appel possible ». Le résultat est un codebase où les fonctionnalités fonctionnent dans le contexte où elles ont été développées mais pas dans les contextes où les utilisateurs les utiliseront réellement.

L'atténuation est une checklist. Pour chaque fonction ajoutée à FLIN, la checklist demande : est-ce que ça fonctionne depuis le bytecode ? Est-ce que ça fonctionne depuis les templates ? Est-ce que ça fonctionne depuis les routes ? Est-ce que ça fonctionne depuis les gestionnaires WebSocket ? L'audit nous a montré que sans la checklist, la réponse aux deuxième et suivantes questions était généralement « pas encore ».

Leçon 5 : les optimisations du compilateur doivent être invisibles

Le bug CreateMap ne se manifestait que lorsque le compilateur choisissait d'émettre une chaîne sous forme de Value::Text au lieu de Value::Object. Ce choix était une optimisation -- Value::Text évite une allocation sur le tas. Mais l'optimisation n'était pas invisible pour le reste du système. Le gestionnaire d'opcode dans run() ne pouvait pas traiter les clés Value::Text, créant un bug de correction qui dépendait d'une décision d'optimisation du compilateur.

Le principe : les optimisations du compilateur doivent être transparentes pour le runtime. Si le compilateur choisit de représenter une valeur différemment pour des raisons de performance, chaque partie du runtime qui touche cette valeur doit gérer toutes les représentations possibles. Si ce contrat est trop coûteux à maintenir, l'optimisation ne devrait pas être faite.

rust// This optimization is only safe if EVERY consumer handles both forms
enum Value {
    Text(String),       // Optimization: inline string
    Object(ObjectId),   // Standard: heap-allocated string
    // ...
}

// Compiler's choice must be invisible:
// compile("hello") -> Value::Text("hello")  OR  Value::Object(alloc("hello"))
// Both must produce identical behavior everywhere

Leçon 6 : un audit est un transfert de connaissances

Avant l'audit, le codebase de FLIN existait dans un état distribué -- partiellement dans les journaux de session, partiellement dans les données d'entraînement de Claude, partiellement dans le code lui-même, mais pas dans la compréhension complète d'une seule entité. L'audit a changé cela. En lisant chaque ligne, l'auditeur a construit un modèle mental complet du système : comment les modules se connectent, où l'état circule, quels invariants sont maintenus et où ils sont violés.

Ce transfert de connaissances est aussi précieux que les corrections de bugs. Les sessions de développement futures peuvent référencer les résultats de l'audit pour comprendre non seulement ce que fait un morceau de code, mais comment il se rapporte au reste du système. Le graphe de dépendances des modules, le diagramme du pipeline de compilation, les chemins d'exécution clés -- ce sont des aides à la navigation pour un codebase trop grand pour qu'une seule session puisse le contenir en contexte.

Architecture (from the audit):

lib.rs
  lexer/      (5,877 lines)  -- Clean. No issues.
  parser/     (21,735 lines) -- Clean. Well-tested.
  resolver/   (1,858 lines)  -- Small. Stable.
  typechecker/(9,925 lines)  -- Medium issues. Now fixed.
  codegen/    (11,936 lines) -- Had stale TODOs. Now fixed.
  vm/         (61,054 lines) -- Most issues concentrated here.
  server/     (17,908 lines) -- WebSocket gaps. Now fixed.
  database/   (28,395 lines) -- Persistence bugs. Now fixed.
  storage/    (7,866 lines)  -- S3 missing. Now fixed.
  ai/         (2,208 lines)  -- Clean. Small surface.

Leçon 7 : zéro problème de sécurité n'est pas un accident

L'audit a trouvé zéro vulnérabilité de sécurité sur 186 252 lignes. Ce n'était pas de la chance. C'était une conséquence de décisions architecturales prises lors des premières sessions :

  • FLIN n'utilise pas SQL, donc l'injection SQL est structurellement impossible.
  • Le moteur de templates de FLIN échappe la sortie par défaut, donc le XSS nécessite un opt-in explicite.
  • Les opérations sur les fichiers de FLIN valident les chemins contre des répertoires autorisés, donc la traversée de chemin est interceptée.
  • Rust élimine les dépassements de tampon et les use-after-free, donc les bugs de sécurité mémoire n'existent pas.

La leçon est que la sécurité n'est pas principalement une question de codage soigneux. C'est une question de choisir des architectures qui éliminent des catégories entières de vulnérabilités. En évitant SQL, FLIN n'a pas besoin de s'inquiéter de l'injection. En échappant la sortie par défaut, FLIN n'a pas besoin de se rappeler d'échapper. Le code le plus sûr est le code que l'on n'a pas besoin d'écrire.

Regard vers l'avenir

L'audit n'était pas la fin. C'était la transition de la construction vers le durcissement. Les 301 sessions qui ont construit FLIN étaient un acte de création -- désordonné, rapide, itératif, parfois brillant, parfois imparfait. L'audit et ses sessions de correction étaient un acte de discipline -- systématique, approfondi, sans romantisme, essentiel.

FLIN a émergé de l'audit comme un système plus solide. Non pas parce qu'il n'avait pas de bugs -- il avait encore de la marge d'amélioration en fuzzing, vérification de concurrence et tests basés sur les propriétés. Mais parce que chaque bug qu'il avait était désormais connu, documenté et soit corrigé, soit suivi. Les inconnues inconnues avaient été converties en quantités connues. Et les quantités connues peuvent être gérées.

Le prochain article se tourne vers une catégorie spécifique de découvertes d'audit qui méritait sa propre investigation : les appels panic en production et l'effort systématique pour les éliminer du runtime de FLIN.


Ceci est la partie 153 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont conçu et construit un langage de programmation à partir de zéro.

Navigation de la série : - [152] 3 452 tests, zéro échec - [153] Ce que l'audit nous a appris sur la construction d'un langage (vous êtes ici) - [154] Appels panic en production : suivi et élimination

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles