Skip to main content
Image de couverture pour Cartog : Language Server Protocol pour gagner en précision sur un graph de code

Cartog : Language Server Protocol pour gagner en précision sur un graph de code

TL;DR
L’heuristique de résolution d’arêtes plafonne à 25-37 % de précision : homonymes, re-exports et imports dynamiques restent ambigus. En branchant rust-analyzer, pyright, gopls et typescript-language-server via LSP, Cartog passe à 44-81 % selon le langage.
Pour tester directement : documentation · code source.

L’article Tree-sitter et code graph pour mieux naviguer dans le code explique comment Cartog construit un graph dirigé : les nœuds sont des symboles (fonctions, classes, méthodes), les arêtes sont leurs relations (appels, imports, héritage). Pour créer une arête, il faut résoudre chaque référence vers le bon symbole cible.

Une cascade à 6 niveaux (même fichier > chemin d’import > même répertoire > scope parent > unique global > discrimination par kind) fait ce travail rapidement sur un projet entier.

flowchart TD
    A["validate(data)"] --> H["Heuristique 6 niveaux<br>fichier · import · répertoire<br>scope · unique · kind"]
    H -->|"3 homonymes,<br>aucun niveau ne tranche"| U["Non résolue ❌"]

Trois cas fréquents d’échec :

Homonymes : plusieurs fonctions validate() dans des modules différents. L’heuristique « même répertoire » trouve 3 candidats : impossible de trancher.

Re-exports : Python et TypeScript utilisent massivement les re-exports (__init__.py, index.ts). Le chemin d’import ne pointe pas vers le fichier source réel mais vers un barrel file.

Imports dynamiques : getattr(module, func_name)() ou require(variable), aucune trace statique à suivre.

Pour des requêtes comme cartog impact validate --depth 3, une arête non résolue signifie un appelant manquant dans l’analyse d’impact.

Le résultat est incomplet, et l’agent prend des décisions sur une vue partielle du code.

Le problème : on plafonne à 25-37 % de précision selon les langages. Autrement dit, sur 100 appels de fonction extraits du code, seuls 25 à 37 sont correctement reliés à leur définition.
Le reste pointe dans le vide ou n’est pas résolu du tout.

25 % à 37 %, ça paraît faible… Pourquoi ?
Pourtant quand mon IDE renomme une fonction à travers tout le projet, il ne casse rien… Comment fait-il ?

Les IDE interrogent un language server : un compilateur partiel qui maintient un modèle sémantique complet du projet (types, scopes, re-exports, signatures).

Quand vous faites « Go to Definition » dans VS Code, c’est le serveur LSP correspondant (rust-analyzer, pyright, gopls, typescript-language-server…) qui répond.

Le Language Server Protocol est un standard ouvert défini par Microsoft : un même serveur peut alimenter VS Code, Neovim, Helix, Zed…

Plutôt que de réécrire un compilateur par langage, Cartog s’appuie sur ces serveurs existants comme source de vérité pour résoudre ses arêtes.

sequenceDiagram
    participant C as Cartog
    participant LSP as Language Server

    C->>LSP: initialize(rootUri)
    LSP-->>C: capabilities

    Note over C: Pour chaque arête non résolue...

    C->>LSP: textDocument/definition(file, line, col)
    LSP-->>C: targetFile:targetLine

    Note over C: Lookup symbole à cette position
    C->>C: Résoudre edge → target_id

    C->>LSP: shutdown

Le LSP est une feature optionnelle activée par défaut lors de la compilation du binaire. Cartog reste pleinement fonctionnel sans : l’heuristique couvre déjà la majorité des cas.

Le LSP intervient uniquement sur les arêtes que l’heuristique n’a pas su résoudre. Elle vient en complément, pas en remplacement : il n’y a donc pas de dégradation si le serveur est lent ou ne trouve pas la définition.

Au lancement, Cartog détecte les language servers disponibles sur le PATH :

LangageServerCommande détectée
Rustrust-analyzerrust-analyzer
Pythonpyrightpyright-langserver
TypeScript/JStypescript-language-servertypescript-language-server
Gogoplsgopls
Rubyruby-lsp / solargraphruby-lsp, solargraph
Javajdtlsjdtls
PHPintelephense / phpactorintelephense, phpactor

Si aucun server n’est trouvé pour un langage, Cartog continue silencieusement avec l’heuristique seule. Pas d’erreur, pas de dépendance forcée.

flowchart TD
    IDX["Indexation<br>tree-sitter"] --> HEUR["Résolution heuristique<br>6 niveaux"]
    HEUR --> CHECK{"Arêtes non<br>résolues ?"}
    CHECK -->|non| DONE["Graph complet"]
    CHECK -->|oui| LSP_CHECK{"LSP servers<br>disponibles ?"}
    LSP_CHECK -->|non| DONE
    LSP_CHECK -->|oui| LSP_RESOLVE["Résolution LSP<br>textDocument/definition"]
    LSP_RESOLVE --> DONE

    style LSP_RESOLVE fill:#e8f5e9,stroke:#4caf50
    style HEUR fill:#fff3e0,stroke:#ff9800

Le LspManager maintient un client par langage qui est réutilisé pour toutes les arêtes non résolues de ce langage.

Pour chaque arête non résolue, Cartog :

  1. Construit l’URI du fichier source + position (ligne, colonne) de l’appel
  2. Envoie une requête textDocument/definition
  3. Reçoit le fichier et la position de la définition cible
  4. Cherche dans le graph quel symbole occupe cette position
  5. Résout l’arête : target_id = symbole trouvé
sequenceDiagram
    participant C as Cartog
    participant LSP as Language Server
    participant DB as Graph

    loop Pour chaque fichier (séquentiel)
        C->>LSP: textDocument/didOpen(file)

        loop Pour chaque arête non résolue du fichier
            C->>LSP: textDocument/definition(file, ligne, col)

            alt Définition trouvée
                LSP-->>C: targetFile : targetLine
                C->>DB: symbole à (targetFile, targetLine) ?
                DB-->>C: symbol_id
                C->>DB: target_id = symbol_id ✓
            else Pas de réponse / hors projet
                LSP-->>C: ∅
                Note over C: Arête laissée non résolue<br>(pas de dégradation)
            end
        end
    end

Si le server ne répond pas ou ne trouve pas la définition, l’arête reste non résolue, pas de dégradation. Les requêtes sont envoyées séquentiellement par fichier pour respecter le protocole LSP.

LangagePrécision heuristiquePrécision avec LSP
Python~25%~65%
TypeScript~30%~72%
Rust~37%~81%
Go~28%~44%

Ces mesures sont effectuées sur un projet Python de 69 fichiers / 4k lignes, en comparant les arêtes résolues à la vérité terrain obtenue par résolution manuelle.

Les gains varient selon le langage.

Rust obtient les meilleurs résultats : rust-analyzer maintient un modèle sémantique complet du projet (types, traits, impls).

Go est plus modeste car gopls résout moins bien les appels à travers des interfaces satisfaites implicitement (duck typing structurel de Go).

Concrètement, sur une analyse cartog impact validate --depth 3, l’heuristique seule remonte 12 symboles avec 3 arêtes manquantes.
Avec LSP, le même impact retourne 18 symboles : le graph est complet, l’agent a une vue fiable des conséquences d’un changement.

Si le LSP donne une bien meilleure précision, pourquoi Cartog ne l’active pas par défaut partout ?

Parce que le LSP n’est pas gratuit :

AspectHeuristique seuleAvec LSP
Temps d’indexation~1s+10-60s (startup server)
DépendancesAucuneLanguage server sur PATH
Précision25-37%44-81%
FiabilitéDéterministeDépend du server

Le startup du language server est le coût principal (initialisation du workspace, chargement des types). Cartog impose un timeout de 20 secondes sur la phase d’initialisation (variable d’environnement CARTOG_LSP_READY_TIMEOUT_SECS pour ajuster) et surveille les tokens window/workDoneProgress du protocole LSP : si le server ne signale aucun progrès, il est abandonné silencieusement et l’arête reste sur l’heuristique seule.

Pour un usage quotidien (développement actif, re-indexation fréquente), l’heuristique seule est suffisante. Le LSP prend tout son sens pour une analyse d’impact approfondie ou une première indexation complète.

Le graph est maintenant précis mais Cartog re-parse l’intégralité du projet à chaque appel à cartog index : sur 10 000 fichiers, ça devient un frein, surtout en mode watch où chaque sauvegarde déclenche un re-index.

Le développeur modifie un fichier. Pourquoi ré-parser les 9 999 autres ?

Ce sera le sujet du prochain article de la série : indexation incrémentale via IDs stables et Merkle tree.