From db1e9fa24f6f72883df413d062c1899561e2038c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 30 Jul 2026 23:15:18 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20t=C3=A9l=C3=A9m=C3=A9trie=20navigation?= =?UTF-8?q?=20:=20extraire=20les=20commandes=20des=20menus=20=C2=AB=20choi?= =?UTF-8?q?ces=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Code » (et Update, Git, Database…) apparaissait vide : l'extracteur AST ne comprenait que le motif « status == "N": self.methode() » (menu QEMU). Les menus bâtis par « choices = config_file.get_config(...) » + « choices.append( ...) » avec dispatch « str(len(choices)-N) » n'exposaient aucun enfant -> impossible d'ouvrir leurs commandes. Ajout de l'extraction de ce motif : - entrées de CONFIG lues depuis todo.json (rejouées via execute_from_configuration avec l'entrée en kwargs) ; - entrées APPENDÉES (dict littéral OU variable menu_entry=… suivie par n° de ligne) mappées à leur méthode via le dispatch « str(len(choices)-K) » ; - une entrée qui ouvre un sous-menu (ex. Update) est développée récursivement. Validé : Code -> 7 commandes (statut/remiser/formater/SHELL/màj module/débogage + sous-menu Update), méthodes correctes et existantes, TUI monte sans erreur. Les dispatches complexes (ex. « Migration BD » via TodoUpgrade) restent visibles mais non lançables (method None). Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo_telemetry.py | 199 ++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py index a94a8f8..1dcb9d8 100644 --- a/script/todo/todo_telemetry.py +++ b/script/todo/todo_telemetry.py @@ -210,12 +210,192 @@ def _menu_labels(cls) -> dict: return {} +def _config_list(config_key, todo_dir): + """Entrées d'une liste de config de todo.json (ex. « code_from_makefile »), + cherchée à n'importe quel niveau. [] si absente.""" + try: + data = json.loads( + (Path(todo_dir) / "todo.json").read_text(encoding="utf-8") + ) + except (OSError, ValueError): + return [] + found = [] + + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k == config_key and isinstance(v, list): + found.extend(v) + walk(v) + elif isinstance(o, list): + for x in o: + walk(x) + + walk(data) + return found + + +def _len_choices_offset(comp): + """K pour « str(len(choices)) » -> 0 et « str(len(choices) - N) » -> N ; + None si le nœud n'est pas de cette forme.""" + if not ( + isinstance(comp, ast.Call) + and isinstance(comp.func, ast.Name) + and comp.func.id == "str" + and comp.args + ): + return None + arg = comp.args[0] + + def is_len_choices(x): + return ( + isinstance(x, ast.Call) + and isinstance(x.func, ast.Name) + and x.func.id == "len" + and x.args + and isinstance(x.args[0], ast.Name) + and x.args[0].id == "choices" + ) + + if is_len_choices(arg): + return 0 + if ( + isinstance(arg, ast.BinOp) + and isinstance(arg.op, ast.Sub) + and is_len_choices(arg.left) + and isinstance(arg.right, ast.Constant) + ): + return int(arg.right.value) + return None + + +def _dispatch_len(func) -> dict: + """{K: méthode} pour « status == str(len(choices) - K): self.M() ».""" + out = {} + for node in ast.walk(func): + if not isinstance(node, ast.If): + continue + test = node.test + if not ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "status" + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) + ): + continue + k = _len_choices_offset(test.comparators[0]) + if k is None: + continue + method = None + for stmt in node.body: + for n in ast.walk(stmt): + if ( + isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "self" + ): + method = n.func.attr + break + if method: + break + if method: + out[k] = method + return out + + +def _choices_children(func, todo_dir): + """Commandes d'un menu bâti par « choices » (config_file.get_config + + choices.append/extend) avec dispatch « str(len(choices)-N) ». Renvoie une + liste de (label, méthode, kwargs) dans l'ordre affiché, ou None si le motif + ne s'applique pas. Les entrées de CONFIG rejouent via + execute_from_configuration ; les entrées APPENDÉES via leur méthode.""" + def _dict_label(dnode): + d = {} + for k, v in zip(dnode.keys, dnode.values): + if isinstance(k, ast.Constant): + d[k.value] = _str_of(v) + return d.get("prompt_description") or d.get("prompt_description_key") + + # On collecte affectations et append AVEC leur n° de ligne pour rejouer + # dans l'ORDRE (les entrées « menu_entry = {…}; choices.append(menu_entry) » + # réutilisent la même variable -> il faut suivre la dernière valeur). + config_key = None + events = [] # (lineno, kind, payload) + for node in ast.walk(func): + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + ): + var = node.targets[0].id + val = node.value + if isinstance(val, ast.Dict): + events.append((node.lineno, "assign", (var, val))) + elif ( + var == "choices" + and isinstance(val, ast.Call) + and isinstance(val.func, ast.Attribute) + and val.func.attr == "get_config" + and val.args + and isinstance(val.args[0], ast.Constant) + ): + config_key = val.args[0].value + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "choices" + and node.args + ): + events.append((node.lineno, "append", node.args[0])) + events.sort(key=lambda e: e[0]) + var_dict, appended = {}, [] + for _ln, kind, payload in events: + if kind == "assign": + var_dict[payload[0]] = payload[1] + else: # append : arg = Dict littéral ou Name (variable) + arg = payload + dnode = ( + arg + if isinstance(arg, ast.Dict) + else var_dict.get(arg.id) + if isinstance(arg, ast.Name) + else None + ) + if dnode is not None: + lab = _dict_label(dnode) + if lab: + appended.append(lab) + if config_key is None and not appended: + return None + children = [] + for entry in _config_list(config_key, todo_dir) if config_key else []: + lab = ( + entry.get("prompt_description_key") + or entry.get("prompt_description") + or "?" + ) + children.append((lab, "execute_from_configuration", {"instance": entry})) + len_disp = _dispatch_len(func) + n_config = len(children) # figé : `children` grossit dans la boucle + n_total = n_config + len(appended) + for j, lab in enumerate(appended): + pos = n_config + j # 0-based dans la liste globale (stable) + method = len_disp.get(n_total - 1 - pos) # None si non mappé + children.append((lab, method, {})) + return children + + def build_code_tree(todo_path=None) -> dict | None: """Construit l'arbre des menus/commandes EN LISANT le code de todo.py (AST). Chaque menu (méthode de _MENU_LABELS) devient un nœud ; ses branches « status == N » deviennent des sous-menus (si la cible est un menu) ou des commandes (feuilles). None si l'analyse échoue.""" p = Path(todo_path) if todo_path else (Path(__file__).parent / "todo.py") + todo_dir = p.parent # todo.json est à côté de todo.py try: mod = ast.parse(p.read_text(encoding="utf-8")) except (OSError, SyntaxError): @@ -270,6 +450,25 @@ def build_code_tree(todo_path=None) -> dict | None: "section": entry["section"] if entry else None, } ) + # Menus bâtis par « choices » (get_config + append) sans dispatch + # littéral « status == "N" » : Code, Update, Git, Database… + if not disp: + for lab, tmethod, tkwargs in ( + _choices_children(func, todo_dir) or [] + ): + if tmethod in labels: # une entrée qui ouvre un sous-menu + node["children"].append(build(tmethod)) + else: + node["children"].append( + { + "label": lab, + "is_menu": False, + "children": [], + "method": tmethod, + "kwargs": tkwargs, + "section": None, + } + ) seen.discard(method) return node